mirror of
https://github.com/ansible/awx.git
synced 2026-06-10 09:26:18 -02:30
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe1bfd993 | ||
|
|
4f231aea44 | ||
|
|
0a80c91a96 |
55
.github/workflows/_repo-owns-branch.yml
vendored
55
.github/workflows/_repo-owns-branch.yml
vendored
@@ -1,55 +0,0 @@
|
||||
---
|
||||
name: Repo Owns Branch
|
||||
|
||||
# Reusable workflow that determines whether the current repository
|
||||
# owns the current branch for push operations.
|
||||
#
|
||||
# Ownership rules:
|
||||
# - ansible/awx owns: devel, feature_*
|
||||
# - ansible/tower owns: stable-*, release_*
|
||||
# - workflow_dispatch is always allowed
|
||||
#
|
||||
# All other repo/branch combinations are skipped.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
should_run:
|
||||
description: Whether this repo owns the current branch
|
||||
value: ${{ jobs.check.outputs.should_run }}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- name: Check branch ownership
|
||||
id: check
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
EVENT="${{ github.event_name }}"
|
||||
|
||||
if [[ "$EVENT" == "workflow_dispatch" ]]; then
|
||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger — allowed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ansible/awx owns devel and feature_* branches
|
||||
if [[ "$REPO" == "ansible/awx" ]] && [[ "$BRANCH" == "devel" || "$BRANCH" == feature_* ]]; then
|
||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||
echo "Repository '$REPO' owns branch '$BRANCH'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ansible/tower owns stable-* and release_* branches
|
||||
if [[ "$REPO" == "ansible/tower" ]] && [[ "$BRANCH" == stable-* || "$BRANCH" == release_* ]]; then
|
||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||
echo "Repository '$REPO' owns branch '$BRANCH'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "Repository '$REPO' does not own branch '$BRANCH' — skipping"
|
||||
11
.github/workflows/devel_images.yml
vendored
11
.github/workflows/devel_images.yml
vendored
@@ -12,12 +12,7 @@ on:
|
||||
- feature_*
|
||||
- stable-*
|
||||
jobs:
|
||||
check-ownership:
|
||||
uses: ./.github/workflows/_repo-owns-branch.yml
|
||||
|
||||
push-development-images:
|
||||
needs: check-ownership
|
||||
if: needs.check-ownership.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
@@ -35,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
|
||||
|
||||
7
.github/workflows/spec-sync-on-merge.yml
vendored
7
.github/workflows/spec-sync-on-merge.yml
vendored
@@ -16,16 +16,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- devel
|
||||
- 'stable-2.[6-9]'
|
||||
- 'stable-2.[1-9][0-9]'
|
||||
workflow_dispatch: # Allow manual triggering for testing
|
||||
jobs:
|
||||
check-ownership:
|
||||
uses: ./.github/workflows/_repo-owns-branch.yml
|
||||
|
||||
sync-openapi-spec:
|
||||
needs: check-ownership
|
||||
if: needs.check-ownership.outputs.should_run == 'true'
|
||||
name: Sync OpenAPI spec to central repo
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
5
.github/workflows/upload_schema.yml
vendored
5
.github/workflows/upload_schema.yml
vendored
@@ -13,12 +13,7 @@ on:
|
||||
- feature_**
|
||||
- stable-**
|
||||
jobs:
|
||||
check-ownership:
|
||||
uses: ./.github/workflows/_repo-owns-branch.yml
|
||||
|
||||
push:
|
||||
needs: check-ownership
|
||||
if: needs.check-ownership.outputs.should_run == 'true'
|
||||
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:54d9e941748bae94b2154b3b253a985e628751dfa4508a138d9b05f74a3c1ddf
|
||||
- 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.
|
||||
|
||||
39
Makefile
39
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
|
||||
|
||||
@@ -272,10 +272,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)
|
||||
|
||||
@@ -122,6 +122,7 @@ from awx.main.scheduler.task_manager_models import TaskManagerModels
|
||||
from awx.main.redact import UriCleaner, REPLACE_STR
|
||||
from awx.main.signals import update_inventory_computed_fields
|
||||
|
||||
|
||||
from awx.main.validators import vars_validate_or_raise
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
@@ -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'),
|
||||
@@ -961,32 +962,14 @@ class UnifiedJobSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class UnifiedJobListSerializer(UnifiedJobSerializer):
|
||||
# these fields can be included optionally in the response
|
||||
OPTIONAL_INCLUDE_FIELDS = frozenset({'artifacts', 'extra_vars'})
|
||||
|
||||
# these fields are stripped from the response
|
||||
_STRIPPED_FIELDS = frozenset({'job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts', 'extra_vars'})
|
||||
|
||||
class Meta:
|
||||
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts', '-extra_vars')
|
||||
|
||||
# processes the include query param if present
|
||||
def _requested_includes(self):
|
||||
request = self.context.get('request')
|
||||
if request is None:
|
||||
return frozenset()
|
||||
raw = request.query_params.get('include', '')
|
||||
requested = {name.strip() for name in raw.split(',') if name.strip()}
|
||||
|
||||
# only allow the fields listed in OPTIONAL_INCLUDE_FIELDS
|
||||
return frozenset(requested) & self.OPTIONAL_INCLUDE_FIELDS
|
||||
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts')
|
||||
|
||||
def get_field_names(self, declared_fields, info):
|
||||
field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info)
|
||||
# Meta multiple inheritance and -field_name options don't seem to be
|
||||
# taking effect above, so remove the undesired fields here.
|
||||
strip = self._STRIPPED_FIELDS - self._requested_includes()
|
||||
return tuple(x for x in field_names if x not in strip)
|
||||
return tuple(x for x in field_names if x not in ('job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts'))
|
||||
|
||||
def get_types(self):
|
||||
if type(self) is UnifiedJobListSerializer:
|
||||
@@ -1039,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']
|
||||
|
||||
@@ -1855,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:
|
||||
@@ -1958,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()
|
||||
@@ -2114,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}'))
|
||||
|
||||
@@ -2975,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):
|
||||
@@ -4178,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 = {}
|
||||
|
||||
@@ -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
|
||||
@@ -59,13 +58,8 @@ from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
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 (
|
||||
@@ -127,7 +121,6 @@ from awx.api.views.mixin import (
|
||||
RelatedJobsPreventDeleteMixin,
|
||||
UnifiedJobDeletionMixin,
|
||||
NoTruncateMixin,
|
||||
UnifiedJobIncludeMixin,
|
||||
)
|
||||
from awx.api.pagination import UnifiedJobEventPagination
|
||||
from awx.main.utils import set_environ
|
||||
@@ -210,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(),
|
||||
}
|
||||
@@ -802,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)
|
||||
|
||||
@@ -1265,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)
|
||||
|
||||
@@ -1585,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.
|
||||
@@ -1773,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)
|
||||
@@ -1782,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):
|
||||
@@ -1827,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.
|
||||
@@ -1842,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):
|
||||
@@ -1927,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:
|
||||
@@ -1942,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:
|
||||
@@ -1978,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'''
|
||||
@@ -2164,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)
|
||||
@@ -2198,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):
|
||||
@@ -2491,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():
|
||||
@@ -3851,7 +3675,7 @@ class SystemJobTemplateNotificationTemplatesSuccessList(SystemJobTemplateNotific
|
||||
resource_purpose = 'notification templates triggered on system job success'
|
||||
|
||||
|
||||
class JobList(UnifiedJobIncludeMixin, ListAPIView):
|
||||
class JobList(ListAPIView):
|
||||
model = models.Job
|
||||
serializer_class = serializers.JobListSerializer
|
||||
resource_purpose = 'jobs'
|
||||
@@ -4568,7 +4392,7 @@ class UnifiedJobTemplateList(ListAPIView):
|
||||
resource_purpose = 'unified job templates'
|
||||
|
||||
|
||||
class UnifiedJobList(UnifiedJobIncludeMixin, ListAPIView):
|
||||
class UnifiedJobList(ListAPIView):
|
||||
model = models.UnifiedJob
|
||||
serializer_class = serializers.UnifiedJobListSerializer
|
||||
search_fields = ('description', 'name', 'job__playbook')
|
||||
@@ -4871,12 +4695,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)
|
||||
|
||||
@@ -4909,6 +4740,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'
|
||||
@@ -4917,11 +4766,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):
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils import translation
|
||||
from awx.api.generics import APIView, Response
|
||||
from awx.api.permissions import AnalyticsPermission
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.utils import get_awx_version, set_environ
|
||||
from awx.main.utils import get_awx_version
|
||||
from awx.main.utils.analytics_proxy import OIDCClient
|
||||
from rest_framework import status
|
||||
|
||||
@@ -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"})
|
||||
@@ -210,32 +211,31 @@ class AnalyticsGenericView(APIView):
|
||||
return self._error_response(ERROR_UNSUPPORTED_METHOD, method, remote=False, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
url = self._get_analytics_url(request.path)
|
||||
using_subscriptions_credentials = False
|
||||
with set_environ(**settings.AWX_TASK_ENV):
|
||||
try:
|
||||
rh_user = getattr(settings, 'REDHAT_USERNAME', None)
|
||||
rh_password = getattr(settings, 'REDHAT_PASSWORD', None)
|
||||
if not (rh_user and rh_password):
|
||||
rh_user = self._get_setting('SUBSCRIPTIONS_CLIENT_ID', None, ERROR_MISSING_USER)
|
||||
rh_password = self._get_setting('SUBSCRIPTIONS_CLIENT_SECRET', None, ERROR_MISSING_PASSWORD)
|
||||
using_subscriptions_credentials = True
|
||||
try:
|
||||
rh_user = getattr(settings, 'REDHAT_USERNAME', None)
|
||||
rh_password = getattr(settings, 'REDHAT_PASSWORD', None)
|
||||
if not (rh_user and rh_password):
|
||||
rh_user = self._get_setting('SUBSCRIPTIONS_CLIENT_ID', None, ERROR_MISSING_USER)
|
||||
rh_password = self._get_setting('SUBSCRIPTIONS_CLIENT_SECRET', None, ERROR_MISSING_PASSWORD)
|
||||
using_subscriptions_credentials = True
|
||||
|
||||
client = OIDCClient(rh_user, rh_password)
|
||||
response = client.make_request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
verify=settings.INSIGHTS_CERT_PATH,
|
||||
params=getattr(request, 'query_params', {}),
|
||||
json=getattr(request, 'data', {}),
|
||||
timeout=(31, 31),
|
||||
)
|
||||
except requests.RequestException:
|
||||
# subscriptions credentials are not valid for basic auth, so just return 401
|
||||
if using_subscriptions_credentials:
|
||||
response = Response(status=status.HTTP_401_UNAUTHORIZED)
|
||||
else:
|
||||
logger.error("Automation Analytics API request failed, trying base auth method")
|
||||
response = self._base_auth_request(request, method, url, rh_user, rh_password, headers)
|
||||
client = OIDCClient(rh_user, rh_password)
|
||||
response = client.make_request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
verify=settings.INSIGHTS_CERT_PATH,
|
||||
params=getattr(request, 'query_params', {}),
|
||||
json=getattr(request, 'data', {}),
|
||||
timeout=(31, 31),
|
||||
)
|
||||
except requests.RequestException:
|
||||
# subscriptions credentials are not valid for basic auth, so just return 401
|
||||
if using_subscriptions_credentials:
|
||||
response = Response(status=status.HTTP_401_UNAUTHORIZED)
|
||||
else:
|
||||
logger.error("Automation Analytics API request failed, trying base auth method")
|
||||
response = self._base_auth_request(request, method, url, rh_user, rh_password, headers)
|
||||
#
|
||||
# Missing or wrong user/pass
|
||||
#
|
||||
@@ -306,6 +306,7 @@ class AnalyticsAuthorizedView(AnalyticsGenericListView):
|
||||
|
||||
class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView):
|
||||
name = _("Reports")
|
||||
swagger_topic = "Automation Analytics"
|
||||
resource_purpose = 'automation analytics reports'
|
||||
|
||||
|
||||
|
||||
@@ -212,9 +212,3 @@ class NoTruncateMixin(object):
|
||||
if self.request.query_params.get('no_truncate'):
|
||||
context.update(no_truncate=True)
|
||||
return context
|
||||
|
||||
|
||||
class UnifiedJobIncludeMixin(object):
|
||||
# Reserve the name 'include' so we can use it as a query param. Otherwise, the rest-filters backend
|
||||
# would treat it as a model field lookup.
|
||||
rest_filters_reserved_names = ('include',)
|
||||
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
|
||||
@@ -100,6 +100,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
|
||||
|
||||
@@ -31,7 +31,6 @@ def get_dispatcherd_config(for_service: bool = False, mock_publish: bool = False
|
||||
# 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",
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -49,6 +49,10 @@ from awx.main.models import Team, Organization
|
||||
from awx.main.utils import encrypt_field
|
||||
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')
|
||||
@@ -76,6 +80,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
|
||||
@@ -352,15 +396,16 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
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
|
||||
|
||||
@@ -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')})
|
||||
|
||||
@@ -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
|
||||
@@ -386,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()
|
||||
@@ -585,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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1141,22 +1140,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -335,7 +335,9 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
# or labels, because they do not propogate WFJT-->node at all
|
||||
|
||||
# Combine WFJT prompts with node here, WFJT at higher level
|
||||
node_prompts_data.update(wj_prompts_data)
|
||||
# Empty string values on the workflow job (e.g. from IaC setting limit: "")
|
||||
# should not override a node's explicit non-empty prompt value
|
||||
node_prompts_data.update({k: v for k, v in wj_prompts_data.items() if v != ''})
|
||||
accepted_fields, ignored_fields, errors = ujt_obj._accept_or_ignore_job_kwargs(**node_prompts_data)
|
||||
if errors:
|
||||
logger.info(
|
||||
@@ -345,11 +347,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 +368,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 +736,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,
|
||||
|
||||
@@ -241,8 +241,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -99,99 +99,64 @@ 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 = list(hosts_cached_map.keys())
|
||||
hosts_cached = host_qs.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
|
||||
|
||||
# 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):
|
||||
# File is missing. Only interpret this as "ansible cleared facts" if
|
||||
# start_fact_cache actually wrote a file for this host (i.e. the host
|
||||
# had valid, non-expired facts before the job ran). If no file was
|
||||
# ever written, the missing file is expected and not a clear signal.
|
||||
if not hosts_cached_map.get(host.name):
|
||||
log_data['unmodified_ct'] += 1
|
||||
continue
|
||||
|
||||
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 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
|
||||
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} '
|
||||
@@ -204,13 +169,13 @@ def finish_fact_cache(host_qs, artifacts_dir, job_id=None, inventory_id=None, jo
|
||||
else:
|
||||
host.ansible_facts = {}
|
||||
host.ansible_facts_modified = now()
|
||||
clear_hosts.append(host)
|
||||
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
|
||||
|
||||
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')
|
||||
if len(hosts_to_update) >= 100:
|
||||
bulk_update_sorted_by_id(Host, hosts_to_update, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
hosts_to_update = []
|
||||
|
||||
bulk_update_sorted_by_id(Host, hosts_to_update, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
logger.debug(f'Updated {log_data["updated_ct"]} host facts for inventory {inventory_id} in job {job_id}')
|
||||
|
||||
@@ -94,7 +94,7 @@ 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
|
||||
from ansible_base.resource_registry.workload_identity_client import get_workload_identity_client
|
||||
|
||||
logger = logging.getLogger('awx.main.tasks.jobs')
|
||||
|
||||
@@ -168,12 +168,14 @@ def retrieve_workload_identity_jwt(
|
||||
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,
|
||||
)
|
||||
client = get_workload_identity_client()
|
||||
if client is None:
|
||||
raise RuntimeError("Workload identity client is not configured")
|
||||
claims = populate_claims_for_workload(unified_job)
|
||||
kwargs = {"claims": claims, "scope": scope, "audience": audience}
|
||||
if workload_ttl_seconds:
|
||||
kwargs["workload_ttl_seconds"] = workload_ttl_seconds
|
||||
return client.request_workload_jwt(**kwargs).jwt
|
||||
|
||||
|
||||
def with_path_cleanup(f):
|
||||
@@ -228,19 +230,16 @@ class BaseTask(object):
|
||||
# Convert to list to prevent re-evaluation of QuerySet
|
||||
return list(credentials_list)
|
||||
|
||||
def populate_workload_identity_tokens(self, additional_credentials=None):
|
||||
def populate_workload_identity_tokens(self):
|
||||
"""
|
||||
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 credential in self._credentials
|
||||
for src in credential.input_sources.all()
|
||||
if any(
|
||||
field.get('id') == 'workload_identity_token' and field.get('internal')
|
||||
@@ -254,7 +253,7 @@ class BaseTask(object):
|
||||
try:
|
||||
jwt = retrieve_workload_identity_jwt(
|
||||
self.instance,
|
||||
audience=input_src.source_credential.get_input('url'),
|
||||
audience=input_src.source_credential.get_input('jwt_aud'),
|
||||
scope=AutomationControllerJobScope.name,
|
||||
workload_ttl_seconds=workload_ttl,
|
||||
)
|
||||
@@ -1138,11 +1137,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,12 +1157,11 @@ 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'
|
||||
env['ANSIBLE_CALLBACKS_ENABLED'] = 'indirect_instance_count'
|
||||
if 'callbacks_enabled' in config_values:
|
||||
env['ANSIBLE_CALLBACKS_ENABLED'] += ':' + config_values['callbacks_enabled']
|
||||
|
||||
# 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']}"
|
||||
@@ -1331,7 +1330,6 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
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)),
|
||||
@@ -1612,14 +1610,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)
|
||||
@@ -1865,24 +1865,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,4 +1,3 @@
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
from unittest import mock
|
||||
@@ -258,92 +257,3 @@ class TestAnalyticsGenericView:
|
||||
else:
|
||||
# assert mock_base_auth_request not called
|
||||
mock_base_auth_request.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test__send_to_analytics_respects_proxy_env_oidc(self):
|
||||
settings_map = {
|
||||
'INSIGHTS_TRACKING_STATE': True,
|
||||
'AUTOMATION_ANALYTICS_URL': 'https://example.com',
|
||||
'REDHAT_USERNAME': 'redhat_user',
|
||||
'REDHAT_PASSWORD': 'redhat_pass',
|
||||
'SUBSCRIPTIONS_CLIENT_ID': '',
|
||||
'SUBSCRIPTIONS_CLIENT_SECRET': '',
|
||||
'AWX_TASK_ENV': {'HTTPS_PROXY': '192.168.50.100:1234', 'HTTP_PROXY': '192.168.50.100:5678'},
|
||||
}
|
||||
with override_settings(**settings_map):
|
||||
request = RequestFactory().post('/some/path')
|
||||
view = AnalyticsGenericView()
|
||||
|
||||
with mock.patch('awx.api.views.analytics.OIDCClient') as mock_oidc_client:
|
||||
mock_client_instance = mock.Mock()
|
||||
mock_oidc_client.return_value = mock_client_instance
|
||||
|
||||
def _check_env_and_respond(*args, **kwargs):
|
||||
assert os.environ.get('HTTPS_PROXY') == '192.168.50.100:1234'
|
||||
assert os.environ.get('HTTP_PROXY') == '192.168.50.100:5678'
|
||||
return mock.Mock(status_code=200)
|
||||
|
||||
mock_client_instance.make_request.side_effect = _check_env_and_respond
|
||||
response = view._send_to_analytics(request, 'POST')
|
||||
assert response.status_code == 200
|
||||
mock_client_instance.make_request.assert_called_once()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test__send_to_analytics_respects_proxy_env_basic_auth(self):
|
||||
settings_map = {
|
||||
'INSIGHTS_TRACKING_STATE': True,
|
||||
'AUTOMATION_ANALYTICS_URL': 'https://example.com',
|
||||
'REDHAT_USERNAME': 'redhat_user',
|
||||
'REDHAT_PASSWORD': 'redhat_pass',
|
||||
'SUBSCRIPTIONS_CLIENT_ID': '',
|
||||
'SUBSCRIPTIONS_CLIENT_SECRET': '',
|
||||
'AWX_TASK_ENV': {'HTTPS_PROXY': '192.168.50.100:1234'},
|
||||
}
|
||||
with override_settings(**settings_map):
|
||||
request = RequestFactory().post('/some/path')
|
||||
view = AnalyticsGenericView()
|
||||
|
||||
with mock.patch('awx.api.views.analytics.OIDCClient') as mock_oidc_client, mock.patch(
|
||||
'awx.api.views.analytics.AnalyticsGenericView._base_auth_request'
|
||||
) as mock_base_auth:
|
||||
mock_client_instance = mock.Mock()
|
||||
mock_oidc_client.return_value = mock_client_instance
|
||||
mock_client_instance.make_request.side_effect = requests.RequestException("OIDC failed")
|
||||
|
||||
def _check_env_and_respond(*args, **kwargs):
|
||||
assert os.environ.get('HTTPS_PROXY') == '192.168.50.100:1234'
|
||||
return mock.Mock(status_code=200)
|
||||
|
||||
mock_base_auth.side_effect = _check_env_and_respond
|
||||
response = view._send_to_analytics(request, 'POST')
|
||||
assert response.status_code == 200
|
||||
mock_base_auth.assert_called_once()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test__send_to_analytics_restores_env_after_request(self):
|
||||
original_value = os.environ.pop('HTTPS_PROXY', None)
|
||||
settings_map = {
|
||||
'INSIGHTS_TRACKING_STATE': True,
|
||||
'AUTOMATION_ANALYTICS_URL': 'https://example.com',
|
||||
'REDHAT_USERNAME': 'redhat_user',
|
||||
'REDHAT_PASSWORD': 'redhat_pass',
|
||||
'SUBSCRIPTIONS_CLIENT_ID': '',
|
||||
'SUBSCRIPTIONS_CLIENT_SECRET': '',
|
||||
'AWX_TASK_ENV': {'HTTPS_PROXY': '192.168.50.100:1234'},
|
||||
}
|
||||
try:
|
||||
with override_settings(**settings_map):
|
||||
request = RequestFactory().post('/some/path')
|
||||
view = AnalyticsGenericView()
|
||||
|
||||
with mock.patch('awx.api.views.analytics.OIDCClient') as mock_oidc_client:
|
||||
mock_client_instance = mock.Mock()
|
||||
mock_oidc_client.return_value = mock_client_instance
|
||||
mock_client_instance.make_request.return_value = mock.Mock(status_code=200)
|
||||
|
||||
view._send_to_analytics(request, 'POST')
|
||||
|
||||
assert 'HTTPS_PROXY' not in os.environ
|
||||
finally:
|
||||
if original_value is not None:
|
||||
os.environ['HTTPS_PROXY'] = original_value
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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()
|
||||
@@ -145,124 +145,3 @@ def test_delete_ad_hoc_command_in_active_state(ad_hoc_command_factory, delete, a
|
||||
adhoc = ad_hoc_command_factory(initial_state=status)
|
||||
url = reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk})
|
||||
delete(url, None, admin, expect=403)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_with_heavy_fields(job_factory):
|
||||
job = job_factory()
|
||||
job.extra_vars = '{"some_var": "some_value"}'
|
||||
job.artifacts = {"some_artifact": "some_value"}
|
||||
job.save()
|
||||
return job
|
||||
|
||||
|
||||
def _job_result(response, job_id):
|
||||
for row in response.data['results']:
|
||||
if row['id'] == job_id:
|
||||
return row
|
||||
raise AssertionError('job {} not found in {}'.format(job_id, [r['id'] for r in response.data['results']]))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_strips_heavy_fields_by_default(get, admin, job_with_heavy_fields):
|
||||
response = get(reverse('api:unified_job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'artifacts' not in row
|
||||
assert 'extra_vars' not in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_include_artifacts(get, admin, job_with_heavy_fields):
|
||||
response = get(
|
||||
reverse('api:unified_job_list') + '?id={}&include=artifacts'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'artifacts' in row
|
||||
assert 'extra_vars' not in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_include_extra_vars(get, admin, job_with_heavy_fields):
|
||||
response = get(
|
||||
reverse('api:unified_job_list') + '?id={}&include=extra_vars'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'extra_vars' in row
|
||||
assert 'artifacts' not in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_include_both(get, admin, job_with_heavy_fields):
|
||||
response = get(
|
||||
reverse('api:unified_job_list') + '?id={}&include=artifacts,extra_vars'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'artifacts' in row
|
||||
assert 'extra_vars' in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_include_tolerates_whitespace(get, admin, job_with_heavy_fields):
|
||||
response = get(
|
||||
reverse('api:unified_job_list') + '?id={}&include=%20artifacts%20,%20extra_vars%20'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'artifacts' in row
|
||||
assert 'extra_vars' in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_include_ignores_unknown(get, admin, job_with_heavy_fields):
|
||||
response = get(
|
||||
reverse('api:unified_job_list') + '?id={}&include=does_not_exist'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'artifacts' not in row
|
||||
assert 'extra_vars' not in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unified_jobs_list_include_does_not_honor_disallowed(get, admin, job_with_heavy_fields):
|
||||
# event_processing_finished triggers a count(*) on main_jobevent and must
|
||||
# not be re-enabled via the public ?include= param.
|
||||
response = get(
|
||||
reverse('api:unified_job_list') + '?id={}&include=event_processing_finished,job_args,result_traceback'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'event_processing_finished' not in row
|
||||
assert 'job_args' not in row
|
||||
assert 'result_traceback' not in row
|
||||
assert 'artifacts' not in row
|
||||
assert 'extra_vars' not in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jobs_list_strips_heavy_fields_by_default(get, admin, job_with_heavy_fields):
|
||||
response = get(reverse('api:job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'artifacts' not in row
|
||||
assert 'extra_vars' not in row
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jobs_list_include_extra_vars(get, admin, job_with_heavy_fields):
|
||||
response = get(
|
||||
reverse('api:job_list') + '?id={}&include=extra_vars'.format(job_with_heavy_fields.id),
|
||||
admin,
|
||||
expect=200,
|
||||
)
|
||||
row = _job_result(response, job_with_heavy_fields.id)
|
||||
assert 'extra_vars' in row
|
||||
assert 'artifacts' not in row
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,7 @@ from crum import impersonate
|
||||
# AWX
|
||||
from awx.main.models import UnifiedJobTemplate, Job, JobTemplate, WorkflowJobTemplate, Project, WorkflowJob, Schedule, Credential
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.utils.common import get_job_variable_prefixes
|
||||
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -160,13 +160,7 @@ class TestMetaVars:
|
||||
job = Job.objects.create(name='job', created_by=admin_user)
|
||||
job.save()
|
||||
|
||||
user_vars = [
|
||||
'_'.join(x)
|
||||
for x in itertools.product(
|
||||
get_job_variable_prefixes(),
|
||||
['user_name', 'user_id', 'user_email', 'user_first_name', 'user_last_name'],
|
||||
)
|
||||
]
|
||||
user_vars = ['_'.join(x) for x in itertools.product(['tower', 'awx'], ['user_name', 'user_id', 'user_email', 'user_first_name', 'user_last_name'])]
|
||||
|
||||
for key in user_vars:
|
||||
assert key in job.awx_meta_vars()
|
||||
@@ -185,7 +179,7 @@ class TestMetaVars:
|
||||
|
||||
workflow_job.workflow_nodes.create(job=job)
|
||||
data = job.awx_meta_vars()
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
assert data['{}_user_id'.format(name)] == admin_user.id
|
||||
assert data['{}_user_name'.format(name)] == admin_user.username
|
||||
assert data['{}_workflow_job_id'.format(name)] == workflow_job.pk
|
||||
@@ -195,7 +189,7 @@ class TestMetaVars:
|
||||
schedule = Schedule.objects.create(name='job-schedule', rrule='DTSTART:20171129T155939z\nFREQ=MONTHLY', unified_job_template=job_template)
|
||||
job = Job.objects.create(name='fake-job', launch_type='workflow', schedule=schedule, job_template=job_template)
|
||||
data = job.awx_meta_vars()
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
assert data['{}_schedule_id'.format(name)] == schedule.pk
|
||||
assert '{}_user_name'.format(name) not in data
|
||||
|
||||
@@ -207,7 +201,7 @@ class TestMetaVars:
|
||||
job = Job.objects.create(launch_type='workflow')
|
||||
workflow_job.workflow_nodes.create(job=job)
|
||||
result_hash = {}
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
result_hash['{}_job_id'.format(name)] = job.id
|
||||
result_hash['{}_job_launch_type'.format(name)] = 'workflow'
|
||||
result_hash['{}_workflow_job_name'.format(name)] = 'workflow-job'
|
||||
|
||||
@@ -291,6 +291,33 @@ class TestWorkflowJob:
|
||||
assert set(data['labels']) == set(node_labels) # as exception, WFJT labels not applied
|
||||
assert data['limit'] == 'wj_limit'
|
||||
|
||||
def test_node_limit_not_overridden_by_empty_string_wj_limit(self, project, inventory):
|
||||
"""
|
||||
When the workflow job has an empty string limit (e.g., set via IaC with limit: ""),
|
||||
the node-level limit should still be passed to the spawned job, not silently suppressed.
|
||||
"""
|
||||
jt = JobTemplate.objects.create(
|
||||
project=project,
|
||||
inventory=inventory,
|
||||
ask_limit_on_launch=True,
|
||||
)
|
||||
# Simulate a workflow job whose WFJT was created via IaC with `limit: ""`
|
||||
# (e.g. awx.awx.workflow_job_template: ... limit: "")
|
||||
# This stores '' in char_prompts instead of treating it as None/"no limit".
|
||||
wj = WorkflowJob.objects.create(name='test-wf-job')
|
||||
wj.limit = '' # stores {'limit': ''} in char_prompts - the IaC bug scenario
|
||||
wj.save()
|
||||
|
||||
node = WorkflowJobNode.objects.create(workflow_job=wj, unified_job_template=jt)
|
||||
node.limit = 'web_servers'
|
||||
node.save()
|
||||
|
||||
data = node.get_job_kwargs()
|
||||
# The node-level limit should be applied; the WJ's empty string limit is not meaningful
|
||||
assert data.get('limit') == 'web_servers', (
|
||||
"Node-level limit 'web_servers' was not passed to the job. " "Likely caused by an empty string WJ limit overriding the node limit"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestWorkflowJobTemplate:
|
||||
|
||||
@@ -8,8 +8,6 @@ from awx.main.models.jobs import JobTemplate
|
||||
from awx.main.models import Organization, Inventory, WorkflowJob, ExecutionEnvironment, Host
|
||||
from awx.main.scheduler import TaskManager
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('num_hosts, num_queries', [(1, 15), (10, 15)])
|
||||
@@ -447,185 +445,3 @@ def get_inventory_hosts(get, inv_id, use_user):
|
||||
data = get(reverse('api:inventory_hosts_list', kwargs={'pk': inv_id}), use_user, expect=200).data
|
||||
results = [host['id'] for host in data['results']]
|
||||
return results
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_job_launch_respects_settings_limit(job_template, organization, inventory, project, post, patch, get, user):
|
||||
"""Test that bulk job launch respects BULK_JOB_MAX_LAUNCH setting."""
|
||||
normal_user = user('normal_user', False)
|
||||
organization.member_role.members.add(normal_user)
|
||||
|
||||
jt = JobTemplate.objects.create(
|
||||
name='bulk-test-jt',
|
||||
ask_inventory_on_launch=True,
|
||||
project=project,
|
||||
playbook='helloworld.yml',
|
||||
allow_simultaneous=True,
|
||||
)
|
||||
jt.execute_role.members.add(normal_user)
|
||||
inventory.use_role.members.add(normal_user)
|
||||
|
||||
# Test with limit set to 3
|
||||
with override_settings(BULK_JOB_MAX_LAUNCH=3):
|
||||
# Attempt to launch 5 jobs when limit is 3 - should fail
|
||||
jobs = [{'unified_job_template': jt.id, 'inventory': inventory.id} for _ in range(5)]
|
||||
resp = post(
|
||||
reverse('api:bulk_job_launch'),
|
||||
{'name': 'Bulk Job Test', 'jobs': jobs},
|
||||
normal_user,
|
||||
expect=400,
|
||||
)
|
||||
assert 'Number of requested jobs exceeds system setting' in str(resp.data)
|
||||
|
||||
# Test with limit increased to 10
|
||||
with override_settings(BULK_JOB_MAX_LAUNCH=10):
|
||||
# Now launching 5 jobs should succeed
|
||||
jobs = [{'unified_job_template': jt.id, 'inventory': inventory.id} for _ in range(5)]
|
||||
resp = post(
|
||||
reverse('api:bulk_job_launch'),
|
||||
{'name': 'Bulk Job Test', 'jobs': jobs},
|
||||
normal_user,
|
||||
expect=201,
|
||||
)
|
||||
bulk_job = get(resp.data['url'], normal_user, expect=200).data
|
||||
# Verify the workflow job was created
|
||||
assert bulk_job['name'] == 'Bulk Job Test'
|
||||
|
||||
|
||||
# Tests for BulkHostCreateSerializer duplicate detection optimization
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_host_create_duplicate_within_batch(organization, inventory, post, user):
|
||||
"""
|
||||
Test that duplicate hostnames within the same batch are detected.
|
||||
This tests the Counter-based duplicate detection logic.
|
||||
"""
|
||||
inventory.organization = organization
|
||||
inv_admin = user('inventory_admin', False)
|
||||
organization.member_role.members.add(inv_admin)
|
||||
inventory.admin_role.members.add(inv_admin)
|
||||
|
||||
# Try to create hosts where 'duplicate-host' appears twice in the same batch
|
||||
hosts = [
|
||||
{'name': 'unique-host-1'},
|
||||
{'name': 'duplicate-host'},
|
||||
{'name': 'unique-host-2'},
|
||||
{'name': 'duplicate-host'}, # Duplicate within batch
|
||||
]
|
||||
|
||||
response = post(reverse('api:bulk_host_create'), {'inventory': inventory.id, 'hosts': hosts}, inv_admin, expect=400)
|
||||
|
||||
assert 'Hostnames must be unique in an inventory' in response.data['__all__'][0]
|
||||
assert 'duplicate-host' in response.data['__all__'][0]
|
||||
assert Host.objects.filter(inventory=inventory).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_host_create_duplicate_against_existing(organization, inventory, post, user):
|
||||
"""
|
||||
Test that duplicate hostnames against existing inventory hosts are detected.
|
||||
This tests the database query-based duplicate detection.
|
||||
"""
|
||||
inventory.organization = organization
|
||||
inv_admin = user('inventory_admin', False)
|
||||
organization.member_role.members.add(inv_admin)
|
||||
inventory.admin_role.members.add(inv_admin)
|
||||
|
||||
Host.objects.create(name='existing-host-1', inventory=inventory)
|
||||
Host.objects.create(name='existing-host-2', inventory=inventory)
|
||||
|
||||
# Try to create hosts where one already exists
|
||||
hosts = [
|
||||
{'name': 'new-host-1'},
|
||||
{'name': 'existing-host-1'},
|
||||
{'name': 'new-host-2'},
|
||||
]
|
||||
|
||||
response = post(reverse('api:bulk_host_create'), {'inventory': inventory.id, 'hosts': hosts}, inv_admin, expect=400)
|
||||
|
||||
assert 'Hostnames must be unique in an inventory' in response.data['__all__'][0]
|
||||
assert 'existing-host-1' in response.data['__all__'][0]
|
||||
assert Host.objects.filter(inventory=inventory).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_host_create_combined_duplicates(organization, inventory, post, user):
|
||||
"""
|
||||
Test detection of both batch-internal duplicates and duplicates against existing hosts.
|
||||
"""
|
||||
inventory.organization = organization
|
||||
inventory_admin = user('inventory_admin', False)
|
||||
organization.member_role.members.add(inventory_admin)
|
||||
inventory.admin_role.members.add(inventory_admin)
|
||||
|
||||
Host.objects.create(name='existing-host', inventory=inventory)
|
||||
|
||||
# Try to create hosts with both types of duplicates
|
||||
hosts = [
|
||||
{'name': 'new-host'},
|
||||
{'name': 'batch-duplicate'},
|
||||
{'name': 'existing-host'},
|
||||
{'name': 'batch-duplicate'},
|
||||
]
|
||||
|
||||
response = post(reverse('api:bulk_host_create'), {'inventory': inventory.id, 'hosts': hosts}, inventory_admin, expect=400)
|
||||
|
||||
error_message = response.data['__all__'][0]
|
||||
assert 'Hostnames must be unique in an inventory' in error_message
|
||||
assert 'batch-duplicate' in error_message or 'existing-host' in error_message
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_host_create_no_duplicates_success(organization, inventory, post, user):
|
||||
"""
|
||||
Test that hosts are created successfully when there are no duplicates.
|
||||
"""
|
||||
inventory.organization = organization
|
||||
inventory_admin = user('inventory_admin', False)
|
||||
organization.member_role.members.add(inventory_admin)
|
||||
inventory.admin_role.members.add(inventory_admin)
|
||||
|
||||
Host.objects.create(name='existing-host-1', inventory=inventory)
|
||||
Host.objects.create(name='existing-host-2', inventory=inventory)
|
||||
|
||||
# Create new hosts with unique names
|
||||
hosts = [
|
||||
{'name': 'new-host-1'},
|
||||
{'name': 'new-host-2'},
|
||||
{'name': 'new-host-3'},
|
||||
]
|
||||
|
||||
response = post(reverse('api:bulk_host_create'), {'inventory': inventory.id, 'hosts': hosts}, inventory_admin, expect=201)
|
||||
|
||||
assert len(response.data['hosts']) == 3
|
||||
assert Host.objects.filter(inventory=inventory).count() == 5
|
||||
assert Host.objects.filter(inventory=inventory, name='new-host-1').exists()
|
||||
assert Host.objects.filter(inventory=inventory, name='new-host-2').exists()
|
||||
assert Host.objects.filter(inventory=inventory, name='new-host-3').exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_host_create_performance_large_inventory(organization, inventory, post, user, django_assert_max_num_queries):
|
||||
"""
|
||||
Test that duplicate detection is performant and doesn't load all hosts.
|
||||
"""
|
||||
inventory.organization = organization
|
||||
inventory_admin = user('inventory_admin', False)
|
||||
organization.member_role.members.add(inventory_admin)
|
||||
inventory.admin_role.members.add(inventory_admin)
|
||||
|
||||
# Create 10k existing hosts to simulate a reasonably large inventory
|
||||
from django.utils.timezone import now
|
||||
|
||||
_now = now()
|
||||
existing_hosts = [Host(name=f'existing-host-{i}', inventory=inventory, created=_now, modified=_now) for i in range(10000)]
|
||||
Host.objects.bulk_create(existing_hosts)
|
||||
|
||||
new_hosts = [{'name': f'new-host-{i}'} for i in range(10)]
|
||||
|
||||
# The number of queries should be bounded and not scale with inventory size
|
||||
# This should be around 15-20 queries regardless of whether there are 10k or 500k+ existing hosts
|
||||
with django_assert_max_num_queries(20):
|
||||
response = post(reverse('api:bulk_host_create'), {'inventory': inventory.id, 'hosts': new_hosts}, inventory_admin, expect=201)
|
||||
|
||||
assert len(response.data['hosts']) == 10
|
||||
assert Host.objects.filter(inventory=inventory).count() == 10010
|
||||
|
||||
@@ -486,7 +486,7 @@ def test_populate_workload_identity_tokens_with_flag_enabled(job_template_with_c
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'type': 'string', 'label': 'Server URL'},
|
||||
{'id': 'jwt_aud', 'type': 'string', 'label': 'JWT Audience'},
|
||||
{'id': 'workload_identity_token', 'type': 'string', 'label': 'Workload Identity Token', 'secret': True, 'internal': True},
|
||||
]
|
||||
},
|
||||
@@ -495,7 +495,7 @@ def test_populate_workload_identity_tokens_with_flag_enabled(job_template_with_c
|
||||
|
||||
# Create credentials
|
||||
ssh_cred = Credential.objects.create(credential_type=ssh_type, name='ssh-cred')
|
||||
source_cred = Credential.objects.create(credential_type=hashivault_type, name='vault-source', inputs={'url': 'https://vault.example.com'})
|
||||
source_cred = Credential.objects.create(credential_type=hashivault_type, name='vault-source', inputs={'jwt_aud': 'https://vault.example.com'})
|
||||
target_cred = Credential.objects.create(credential_type=ssh_type, name='target-cred', inputs={'username': 'testuser'})
|
||||
|
||||
# Create input source linking source credential to target credential
|
||||
@@ -545,7 +545,7 @@ def test_populate_workload_identity_tokens_passes_workload_ttl_from_job_timeout(
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'type': 'string', 'label': 'Server URL'},
|
||||
{'id': 'jwt_aud', 'type': 'string', 'label': 'JWT Audience'},
|
||||
{'id': 'workload_identity_token', 'type': 'string', 'label': 'Workload Identity Token', 'secret': True, 'internal': True},
|
||||
]
|
||||
},
|
||||
@@ -553,7 +553,7 @@ def test_populate_workload_identity_tokens_passes_workload_ttl_from_job_timeout(
|
||||
hashivault_type.save()
|
||||
|
||||
ssh_cred = Credential.objects.create(credential_type=ssh_type, name='ssh-cred')
|
||||
source_cred = Credential.objects.create(credential_type=hashivault_type, name='vault-source', inputs={'url': 'https://vault.example.com'})
|
||||
source_cred = Credential.objects.create(credential_type=hashivault_type, name='vault-source', inputs={'jwt_aud': 'https://vault.example.com'})
|
||||
target_cred = Credential.objects.create(credential_type=ssh_type, name='target-cred', inputs={'username': 'testuser'})
|
||||
|
||||
CredentialInputSource.objects.create(
|
||||
@@ -595,7 +595,7 @@ def test_populate_workload_identity_tokens_with_flag_disabled(job_template_with_
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'type': 'string', 'label': 'Server URL'},
|
||||
{'id': 'jwt_aud', 'type': 'string', 'label': 'JWT Audience'},
|
||||
{'id': 'workload_identity_token', 'type': 'string', 'label': 'Workload Identity Token', 'secret': True, 'internal': True},
|
||||
]
|
||||
},
|
||||
@@ -647,7 +647,7 @@ def test_populate_workload_identity_tokens_multiple_input_sources_per_credential
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'type': 'string', 'label': 'Server URL'},
|
||||
{'id': 'jwt_aud', 'type': 'string', 'label': 'JWT Audience'},
|
||||
{'id': 'workload_identity_token', 'type': 'string', 'label': 'Workload Identity Token', 'secret': True, 'internal': True},
|
||||
]
|
||||
},
|
||||
@@ -660,7 +660,7 @@ def test_populate_workload_identity_tokens_multiple_input_sources_per_credential
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'type': 'string', 'label': 'Server URL'},
|
||||
{'id': 'jwt_aud', 'type': 'string', 'label': 'JWT Audience'},
|
||||
{'id': 'workload_identity_token', 'type': 'string', 'label': 'Workload Identity Token', 'secret': True, 'internal': True},
|
||||
]
|
||||
},
|
||||
@@ -668,9 +668,11 @@ def test_populate_workload_identity_tokens_multiple_input_sources_per_credential
|
||||
hashivault_ssh_type.save()
|
||||
|
||||
# Create source credentials with different audiences
|
||||
source_cred_kv = Credential.objects.create(credential_type=hashivault_kv_type, name='vault-kv-source', inputs={'url': 'https://vault-kv.example.com'})
|
||||
source_cred_kv = Credential.objects.create(
|
||||
credential_type=hashivault_kv_type, name='vault-kv-source', inputs={'jwt_aud': 'https://vault-kv.example.com'}
|
||||
)
|
||||
source_cred_ssh = Credential.objects.create(
|
||||
credential_type=hashivault_ssh_type, name='vault-ssh-source', inputs={'url': 'https://vault-ssh.example.com'}
|
||||
credential_type=hashivault_ssh_type, name='vault-ssh-source', inputs={'jwt_aud': 'https://vault-ssh.example.com'}
|
||||
)
|
||||
|
||||
# Create target credential that uses both sources for different fields
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from awx.main.tests.live.tests.conftest import wait_for_job
|
||||
|
||||
from awx.main.models import JobTemplate, WorkflowJobTemplate, WorkflowJobTemplateNode
|
||||
|
||||
JT_NAMES = ('artifact-test-first', 'artifact-test-second', 'artifact-test-reader')
|
||||
WFT_NAMES = ('artifact-test-outer-wf', 'artifact-test-inner-wf')
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_nested_workflow_set_stats_precedence(live_tmp_folder, demo_inv, project_factory, default_org):
|
||||
"""Reproducer for set_stats artifacts from an outer workflow leaking into
|
||||
an inner (child) workflow and overriding the inner workflow's own artifacts.
|
||||
|
||||
Outer WF: [job_first] --success--> [inner_wf]
|
||||
Inner WF: [job_second] --success--> [job_reader]
|
||||
|
||||
job_first sets via set_stats:
|
||||
var1: "outer-only" (only source, should propagate through)
|
||||
var2: "should-be-overridden" (will be overridden by job_second)
|
||||
|
||||
job_second sets via set_stats:
|
||||
var2: "from-inner" (should override outer's value)
|
||||
var3: "inner-only" (only source, should be available)
|
||||
|
||||
job_reader runs debug.yml (no set_stats), we inspect its extra_vars:
|
||||
var1 should be "outer-only" - outer artifacts propagate when uncontested
|
||||
var2 should be "from-inner" - inner artifacts override outer (THE BUG)
|
||||
var3 should be "inner-only" - inner-only artifacts propagate normally
|
||||
"""
|
||||
# Clean up resources from prior runs (delete individually for signals)
|
||||
for name in WFT_NAMES:
|
||||
for wft in WorkflowJobTemplate.objects.filter(name=name):
|
||||
wft.delete()
|
||||
for name in JT_NAMES:
|
||||
for jt in JobTemplate.objects.filter(name=name):
|
||||
jt.delete()
|
||||
|
||||
proj = project_factory(scm_url=f'file://{live_tmp_folder}/debug')
|
||||
if proj.current_job:
|
||||
wait_for_job(proj.current_job)
|
||||
|
||||
# job_first: sets var1 (outer-only) and var2 (to be overridden by inner)
|
||||
jt_first = JobTemplate.objects.create(
|
||||
name='artifact-test-first',
|
||||
project=proj,
|
||||
playbook='set_stats.yml',
|
||||
inventory=demo_inv,
|
||||
extra_vars=json.dumps({'stats_data': {'var1': 'outer-only', 'var2': 'should-be-overridden'}}),
|
||||
)
|
||||
# job_second: overrides var2, introduces var3
|
||||
jt_second = JobTemplate.objects.create(
|
||||
name='artifact-test-second',
|
||||
project=proj,
|
||||
playbook='set_stats.yml',
|
||||
inventory=demo_inv,
|
||||
extra_vars=json.dumps({'stats_data': {'var2': 'from-inner', 'var3': 'inner-only'}}),
|
||||
)
|
||||
# job_reader: just runs, we check what extra_vars it receives
|
||||
jt_reader = JobTemplate.objects.create(
|
||||
name='artifact-test-reader',
|
||||
project=proj,
|
||||
playbook='debug.yml',
|
||||
inventory=demo_inv,
|
||||
)
|
||||
|
||||
# Inner WFT: job_second -> job_reader
|
||||
inner_wft = WorkflowJobTemplate.objects.create(name='artifact-test-inner-wf', organization=default_org)
|
||||
inner_node_1 = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=inner_wft,
|
||||
unified_job_template=jt_second,
|
||||
identifier='second',
|
||||
)
|
||||
inner_node_2 = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=inner_wft,
|
||||
unified_job_template=jt_reader,
|
||||
identifier='reader',
|
||||
)
|
||||
inner_node_1.success_nodes.add(inner_node_2)
|
||||
|
||||
# Outer WFT: job_first -> inner_wf
|
||||
outer_wft = WorkflowJobTemplate.objects.create(name='artifact-test-outer-wf', organization=default_org)
|
||||
outer_node_1 = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=outer_wft,
|
||||
unified_job_template=jt_first,
|
||||
identifier='first',
|
||||
)
|
||||
outer_node_2 = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=outer_wft,
|
||||
unified_job_template=inner_wft,
|
||||
identifier='inner',
|
||||
)
|
||||
outer_node_1.success_nodes.add(outer_node_2)
|
||||
|
||||
# Launch and wait
|
||||
outer_wfj = outer_wft.create_unified_job()
|
||||
outer_wfj.signal_start()
|
||||
wait_for_job(outer_wfj, running_timeout=120)
|
||||
|
||||
# Find the reader job inside the inner workflow
|
||||
inner_wf_node = outer_wfj.workflow_job_nodes.get(identifier='inner')
|
||||
inner_wfj = inner_wf_node.job
|
||||
assert inner_wfj is not None, 'Inner workflow job was never created'
|
||||
|
||||
# Check that root node of inner WF (job_second) received outer artifacts
|
||||
second_node = inner_wfj.workflow_job_nodes.get(identifier='second')
|
||||
assert second_node.job is not None, 'Second job was never created'
|
||||
second_extra_vars = json.loads(second_node.job.extra_vars)
|
||||
assert second_extra_vars.get('var1') == 'outer-only', (
|
||||
f'Root node var1: expected "outer-only" (outer artifact should be available to root node), '
|
||||
f'got "{second_extra_vars.get("var1")}". '
|
||||
f'Outer artifacts are not reaching root nodes of child workflows.'
|
||||
)
|
||||
|
||||
reader_node = inner_wfj.workflow_job_nodes.get(identifier='reader')
|
||||
assert reader_node.job is not None, 'Reader job was never created'
|
||||
|
||||
reader_extra_vars = json.loads(reader_node.job.extra_vars)
|
||||
|
||||
# var1: only set by outer job_first, no conflict — should propagate through
|
||||
assert reader_extra_vars.get('var1') == 'outer-only', f'var1: expected "outer-only" (uncontested outer artifact), ' f'got "{reader_extra_vars.get("var1")}"'
|
||||
|
||||
# var2: set by outer as "should-be-overridden", then by inner as "from-inner"
|
||||
# Inner workflow's own ancestor artifacts should take precedence
|
||||
assert reader_extra_vars.get('var2') == 'from-inner', (
|
||||
f'var2: expected "from-inner" (inner workflow artifact should override outer), '
|
||||
f'got "{reader_extra_vars.get("var2")}". '
|
||||
f'Outer workflow artifacts are leaking via wj_special_vars. '
|
||||
f'reader node ancestor_artifacts={reader_node.ancestor_artifacts}'
|
||||
)
|
||||
|
||||
# var3: only set by inner job_second — should propagate normally
|
||||
assert reader_extra_vars.get('var3') == 'inner-only', f'var3: expected "inner-only" (inner-only artifact), ' f'got "{reader_extra_vars.get("var3")}"'
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_workflow_extra_vars_override_artifacts(live_tmp_folder, demo_inv, project_factory, default_org):
|
||||
"""Workflow extra_vars should take precedence over set_stats artifacts
|
||||
within a single (non-nested) workflow.
|
||||
|
||||
WF (extra_vars: my_var="from-wf-extra-vars"):
|
||||
[job_setter] --success--> [job_reader]
|
||||
|
||||
job_setter sets my_var="from-set-stats" via set_stats
|
||||
job_reader should see my_var="from-wf-extra-vars" because workflow
|
||||
extra_vars are higher precedence than ancestor artifacts.
|
||||
"""
|
||||
wft_name = 'artifact-test-wf-extra-vars-precedence'
|
||||
jt_names = ('artifact-test-setter', 'artifact-test-checker')
|
||||
|
||||
for wft in WorkflowJobTemplate.objects.filter(name=wft_name):
|
||||
wft.delete()
|
||||
for name in jt_names:
|
||||
for jt in JobTemplate.objects.filter(name=name):
|
||||
jt.delete()
|
||||
|
||||
proj = project_factory(scm_url=f'file://{live_tmp_folder}/debug')
|
||||
if proj.current_job:
|
||||
wait_for_job(proj.current_job)
|
||||
|
||||
jt_setter = JobTemplate.objects.create(
|
||||
name='artifact-test-setter',
|
||||
project=proj,
|
||||
playbook='set_stats.yml',
|
||||
inventory=demo_inv,
|
||||
extra_vars=json.dumps({'stats_data': {'my_var': 'from-set-stats'}}),
|
||||
)
|
||||
jt_checker = JobTemplate.objects.create(
|
||||
name='artifact-test-checker',
|
||||
project=proj,
|
||||
playbook='debug.yml',
|
||||
inventory=demo_inv,
|
||||
)
|
||||
|
||||
wft = WorkflowJobTemplate.objects.create(
|
||||
name=wft_name,
|
||||
organization=default_org,
|
||||
extra_vars=json.dumps({'my_var': 'from-wf-extra-vars'}),
|
||||
)
|
||||
node_1 = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=wft,
|
||||
unified_job_template=jt_setter,
|
||||
identifier='setter',
|
||||
)
|
||||
node_2 = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=wft,
|
||||
unified_job_template=jt_checker,
|
||||
identifier='checker',
|
||||
)
|
||||
node_1.success_nodes.add(node_2)
|
||||
|
||||
wfj = wft.create_unified_job()
|
||||
wfj.signal_start()
|
||||
wait_for_job(wfj, running_timeout=120)
|
||||
|
||||
checker_node = wfj.workflow_job_nodes.get(identifier='checker')
|
||||
assert checker_node.job is not None, 'Checker job was never created'
|
||||
|
||||
checker_extra_vars = json.loads(checker_node.job.extra_vars)
|
||||
assert checker_extra_vars.get('my_var') == 'from-wf-extra-vars', (
|
||||
f'Expected my_var="from-wf-extra-vars" (workflow extra_vars should override artifacts), '
|
||||
f'got my_var="{checker_extra_vars.get("my_var")}". '
|
||||
f'checker node ancestor_artifacts={checker_node.ancestor_artifacts}'
|
||||
)
|
||||
@@ -1,271 +0,0 @@
|
||||
# Copyright (c) 2026 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
"""Tests for analytics ship() function with mTLS authentication."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from awx.main.analytics.core import ship, _get_cert_upload_url
|
||||
|
||||
|
||||
class TestGetCertUploadUrl:
|
||||
"""Test _get_cert_upload_url() helper function."""
|
||||
|
||||
def test_adds_cert_subdomain(self):
|
||||
"""Test that 'cert.' is added to hostname."""
|
||||
url = 'https://analytics.example.com/api/ingress/v1/upload'
|
||||
result = _get_cert_upload_url(url)
|
||||
assert result == 'https://cert.analytics.example.com/api/ingress/v1/upload'
|
||||
|
||||
def test_preserves_existing_cert_subdomain(self):
|
||||
"""Test that existing 'cert.' subdomain is preserved."""
|
||||
url = 'https://cert.analytics.example.com/api/ingress/v1/upload'
|
||||
result = _get_cert_upload_url(url)
|
||||
assert result == 'https://cert.analytics.example.com/api/ingress/v1/upload'
|
||||
|
||||
|
||||
class TestShipMTLS:
|
||||
"""Test ship() function's mTLS authentication path."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a temporary tarball for testing."""
|
||||
self.temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.tar.gz', delete=False)
|
||||
self.temp_file.write(b'test tarball content')
|
||||
self.temp_file.close()
|
||||
self.tarball_path = self.temp_file.name
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up temporary tarball."""
|
||||
if os.path.exists(self.tarball_path):
|
||||
os.unlink(self.tarball_path)
|
||||
|
||||
@override_settings(
|
||||
AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload',
|
||||
INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz',
|
||||
INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt',
|
||||
REDHAT_USERNAME='test_user',
|
||||
REDHAT_PASSWORD='test_pass', # NOSONAR
|
||||
AWX_TASK_ENV={},
|
||||
)
|
||||
@mock.patch('awx.main.analytics.core.get_awx_http_client_headers')
|
||||
@mock.patch('awx.main.analytics.core._temp_cert_files')
|
||||
@mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate')
|
||||
@mock.patch('awx.main.analytics.core.requests.Session')
|
||||
def test_ship_with_mtls_success(self, mock_session_class, mock_get_cert, mock_temp_files, mock_headers):
|
||||
"""Test successful upload with mTLS certificate authentication."""
|
||||
# Mock headers to avoid database access
|
||||
mock_headers.return_value = {'Content-Type': 'application/json'}
|
||||
|
||||
# Mock certificate retrieval
|
||||
mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data')
|
||||
|
||||
# Mock temp files context manager
|
||||
mock_temp_files.return_value.__enter__.return_value = ('/tmp/cert.pem', '/tmp/key.pem')
|
||||
mock_temp_files.return_value.__exit__.return_value = None
|
||||
|
||||
# Mock successful mTLS response
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_session = mock.Mock()
|
||||
mock_session.headers = {}
|
||||
mock_session.post.return_value = mock_response
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
result = ship(self.tarball_path)
|
||||
|
||||
assert result is True
|
||||
mock_get_cert.assert_called_once()
|
||||
mock_temp_files.assert_called_once_with('cert-pem-data', 'key-pem-data')
|
||||
mock_session.post.assert_called_once()
|
||||
|
||||
# Verify cert URL is used (cert. subdomain added)
|
||||
call_args = mock_session.post.call_args
|
||||
assert call_args[0][0] == 'https://cert.analytics.example.com/api/ingress/v1/upload'
|
||||
|
||||
# Verify mTLS cert was used
|
||||
call_kwargs = call_args[1]
|
||||
assert call_kwargs['cert'] == ('/tmp/cert.pem', '/tmp/key.pem')
|
||||
|
||||
@override_settings(
|
||||
AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload',
|
||||
INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz',
|
||||
INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt',
|
||||
REDHAT_USERNAME='test_user',
|
||||
REDHAT_PASSWORD='test_pass', # NOSONAR
|
||||
AWX_TASK_ENV={},
|
||||
)
|
||||
@mock.patch('awx.main.analytics.core.get_awx_http_client_headers')
|
||||
@mock.patch('awx.main.analytics.core.OIDCClient')
|
||||
@mock.patch('awx.main.analytics.core._temp_cert_files')
|
||||
@mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate')
|
||||
@mock.patch('awx.main.analytics.core.requests.Session')
|
||||
def test_ship_mtls_fallback_to_oidc_on_cert_failure(self, mock_session_class, mock_get_cert, mock_temp_files, mock_oidc_client, mock_headers):
|
||||
"""Test fallback to OIDC auth when mTLS cert authentication fails."""
|
||||
# Mock headers to avoid database access
|
||||
mock_headers.return_value = {'Content-Type': 'application/json'}
|
||||
|
||||
# Mock certificate retrieval
|
||||
mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data')
|
||||
|
||||
# Mock temp files context manager
|
||||
mock_temp_files.return_value.__enter__.return_value = ('/tmp/cert.pem', '/tmp/key.pem')
|
||||
mock_temp_files.return_value.__exit__.return_value = None
|
||||
|
||||
# Mock failed mTLS response (401 Unauthorized)
|
||||
mock_mtls_response = mock.Mock()
|
||||
mock_mtls_response.status_code = 401
|
||||
mock_session = mock.Mock()
|
||||
mock_session.headers = {}
|
||||
mock_session.post.return_value = mock_mtls_response
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
# Mock successful OIDC response
|
||||
mock_oidc_response = mock.Mock()
|
||||
mock_oidc_response.status_code = 200
|
||||
mock_oidc_instance = mock.Mock()
|
||||
mock_oidc_instance.make_request.return_value = mock_oidc_response
|
||||
mock_oidc_client.return_value = mock_oidc_instance
|
||||
|
||||
result = ship(self.tarball_path)
|
||||
|
||||
assert result is True
|
||||
# Both mTLS and OIDC should be attempted
|
||||
assert mock_session.post.call_count == 1
|
||||
mock_oidc_instance.make_request.assert_called_once()
|
||||
|
||||
# Verify mTLS used cert URL
|
||||
mtls_call_args = mock_session.post.call_args
|
||||
assert mtls_call_args[0][0] == 'https://cert.analytics.example.com/api/ingress/v1/upload'
|
||||
|
||||
# Verify OIDC used original URL
|
||||
oidc_call_args = mock_oidc_instance.make_request.call_args
|
||||
assert oidc_call_args[0][1] == 'https://analytics.example.com/api/ingress/v1/upload'
|
||||
|
||||
@override_settings(
|
||||
AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload',
|
||||
INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz',
|
||||
INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt',
|
||||
REDHAT_USERNAME='test_user',
|
||||
REDHAT_PASSWORD='test_pass', # NOSONAR
|
||||
AWX_TASK_ENV={},
|
||||
)
|
||||
@mock.patch('awx.main.analytics.core.get_awx_http_client_headers')
|
||||
@mock.patch('awx.main.analytics.core._temp_cert_files')
|
||||
@mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate')
|
||||
@mock.patch('awx.main.analytics.core.OIDCClient')
|
||||
@mock.patch('awx.main.analytics.core.requests.Session')
|
||||
def test_ship_mtls_exception_fallback_to_oidc(self, mock_session_class, mock_oidc_client, mock_get_cert, mock_temp_files, mock_headers):
|
||||
"""Test fallback to OIDC auth when mTLS raises an exception."""
|
||||
# Mock headers to avoid database access
|
||||
mock_headers.return_value = {'Content-Type': 'application/json'}
|
||||
|
||||
# Mock certificate retrieval
|
||||
mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data')
|
||||
|
||||
# Mock temp files context manager raising an exception
|
||||
mock_temp_files.return_value.__enter__.side_effect = OSError('Temp file creation failed')
|
||||
|
||||
# Mock successful OIDC response
|
||||
mock_oidc_response = mock.Mock()
|
||||
mock_oidc_response.status_code = 200
|
||||
mock_oidc_instance = mock.Mock()
|
||||
mock_oidc_instance.make_request.return_value = mock_oidc_response
|
||||
mock_oidc_client.return_value = mock_oidc_instance
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session.headers = {}
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
result = ship(self.tarball_path)
|
||||
|
||||
assert result is True
|
||||
# mTLS should fail, OIDC should succeed
|
||||
mock_oidc_instance.make_request.assert_called_once()
|
||||
|
||||
@override_settings(
|
||||
AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload',
|
||||
INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz',
|
||||
INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt',
|
||||
REDHAT_USERNAME='test_user',
|
||||
REDHAT_PASSWORD='test_pass', # NOSONAR
|
||||
AWX_TASK_ENV={},
|
||||
)
|
||||
@mock.patch('awx.main.analytics.core.get_awx_http_client_headers')
|
||||
@mock.patch('awx.main.analytics.core.OIDCClient')
|
||||
@mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate')
|
||||
@mock.patch('awx.main.analytics.core.requests.Session')
|
||||
def test_ship_no_certificate_available(self, mock_session_class, mock_get_cert, mock_oidc_client, mock_headers):
|
||||
"""Test ship() when no Candlepin certificate is available."""
|
||||
# Mock headers to avoid database access
|
||||
mock_headers.return_value = {'Content-Type': 'application/json'}
|
||||
|
||||
# Mock no certificate available
|
||||
mock_get_cert.return_value = (None, None)
|
||||
|
||||
# Mock successful OIDC response
|
||||
mock_oidc_response = mock.Mock()
|
||||
mock_oidc_response.status_code = 200
|
||||
mock_oidc_instance = mock.Mock()
|
||||
mock_oidc_instance.make_request.return_value = mock_oidc_response
|
||||
mock_oidc_client.return_value = mock_oidc_instance
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session.headers = {}
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
result = ship(self.tarball_path)
|
||||
|
||||
assert result is True
|
||||
# Should skip mTLS and go straight to OIDC
|
||||
mock_oidc_instance.make_request.assert_called_once()
|
||||
|
||||
@override_settings(
|
||||
AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload',
|
||||
INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz',
|
||||
INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt',
|
||||
REDHAT_USERNAME='test_user',
|
||||
REDHAT_PASSWORD='test_pass', # NOSONAR
|
||||
AWX_TASK_ENV={},
|
||||
)
|
||||
@mock.patch('awx.main.analytics.core.get_awx_http_client_headers')
|
||||
@mock.patch('awx.main.analytics.core.OIDCClient')
|
||||
@mock.patch('awx.main.analytics.core._temp_cert_files')
|
||||
@mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate')
|
||||
@mock.patch('awx.main.analytics.core.requests.Session')
|
||||
def test_ship_both_auth_methods_fail(self, mock_session_class, mock_get_cert, mock_temp_files, mock_oidc_client, mock_headers):
|
||||
"""Test ship() when both mTLS and OIDC authentication fail."""
|
||||
# Mock headers to avoid database access
|
||||
mock_headers.return_value = {'Content-Type': 'application/json'}
|
||||
|
||||
# Mock certificate retrieval
|
||||
mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data')
|
||||
|
||||
# Mock temp files context manager
|
||||
mock_temp_files.return_value.__enter__.return_value = ('/tmp/cert.pem', '/tmp/key.pem')
|
||||
mock_temp_files.return_value.__exit__.return_value = None
|
||||
|
||||
# Mock failed mTLS response
|
||||
mock_mtls_response = mock.Mock()
|
||||
mock_mtls_response.status_code = 401
|
||||
mock_session = mock.Mock()
|
||||
mock_session.headers = {}
|
||||
mock_session.post.return_value = mock_mtls_response
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
# Mock failed OIDC response
|
||||
mock_oidc_response = mock.Mock()
|
||||
mock_oidc_response.status_code = 403
|
||||
mock_oidc_response.text = 'Forbidden'
|
||||
mock_oidc_instance = mock.Mock()
|
||||
mock_oidc_instance.make_request.return_value = mock_oidc_response
|
||||
mock_oidc_client.return_value = mock_oidc_instance
|
||||
|
||||
result = ship(self.tarball_path)
|
||||
|
||||
assert result is False
|
||||
mock_session.post.assert_called_once()
|
||||
mock_oidc_instance.make_request.assert_called_once()
|
||||
@@ -39,7 +39,7 @@ def test_unified_job_detail_exclusive_fields():
|
||||
For each type, assert that the only fields allowed to be exclusive to
|
||||
detail view are the allowed types
|
||||
"""
|
||||
allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts', 'extra_vars'))
|
||||
allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts'))
|
||||
for cls in UnifiedJob.__subclasses__():
|
||||
list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__))
|
||||
detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__))
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# Copyright (c) 2026 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
"""Tests for candlepin_cert management command."""
|
||||
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.test.utils import override_settings
|
||||
|
||||
|
||||
class TestCandlepinCertCommand:
|
||||
"""Tests for candlepin_cert management command."""
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_registration_to_db')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.resolve_registration_credentials')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
)
|
||||
def test_register_success(self, mock_fetch_cert, mock_resolve_creds, mock_client_class, mock_save_reg):
|
||||
"""Test successful registration."""
|
||||
# No existing cert
|
||||
mock_fetch_cert.return_value = (None, None, None)
|
||||
|
||||
# Valid credentials
|
||||
mock_resolve_creds.return_value = ('test_user', 'test_pass', 'test_org', 'install-uuid', None)
|
||||
|
||||
# Mock successful registration
|
||||
mock_client = mock.Mock()
|
||||
mock_client.register_consumer.return_value = ('cert-pem', 'key-pem', 'consumer-uuid')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Mock successful save
|
||||
mock_save_reg.return_value = True
|
||||
|
||||
out = StringIO()
|
||||
call_command('candlepin_cert', 'register', stdout=out, stderr=StringIO())
|
||||
|
||||
output = out.getvalue()
|
||||
assert 'Registered successfully' in output
|
||||
assert 'consumer-uuid' in output
|
||||
|
||||
mock_client.register_consumer.assert_called_once_with('test_user', 'test_pass', 'test_org', install_uuid='install-uuid')
|
||||
mock_save_reg.assert_called_once_with('cert-pem', 'key-pem', 'consumer-uuid')
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
def test_register_already_registered_without_force(self, mock_fetch_cert):
|
||||
"""Test registration fails when cert already exists and --force not provided."""
|
||||
# Existing cert
|
||||
mock_fetch_cert.return_value = ('existing-cert', 'existing-key', 'existing-uuid')
|
||||
|
||||
out = StringIO()
|
||||
call_command('candlepin_cert', 'register', stdout=out, stderr=StringIO())
|
||||
|
||||
output = out.getvalue()
|
||||
assert 'already stored' in output
|
||||
assert '--force' in output
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_registration_to_db')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.resolve_registration_credentials')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
)
|
||||
def test_register_with_force_flag(self, mock_fetch_cert, mock_resolve_creds, mock_client_class, mock_save_reg):
|
||||
"""Test registration succeeds with --force even when cert exists."""
|
||||
# Existing cert
|
||||
mock_fetch_cert.return_value = ('existing-cert', 'existing-key', 'existing-uuid')
|
||||
|
||||
# Valid credentials
|
||||
mock_resolve_creds.return_value = ('test_user', 'test_pass', 'test_org', 'install-uuid', None)
|
||||
|
||||
# Mock successful registration
|
||||
mock_client = mock.Mock()
|
||||
mock_client.register_consumer.return_value = ('new-cert-pem', 'new-key-pem', 'new-consumer-uuid')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Mock successful save
|
||||
mock_save_reg.return_value = True
|
||||
|
||||
out = StringIO()
|
||||
call_command('candlepin_cert', 'register', '--force', stdout=out, stderr=StringIO())
|
||||
|
||||
output = out.getvalue()
|
||||
assert 'Registered successfully' in output
|
||||
|
||||
mock_client.register_consumer.assert_called_once()
|
||||
mock_save_reg.assert_called_once_with('new-cert-pem', 'new-key-pem', 'new-consumer-uuid')
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.resolve_registration_credentials')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
def test_register_missing_credentials(self, mock_fetch_cert, mock_resolve_creds):
|
||||
"""Test registration fails when credentials are missing."""
|
||||
mock_fetch_cert.return_value = (None, None, None)
|
||||
|
||||
# Missing credentials
|
||||
mock_resolve_creds.return_value = (None, None, None, None, ['username', 'password'])
|
||||
|
||||
err = StringIO()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
call_command('candlepin_cert', 'register', stderr=err)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
error_output = err.getvalue()
|
||||
assert 'Missing required value' in error_output
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_cert_to_db')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.parse_cert')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90,
|
||||
)
|
||||
def test_renew_success(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class, mock_save_cert):
|
||||
"""Test successful certificate renewal."""
|
||||
# Existing cert
|
||||
mock_fetch_cert.return_value = ('old-cert', 'old-key', 'consumer-uuid')
|
||||
|
||||
# Parse cert returns metadata
|
||||
mock_parse_cert.side_effect = [
|
||||
{'serial': '123', 'cn': 'test', 'not_after': '2026-06-01', 'days_remaining': 10}, # Current cert
|
||||
{'serial': '456', 'cn': 'test', 'not_after': '2027-06-01', 'days_remaining': 365}, # Renewed cert
|
||||
]
|
||||
|
||||
# Renewal needed
|
||||
mock_needs_renewal.return_value = True
|
||||
|
||||
# Mock successful check-in and renewal
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client.regenerate_cert.return_value = ('new-cert', 'new-key')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
mock_save_cert.return_value = True
|
||||
|
||||
out = StringIO()
|
||||
call_command('candlepin_cert', 'renew', stdout=out, stderr=StringIO())
|
||||
|
||||
output = out.getvalue()
|
||||
assert 'Check-in successful' in output
|
||||
assert 'Certificate renewed successfully' in output
|
||||
assert 'saved to database' in output
|
||||
|
||||
mock_client.checkin.assert_called_once_with('consumer-uuid', 'old-cert', 'old-key')
|
||||
mock_client.regenerate_cert.assert_called_once()
|
||||
mock_save_cert.assert_called_once_with('new-cert', 'new-key')
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
def test_renew_no_cert_in_db(self, mock_fetch_cert):
|
||||
"""Test renew fails when no certificate exists in database."""
|
||||
mock_fetch_cert.return_value = (None, None, None)
|
||||
|
||||
err = StringIO()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
call_command('candlepin_cert', 'renew', stderr=err)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
error_output = err.getvalue()
|
||||
assert 'No Candlepin identity certificate found' in error_output
|
||||
assert 'Run the register subcommand first' in error_output
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.parse_cert')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90,
|
||||
)
|
||||
def test_renew_not_needed(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class):
|
||||
"""Test renew when certificate is still valid and renewal not needed."""
|
||||
mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid')
|
||||
|
||||
# Parse cert returns healthy cert
|
||||
mock_parse_cert.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 200}
|
||||
|
||||
# Renewal not needed
|
||||
mock_needs_renewal.return_value = False
|
||||
|
||||
# Mock successful check-in
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
out = StringIO()
|
||||
call_command('candlepin_cert', 'renew', stdout=out, stderr=StringIO())
|
||||
|
||||
output = out.getvalue()
|
||||
assert 'Check-in successful' in output
|
||||
assert 'No renewal needed' in output
|
||||
|
||||
mock_client.checkin.assert_called_once()
|
||||
mock_client.regenerate_cert.assert_not_called()
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_cert_to_db')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.parse_cert')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90,
|
||||
)
|
||||
def test_renew_with_force_flag(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class, mock_save_cert):
|
||||
"""Test renew --force renews even when not needed."""
|
||||
mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid')
|
||||
|
||||
# Parse cert
|
||||
mock_parse_cert.side_effect = [
|
||||
{'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 200}, # Current cert (healthy)
|
||||
{'serial': '456', 'cn': 'test', 'not_after': '2027-06-01', 'days_remaining': 365}, # New cert
|
||||
]
|
||||
|
||||
# Would not need renewal without --force
|
||||
mock_needs_renewal.return_value = False
|
||||
|
||||
# Mock successful operations
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client.regenerate_cert.return_value = ('new-cert', 'new-key')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
mock_save_cert.return_value = True
|
||||
|
||||
out = StringIO()
|
||||
call_command('candlepin_cert', 'renew', '--force', stdout=out, stderr=StringIO())
|
||||
|
||||
output = out.getvalue()
|
||||
assert 'forced via --force' in output
|
||||
assert 'Certificate renewed successfully' in output
|
||||
|
||||
mock_client.regenerate_cert.assert_called_once()
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.parse_cert')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90,
|
||||
)
|
||||
def test_renew_checkin_failure(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class):
|
||||
"""Test renew handles check-in failure gracefully."""
|
||||
mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid')
|
||||
|
||||
mock_parse_cert.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 100}
|
||||
mock_needs_renewal.return_value = False # Not needed for renewal, just testing check-in failure
|
||||
|
||||
# Mock failed check-in
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = False
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
err = StringIO()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
call_command('candlepin_cert', 'renew', stderr=err)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
error_output = err.getvalue()
|
||||
assert 'Check-in with Candlepin failed' in error_output
|
||||
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.parse_cert')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal')
|
||||
@mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db')
|
||||
@override_settings(
|
||||
AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com',
|
||||
AWX_ANALYTICS_CANDLEPIN_CA=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None,
|
||||
AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90,
|
||||
)
|
||||
def test_renew_regenerate_cert_failure(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class):
|
||||
"""Test renew handles certificate regeneration failure."""
|
||||
mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid')
|
||||
|
||||
mock_parse_cert.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2026-06-01', 'days_remaining': 10}
|
||||
mock_needs_renewal.return_value = True
|
||||
|
||||
# Mock successful check-in but failed regeneration
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client.regenerate_cert.side_effect = Exception('Certificate regeneration failed')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
err = StringIO()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
call_command('candlepin_cert', 'renew', stderr=err)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
error_output = err.getvalue()
|
||||
assert 'Certificate renewal failed' in error_output
|
||||
@@ -112,9 +112,7 @@ def test_finish_job_fact_cache_clear(hosts, mocker, ref_time, tmpdir):
|
||||
os.remove(os.path.join(fact_cache_dir, hosts[1].name))
|
||||
|
||||
hosts_qs = mock.MagicMock()
|
||||
# The new code calls host_qs.filter(name__in=...).select_related('inventory')
|
||||
# Only hosts[1] needs clearing (its file was removed), so return just that host
|
||||
hosts_qs.filter.return_value.select_related.return_value = [hosts[1]]
|
||||
hosts_qs.filter.return_value.order_by.return_value.iterator.return_value = iter(hosts)
|
||||
|
||||
finish_fact_cache(hosts_qs, artifacts_dir=artifacts_dir, inventory_id=inventory_id)
|
||||
|
||||
@@ -147,8 +145,10 @@ def test_finish_job_fact_cache_with_bad_data(hosts, mocker, tmpdir):
|
||||
os.utime(filepath, (new_modification_time, new_modification_time))
|
||||
|
||||
hosts_qs = mock.MagicMock()
|
||||
hosts_qs.filter.return_value.order_by.return_value.iterator.return_value = iter(hosts)
|
||||
|
||||
finish_fact_cache(hosts_qs, artifacts_dir=artifacts_dir, inventory_id=inventory_id)
|
||||
|
||||
# Invalid JSON should be skipped — no hosts updated, bulk_update never called
|
||||
bulk_update.assert_not_called()
|
||||
# Invalid JSON should be skipped — no hosts updated
|
||||
updated_hosts = bulk_update.call_args[0][1]
|
||||
assert updated_hosts == []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.models import UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, WorkflowApprovalTemplate, Job, User, Project, JobTemplate, Inventory
|
||||
from awx.main.utils.common import get_job_variable_prefixes
|
||||
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
||||
|
||||
|
||||
def test_incorrectly_formatted_variables():
|
||||
@@ -50,7 +50,7 @@ class TestMetaVars:
|
||||
maker = User(username='joe', pk=47, id=47)
|
||||
inv = Inventory(name='example-inv', id=45)
|
||||
result_hash = {}
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
result_hash['{}_job_id'.format(name)] = 42
|
||||
result_hash['{}_job_launch_type'.format(name)] = 'manual'
|
||||
result_hash['{}_user_name'.format(name)] = 'joe'
|
||||
@@ -75,48 +75,8 @@ class TestMetaVars:
|
||||
project=Project(name='jobs-sync', scm_revision='12345444'),
|
||||
job_template=JobTemplate(name='jobs-jt', id=92, pk=92),
|
||||
).awx_meta_vars()
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
assert data['{}_project_revision'.format(name)] == '12345444'
|
||||
assert '{}_job_template_id'.format(name) in data
|
||||
assert data['{}_job_template_id'.format(name)] == 92
|
||||
assert data['{}_job_template_name'.format(name)] == 'jobs-jt'
|
||||
|
||||
|
||||
class TestGetJobVariablePrefixes:
|
||||
"""Tests for the get_job_variable_prefixes() helper function."""
|
||||
|
||||
def test_default_returns_both(self):
|
||||
from django.conf import settings
|
||||
|
||||
with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', True, create=True):
|
||||
assert get_job_variable_prefixes() == ['awx', 'tower']
|
||||
|
||||
def test_disabled_returns_tower_only(self):
|
||||
from django.conf import settings
|
||||
|
||||
with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', False, create=True):
|
||||
assert get_job_variable_prefixes() == ['tower']
|
||||
|
||||
def test_fallback_when_setting_not_available(self):
|
||||
"""When setting is not available, falls back to both prefixes for backward compatibility."""
|
||||
fake_settings = mock.MagicMock(spec=[])
|
||||
with mock.patch('django.conf.settings', fake_settings):
|
||||
assert get_job_variable_prefixes() == ['awx', 'tower']
|
||||
|
||||
def test_job_metavars_both_prefixes(self):
|
||||
"""With INCLUDE_DEPRECATED_AWX_VAR_PREFIX=True, both awx_ and tower_ variables."""
|
||||
from django.conf import settings
|
||||
|
||||
with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', True, create=True):
|
||||
data = Job(name='fake-job', pk=1, id=1, launch_type='manual').awx_meta_vars()
|
||||
assert 'awx_job_id' in data
|
||||
assert 'tower_job_id' in data
|
||||
|
||||
def test_job_metavars_tower_only(self):
|
||||
"""With INCLUDE_DEPRECATED_AWX_VAR_PREFIX=False, only tower_ prefixed variables."""
|
||||
from django.conf import settings
|
||||
|
||||
with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', False, create=True):
|
||||
data = Job(name='fake-job', pk=1, id=1, launch_type='manual').awx_meta_vars()
|
||||
assert 'tower_job_id' in data
|
||||
assert 'awx_job_id' not in data
|
||||
|
||||
@@ -83,15 +83,11 @@ def test_pre_post_run_hook_facts(mock_create_partition, mock_facts_settings, pri
|
||||
host1 = mock.MagicMock(spec=Host, id=1, name='host1', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=inventory)
|
||||
host2 = mock.MagicMock(spec=Host, id=2, name='host2', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=inventory)
|
||||
|
||||
# Mock hosts queryset — must support .only().filter().order_by().iterator() chain
|
||||
# Mock hosts queryset
|
||||
hosts = [host1, host2]
|
||||
qs_hosts = mock.MagicMock(spec=QuerySet)
|
||||
qs_hosts._result_cache = hosts
|
||||
qs_hosts.__iter__ = lambda self: iter(self._result_cache)
|
||||
qs_hosts.only.return_value = qs_hosts
|
||||
qs_hosts.filter.return_value = qs_hosts
|
||||
qs_hosts.order_by.return_value = qs_hosts
|
||||
qs_hosts.iterator.side_effect = lambda: iter(qs_hosts._result_cache)
|
||||
qs_hosts.only.return_value = hosts
|
||||
qs_hosts.count.side_effect = lambda: len(qs_hosts._result_cache)
|
||||
inventory.hosts = qs_hosts
|
||||
|
||||
@@ -158,12 +154,9 @@ def test_pre_post_run_hook_facts_deleted_sliced(
|
||||
host.inventory = mock_inventory
|
||||
hosts.append(host)
|
||||
|
||||
# Mock inventory.hosts behavior — must support .only().filter().order_by().iterator() chain
|
||||
# Mock inventory.hosts behavior
|
||||
mock_qs_hosts = mock.MagicMock()
|
||||
mock_qs_hosts.only.return_value = mock_qs_hosts
|
||||
mock_qs_hosts.filter.return_value = mock_qs_hosts
|
||||
mock_qs_hosts.order_by.return_value = mock_qs_hosts
|
||||
mock_qs_hosts.iterator.side_effect = lambda: iter(hosts)
|
||||
mock_qs_hosts.only.return_value = hosts
|
||||
mock_qs_hosts.count.return_value = 999
|
||||
mock_inventory.hosts = mock_qs_hosts
|
||||
|
||||
@@ -480,7 +473,7 @@ def test_populate_claims_for_adhoc_command(workload_attrs, expected_claims):
|
||||
assert claims == expected_claims
|
||||
|
||||
|
||||
@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client')
|
||||
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
|
||||
def test_retrieve_workload_identity_jwt_returns_jwt_from_client(mock_get_client):
|
||||
"""retrieve_workload_identity_jwt returns the JWT string from the client."""
|
||||
mock_client = mock.MagicMock()
|
||||
@@ -509,7 +502,7 @@ def test_retrieve_workload_identity_jwt_returns_jwt_from_client(mock_get_client)
|
||||
assert call_kwargs['claims'][AutomationControllerJobScope.CLAIM_JOB_NAME] == 'Test Job'
|
||||
|
||||
|
||||
@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client')
|
||||
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
|
||||
def test_retrieve_workload_identity_jwt_passes_audience_and_scope(mock_get_client):
|
||||
"""retrieve_workload_identity_jwt passes audience and scope to the client."""
|
||||
mock_client = mock.MagicMock()
|
||||
@@ -525,7 +518,7 @@ def test_retrieve_workload_identity_jwt_passes_audience_and_scope(mock_get_clien
|
||||
mock_client.request_workload_jwt.assert_called_once_with(claims={'job_id': 1}, scope=scope, audience=audience)
|
||||
|
||||
|
||||
@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client')
|
||||
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
|
||||
def test_retrieve_workload_identity_jwt_passes_workload_ttl(mock_get_client):
|
||||
"""retrieve_workload_identity_jwt passes workload_ttl_seconds when provided."""
|
||||
mock_client = mock.Mock()
|
||||
@@ -549,7 +542,7 @@ def test_retrieve_workload_identity_jwt_passes_workload_ttl(mock_get_client):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client')
|
||||
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
|
||||
def test_retrieve_workload_identity_jwt_raises_when_client_not_configured(mock_get_client):
|
||||
"""retrieve_workload_identity_jwt raises RuntimeError when client is None."""
|
||||
mock_get_client.return_value = None
|
||||
@@ -597,67 +590,3 @@ def test_populate_workload_identity_tokens_passes_get_instance_timeout_to_client
|
||||
scope=AutomationControllerJobScope.name,
|
||||
workload_ttl_seconds=expected_ttl,
|
||||
)
|
||||
|
||||
|
||||
class TestRunInventoryUpdatePopulateWorkloadIdentityTokens:
|
||||
"""Tests for RunInventoryUpdate.populate_workload_identity_tokens."""
|
||||
|
||||
def test_cloud_credential_passed_as_additional_credential(self):
|
||||
"""The cloud credential is forwarded to super().populate_workload_identity_tokens via additional_credentials."""
|
||||
cloud_cred = mock.MagicMock(name='cloud_cred')
|
||||
cloud_cred.context = {}
|
||||
|
||||
task = jobs.RunInventoryUpdate()
|
||||
task.instance = mock.MagicMock()
|
||||
task.instance.get_cloud_credential.return_value = cloud_cred
|
||||
task._credentials = []
|
||||
|
||||
with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens') as mock_super:
|
||||
task.populate_workload_identity_tokens()
|
||||
|
||||
mock_super.assert_called_once_with(additional_credentials=[cloud_cred])
|
||||
|
||||
def test_no_cloud_credential_calls_super_with_none(self):
|
||||
"""When there is no cloud credential, super() is called with additional_credentials=None."""
|
||||
task = jobs.RunInventoryUpdate()
|
||||
task.instance = mock.MagicMock()
|
||||
task.instance.get_cloud_credential.return_value = None
|
||||
task._credentials = []
|
||||
|
||||
with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens') as mock_super:
|
||||
task.populate_workload_identity_tokens()
|
||||
|
||||
mock_super.assert_called_once_with(additional_credentials=None)
|
||||
|
||||
def test_additional_credentials_combined_with_cloud_credential(self):
|
||||
"""Caller-supplied additional_credentials are combined with the cloud credential."""
|
||||
cloud_cred = mock.MagicMock(name='cloud_cred')
|
||||
cloud_cred.context = {}
|
||||
extra_cred = mock.MagicMock(name='extra_cred')
|
||||
|
||||
task = jobs.RunInventoryUpdate()
|
||||
task.instance = mock.MagicMock()
|
||||
task.instance.get_cloud_credential.return_value = cloud_cred
|
||||
task._credentials = []
|
||||
|
||||
with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens') as mock_super:
|
||||
task.populate_workload_identity_tokens(additional_credentials=[extra_cred])
|
||||
|
||||
mock_super.assert_called_once_with(additional_credentials=[extra_cred, cloud_cred])
|
||||
|
||||
def test_cloud_credential_override_after_context_set(self):
|
||||
"""After OIDC processing, get_cloud_credential is overridden on the instance when context is populated."""
|
||||
cloud_cred = mock.MagicMock(name='cloud_cred')
|
||||
# Simulate that super().populate_workload_identity_tokens populates context
|
||||
cloud_cred.context = {'workload_identity_token': 'eyJ.test.jwt'}
|
||||
|
||||
task = jobs.RunInventoryUpdate()
|
||||
task.instance = mock.MagicMock()
|
||||
task.instance.get_cloud_credential.return_value = cloud_cred
|
||||
task._credentials = []
|
||||
|
||||
with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens'):
|
||||
task.populate_workload_identity_tokens()
|
||||
|
||||
# The instance's get_cloud_credential should now return the same object with context
|
||||
assert task.instance.get_cloud_credential() is cloud_cred
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.tasks.callback import RunnerCallback, try_load_query_file
|
||||
from awx.main.tasks.callback import RunnerCallback
|
||||
from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -55,102 +50,3 @@ def test_special_ansible_runner_message(mock_me):
|
||||
'Traceback:\ngot an unexpected keyword argument\nFile: bar.py\n'
|
||||
f'{ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE}'
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_ANSIBLE_DATA = {
|
||||
'installed_collections': {
|
||||
'ansible.builtin': {'version': '2.16.0'},
|
||||
'community.general': {'version': '8.0.0', 'host_query': 'SELECT * FROM hosts'},
|
||||
},
|
||||
'ansible_version': '2.16.0',
|
||||
}
|
||||
|
||||
|
||||
class TestTryLoadQueryFile:
|
||||
def test_loads_file_without_feature_flag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, 'ansible_data.json')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(SAMPLE_ANSIBLE_DATA, f)
|
||||
|
||||
with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False):
|
||||
success, data = try_load_query_file(tmpdir)
|
||||
|
||||
assert success is True
|
||||
assert data['ansible_version'] == '2.16.0'
|
||||
assert 'ansible.builtin' in data['installed_collections']
|
||||
|
||||
def test_loads_file_with_feature_flag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, 'ansible_data.json')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(SAMPLE_ANSIBLE_DATA, f)
|
||||
|
||||
with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=True):
|
||||
success, data = try_load_query_file(tmpdir)
|
||||
|
||||
assert success is True
|
||||
assert data == SAMPLE_ANSIBLE_DATA
|
||||
|
||||
def test_returns_false_when_file_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
success, data = try_load_query_file(tmpdir)
|
||||
|
||||
assert success is False
|
||||
assert data is None
|
||||
|
||||
|
||||
class TestArtifactsHandler:
|
||||
def test_always_persists_metadata_when_flag_off(self, mock_me):
|
||||
rc = RunnerCallback()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, 'ansible_data.json')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(SAMPLE_ANSIBLE_DATA, f)
|
||||
|
||||
with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False):
|
||||
rc.artifacts_handler(tmpdir)
|
||||
|
||||
assert rc.extra_update_fields['installed_collections'] == SAMPLE_ANSIBLE_DATA['installed_collections']
|
||||
assert rc.extra_update_fields['ansible_version'] == '2.16.0'
|
||||
assert 'event_queries_processed' not in rc.extra_update_fields
|
||||
assert rc.artifacts_processed is True
|
||||
|
||||
@mock.patch('awx.main.tasks.callback.EventQuery')
|
||||
def test_creates_event_queries_when_flag_on(self, mock_event_query, mock_me):
|
||||
rc = RunnerCallback()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, 'ansible_data.json')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(SAMPLE_ANSIBLE_DATA, f)
|
||||
|
||||
with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=True):
|
||||
rc.artifacts_handler(tmpdir)
|
||||
|
||||
assert rc.extra_update_fields['installed_collections'] == SAMPLE_ANSIBLE_DATA['installed_collections']
|
||||
assert rc.extra_update_fields['ansible_version'] == '2.16.0'
|
||||
assert rc.extra_update_fields['event_queries_processed'] is False
|
||||
mock_event_query.assert_called_once()
|
||||
|
||||
@mock.patch('awx.main.tasks.callback.EventQuery')
|
||||
def test_no_event_queries_when_flag_off(self, mock_event_query, mock_me):
|
||||
rc = RunnerCallback()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, 'ansible_data.json')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(SAMPLE_ANSIBLE_DATA, f)
|
||||
|
||||
with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False):
|
||||
rc.artifacts_handler(tmpdir)
|
||||
|
||||
mock_event_query.assert_not_called()
|
||||
|
||||
def test_handles_missing_artifact_file(self, mock_me):
|
||||
rc = RunnerCallback()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False):
|
||||
rc.artifacts_handler(tmpdir)
|
||||
|
||||
assert 'installed_collections' not in rc.extra_update_fields
|
||||
assert 'ansible_version' not in rc.extra_update_fields
|
||||
assert rc.artifacts_processed is True
|
||||
|
||||
@@ -39,13 +39,6 @@ def create_queries_dir_mock(file_lookup_func):
|
||||
class MockCallbackBase:
|
||||
def __init__(self):
|
||||
self._display = mock.MagicMock()
|
||||
self._plugin_options = {}
|
||||
|
||||
def get_option(self, key):
|
||||
return self._plugin_options.get(key)
|
||||
|
||||
def set_option(self, key, value):
|
||||
self._plugin_options[key] = value
|
||||
|
||||
def v2_playbook_on_stats(self, stats):
|
||||
pass
|
||||
@@ -296,7 +289,6 @@ class TestExternalQueryDiscovery:
|
||||
|
||||
callback = CallbackModule()
|
||||
callback._display = mock.Mock()
|
||||
callback.set_option('collect_host_queries', True)
|
||||
|
||||
with mock.patch('builtins.open', mock.mock_open()):
|
||||
with mock.patch('json.dumps', return_value='{}'):
|
||||
@@ -326,7 +318,6 @@ class TestExternalQueryDiscovery:
|
||||
|
||||
callback = CallbackModule()
|
||||
callback._display = mock.Mock()
|
||||
callback.set_option('collect_host_queries', True)
|
||||
|
||||
with mock.patch('builtins.open', mock.mock_open()):
|
||||
with mock.patch('json.dumps', return_value='{}'):
|
||||
@@ -351,7 +342,6 @@ class TestExternalQueryDiscovery:
|
||||
|
||||
callback = CallbackModule()
|
||||
callback._display = mock.Mock()
|
||||
callback.set_option('collect_host_queries', True)
|
||||
|
||||
with mock.patch('builtins.open', mock.mock_open()):
|
||||
with mock.patch('json.dumps', return_value='{}'):
|
||||
@@ -382,7 +372,6 @@ class TestExternalQueryDiscovery:
|
||||
|
||||
callback = CallbackModule()
|
||||
callback._display = mock.Mock()
|
||||
callback.set_option('collect_host_queries', True)
|
||||
|
||||
with mock.patch('builtins.open', mock.mock_open()):
|
||||
with mock.patch('json.dumps', return_value='{}'):
|
||||
@@ -393,28 +382,6 @@ class TestExternalQueryDiscovery:
|
||||
assert '4.1.0' in call_args
|
||||
assert 'community.vmware' in call_args
|
||||
|
||||
@mock.patch('awx.playbooks.library.indirect_instance_count.list_collections')
|
||||
@mock.patch('awx.playbooks.library.indirect_instance_count.files')
|
||||
@mock.patch('awx.playbooks.library.indirect_instance_count.find_external_query_with_fallback')
|
||||
@mock.patch.dict('os.environ', {'AWX_ISOLATED_DATA_DIR': '/tmp/artifacts'})
|
||||
def test_queries_not_collected_when_option_disabled(self, mock_fallback, mock_files, mock_list_collections):
|
||||
"""Host query scanning is skipped when collect_host_queries is disabled."""
|
||||
from awx.playbooks.library.indirect_instance_count import CallbackModule
|
||||
|
||||
mock_list_collections.return_value = [mock.Mock(namespace='demo', name='query', ver='1.0.0', fqcn='demo.query')]
|
||||
|
||||
callback = CallbackModule()
|
||||
callback._display = mock.Mock()
|
||||
callback.set_option('collect_host_queries', False)
|
||||
|
||||
with mock.patch('builtins.open', mock.mock_open()):
|
||||
with mock.patch('json.dumps', return_value='{}'):
|
||||
callback.v2_playbook_on_stats(mock.Mock())
|
||||
|
||||
mock_list_collections.assert_called_once()
|
||||
mock_files.assert_not_called()
|
||||
mock_fallback.assert_not_called()
|
||||
|
||||
|
||||
class TestPrivateDataDirIntegration:
|
||||
"""Tests for vendor collection copying (AC7.10-AC7.11)."""
|
||||
|
||||
@@ -37,7 +37,7 @@ from awx.main.utils import encrypt_field, encrypt_value
|
||||
from awx.main.utils.safe_yaml import SafeLoader
|
||||
|
||||
from awx.main.utils.licensing import Licenser
|
||||
from awx.main.utils.common import get_job_variable_prefixes
|
||||
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
||||
|
||||
from receptorctl.socket_interface import ReceptorControl
|
||||
|
||||
@@ -372,12 +372,12 @@ class TestExtraVarSanitation(TestJobExecution):
|
||||
extra_vars = yaml.load(fd, Loader=SafeLoader)
|
||||
|
||||
# ensure that strings are marked as unsafe
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
for variable_name in ['_job_template_name', '_user_name', '_job_launch_type', '_project_revision', '_inventory_name']:
|
||||
assert hasattr(extra_vars['{}{}'.format(name, variable_name)], '__UNSAFE__')
|
||||
|
||||
# ensure that non-strings are marked as safe
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
for variable_name in ['_job_template_id', '_job_id', '_user_id', '_inventory_id']:
|
||||
assert not hasattr(extra_vars['{}{}'.format(name, variable_name)], '__UNSAFE__')
|
||||
|
||||
@@ -524,7 +524,7 @@ class TestGenericRun:
|
||||
call_args, _ = task._write_extra_vars_file.call_args_list[0]
|
||||
|
||||
private_data_dir, extra_vars, safe_dict = call_args
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
assert extra_vars['{}_user_id'.format(name)] == 123
|
||||
assert extra_vars['{}_user_name'.format(name)] == "angry-spud"
|
||||
|
||||
@@ -615,7 +615,7 @@ class TestAdhocRun(TestJobExecution):
|
||||
call_args, _ = task._write_extra_vars_file.call_args_list[0]
|
||||
|
||||
private_data_dir, extra_vars = call_args
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
assert extra_vars['{}_user_id'.format(name)] == 123
|
||||
assert extra_vars['{}_user_name'.format(name)] == "angry-spud"
|
||||
|
||||
@@ -918,81 +918,6 @@ class TestJobCredentials(TestJobExecution):
|
||||
assert env['FOO'] == 'BAR'
|
||||
|
||||
|
||||
class TestCallbacksEnabled(TestJobExecution):
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_flag_enabled(self):
|
||||
with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=False):
|
||||
yield
|
||||
|
||||
def test_callbacks_enabled_default(self, patch_Job, private_data_dir, execution_environment, mock_me):
|
||||
job = Job(project=Project(), inventory=Inventory())
|
||||
job.execution_environment = execution_environment
|
||||
|
||||
task = jobs.RunJob()
|
||||
task.instance = job
|
||||
task._write_extra_vars_file = mock.Mock()
|
||||
|
||||
with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True):
|
||||
env = task.build_env(job, private_data_dir)
|
||||
|
||||
assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count'
|
||||
|
||||
def test_callbacks_enabled_preserves_user_config(self, patch_Job, private_data_dir, execution_environment, mock_me):
|
||||
job = Job(project=Project(), inventory=Inventory())
|
||||
job.execution_environment = execution_environment
|
||||
|
||||
task = jobs.RunJob()
|
||||
task.instance = job
|
||||
task._write_extra_vars_file = mock.Mock()
|
||||
|
||||
with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True):
|
||||
with mock.patch('awx.main.tasks.jobs.read_ansible_config', return_value={'callbacks_enabled': 'custom_callback,another_callback'}):
|
||||
env = task.build_env(job, private_data_dir)
|
||||
|
||||
assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count,custom_callback,another_callback'
|
||||
|
||||
def test_callbacks_enabled_uses_comma_delimiter(self, patch_Job, private_data_dir, execution_environment, mock_me):
|
||||
job = Job(project=Project(), inventory=Inventory())
|
||||
job.execution_environment = execution_environment
|
||||
|
||||
task = jobs.RunJob()
|
||||
task.instance = job
|
||||
task._write_extra_vars_file = mock.Mock()
|
||||
|
||||
with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True):
|
||||
with mock.patch('awx.main.tasks.jobs.read_ansible_config', return_value={'callbacks_enabled': 'my_callback'}):
|
||||
env = task.build_env(job, private_data_dir)
|
||||
|
||||
assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count,my_callback'
|
||||
|
||||
def test_collect_host_queries_set_when_flag_on(self, patch_Job, private_data_dir, execution_environment, mock_me):
|
||||
job = Job(project=Project(), inventory=Inventory())
|
||||
job.execution_environment = execution_environment
|
||||
|
||||
task = jobs.RunJob()
|
||||
task.instance = job
|
||||
task._write_extra_vars_file = mock.Mock()
|
||||
|
||||
with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True):
|
||||
with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=True):
|
||||
env = task.build_env(job, private_data_dir)
|
||||
|
||||
assert env['AWX_COLLECT_HOST_QUERIES'] == '1'
|
||||
|
||||
def test_collect_host_queries_not_set_when_flag_off(self, patch_Job, private_data_dir, execution_environment, mock_me):
|
||||
job = Job(project=Project(), inventory=Inventory())
|
||||
job.execution_environment = execution_environment
|
||||
|
||||
task = jobs.RunJob()
|
||||
task.instance = job
|
||||
task._write_extra_vars_file = mock.Mock()
|
||||
|
||||
with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True):
|
||||
env = task.build_env(job, private_data_dir)
|
||||
|
||||
assert 'AWX_COLLECT_HOST_QUERIES' not in env
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_Organization")
|
||||
class TestProjectUpdateGalaxyCredentials(TestJobExecution):
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
# Copyright (c) 2026 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.utils.candlepin import (
|
||||
_discover_org,
|
||||
_fetch_candlepin_cert_from_db,
|
||||
_fetch_registration_credentials_from_db,
|
||||
_save_candlepin_cert_to_db,
|
||||
_save_candlepin_registration_to_db,
|
||||
_register_candlepin_consumer,
|
||||
_run_candlepin_lifecycle,
|
||||
get_or_generate_candlepin_certificate,
|
||||
resolve_registration_credentials,
|
||||
)
|
||||
|
||||
|
||||
class TestCandlepinCertificateRegistration:
|
||||
"""Tests for Candlepin integration in certificate registration module."""
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.requests.get')
|
||||
@mock.patch('awx.main.utils.candlepin.get_candlepin_ca')
|
||||
def test_discover_org_success(self, mock_get_ca, mock_requests_get):
|
||||
"""Test successful organization discovery."""
|
||||
mock_get_ca.return_value = '/path/to/ca.pem'
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = [
|
||||
{'key': 'test_org', 'displayName': 'Test Organization'},
|
||||
{'key': 'other_org', 'displayName': 'Other Organization'},
|
||||
]
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
org = _discover_org('https://candlepin.example.com', 'test_user', 'test_pass')
|
||||
|
||||
assert org == 'test_org'
|
||||
mock_requests_get.assert_called_once_with(
|
||||
'https://candlepin.example.com/users/test_user/owners',
|
||||
auth=('test_user', 'test_pass'),
|
||||
verify='/path/to/ca.pem',
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.requests.get')
|
||||
@mock.patch('awx.main.utils.candlepin.get_candlepin_ca')
|
||||
def test_discover_org_no_ca(self, mock_get_ca, mock_requests_get):
|
||||
"""Test organization discovery without custom CA (uses system certs)."""
|
||||
mock_get_ca.return_value = None
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = [{'key': 'test_org', 'displayName': 'Test Organization'}]
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
org = _discover_org('https://candlepin.example.com', 'test_user', 'test_pass')
|
||||
|
||||
assert org == 'test_org'
|
||||
# Should use True for verify when no CA is configured
|
||||
mock_requests_get.assert_called_once_with(
|
||||
'https://candlepin.example.com/users/test_user/owners',
|
||||
auth=('test_user', 'test_pass'),
|
||||
verify=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.requests.get')
|
||||
def test_discover_org_no_verify_tls(self, mock_requests_get):
|
||||
"""Test organization discovery with TLS verification disabled."""
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = [{'key': 'test_org', 'displayName': 'Test Organization'}]
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
org = _discover_org('https://candlepin.example.com', 'test_user', 'test_pass', verify_tls=False)
|
||||
|
||||
assert org == 'test_org'
|
||||
# Should use False for verify when verify_tls=False
|
||||
mock_requests_get.assert_called_once_with(
|
||||
'https://candlepin.example.com/users/test_user/owners',
|
||||
auth=('test_user', 'test_pass'),
|
||||
verify=False,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.settings')
|
||||
def test_fetch_candlepin_cert_from_db(self, mock_settings):
|
||||
"""Test fetching Candlepin cert from conf_settings."""
|
||||
mock_settings.CANDLEPIN_CONSUMER_UUID = 'test-uuid'
|
||||
mock_settings.CANDLEPIN_CERT_PEM = 'cert-pem-data'
|
||||
mock_settings.CANDLEPIN_KEY_PEM = 'key-pem-data'
|
||||
|
||||
cert, key, uuid = _fetch_candlepin_cert_from_db()
|
||||
|
||||
assert cert == 'cert-pem-data'
|
||||
assert key == 'key-pem-data'
|
||||
assert uuid == 'test-uuid'
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._discover_org')
|
||||
@mock.patch('awx.main.utils.candlepin.settings')
|
||||
def test_fetch_registration_credentials_from_db(self, mock_settings, mock_discover_org):
|
||||
"""Test fetching registration credentials from settings.
|
||||
|
||||
When both REDHAT and SUBSCRIPTIONS credentials exist, REDHAT takes priority
|
||||
for both authentication and org discovery.
|
||||
"""
|
||||
mock_settings.REDHAT_USERNAME = 'test_user'
|
||||
mock_settings.REDHAT_PASSWORD = 'test_pass'
|
||||
mock_settings.INSTALL_UUID = 'test-install-uuid'
|
||||
mock_settings.SUBSCRIPTIONS_USERNAME = 'subs_user'
|
||||
mock_settings.SUBSCRIPTIONS_PASSWORD = 'subs_pass'
|
||||
mock_discover_org.return_value = 'test_org'
|
||||
|
||||
username, password, org, install_uuid = _fetch_registration_credentials_from_db()
|
||||
|
||||
assert username == 'test_user'
|
||||
assert password == 'test_pass'
|
||||
assert org == 'test_org'
|
||||
assert install_uuid == 'test-install-uuid'
|
||||
# Verify _discover_org was called with REDHAT credentials (takes priority)
|
||||
assert mock_discover_org.call_count == 1
|
||||
args = mock_discover_org.call_args[0]
|
||||
assert args[1] == 'test_user' # REDHAT_USERNAME (selected)
|
||||
assert args[2] == 'test_pass' # REDHAT_PASSWORD (selected)
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._discover_org')
|
||||
@mock.patch('awx.main.utils.candlepin.settings')
|
||||
def test_fetch_registration_credentials_no_verify_tls(self, mock_settings, mock_discover_org):
|
||||
"""Test fetching credentials passes verify_tls=False to _discover_org.
|
||||
|
||||
Also verifies that selected credentials (REDHAT in this case) are used for org discovery.
|
||||
"""
|
||||
mock_settings.REDHAT_USERNAME = 'test_user'
|
||||
mock_settings.REDHAT_PASSWORD = 'test_pass'
|
||||
mock_settings.INSTALL_UUID = 'test-install-uuid'
|
||||
mock_settings.SUBSCRIPTIONS_USERNAME = 'subs_user'
|
||||
mock_settings.SUBSCRIPTIONS_PASSWORD = 'subs_pass'
|
||||
mock_discover_org.return_value = 'test_org'
|
||||
|
||||
username, password, org, install_uuid = _fetch_registration_credentials_from_db(verify_tls=False)
|
||||
|
||||
assert username == 'test_user'
|
||||
assert password == 'test_pass'
|
||||
assert org == 'test_org'
|
||||
assert install_uuid == 'test-install-uuid'
|
||||
# Verify _discover_org was called with verify_tls=False and REDHAT credentials
|
||||
mock_discover_org.assert_called_once()
|
||||
call_args = mock_discover_org.call_args
|
||||
assert call_args[0][1] == 'test_user' # REDHAT_USERNAME (selected)
|
||||
assert call_args[0][2] == 'test_pass' # REDHAT_PASSWORD (selected)
|
||||
call_kwargs = call_args[1]
|
||||
assert call_kwargs['verify_tls'] is False
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db')
|
||||
def test_resolve_registration_credentials_no_overrides(self, mock_fetch):
|
||||
"""Test resolve_registration_credentials with no overrides."""
|
||||
mock_fetch.return_value = ('db_user', 'db_pass', 'db_org', 'install-uuid')
|
||||
|
||||
username, password, org, install_uuid, errors = resolve_registration_credentials()
|
||||
|
||||
assert username == 'db_user'
|
||||
assert password == 'db_pass'
|
||||
assert org == 'db_org'
|
||||
assert install_uuid == 'install-uuid'
|
||||
assert errors is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db')
|
||||
def test_resolve_registration_credentials_with_overrides(self, mock_fetch):
|
||||
"""Test resolve_registration_credentials with CLI overrides."""
|
||||
mock_fetch.return_value = ('db_user', 'db_pass', 'db_org', 'install-uuid')
|
||||
|
||||
username, password, org, install_uuid, errors = resolve_registration_credentials(
|
||||
username_override='cli_user', password_override='cli_pass', org_override='cli_org'
|
||||
)
|
||||
|
||||
assert username == 'cli_user'
|
||||
assert password == 'cli_pass'
|
||||
assert org == 'cli_org'
|
||||
assert install_uuid == 'install-uuid'
|
||||
assert errors is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db')
|
||||
def test_resolve_registration_credentials_verify_tls_false(self, mock_fetch):
|
||||
"""Test resolve_registration_credentials passes verify_tls=False to fetch function."""
|
||||
mock_fetch.return_value = ('db_user', 'db_pass', 'db_org', 'install-uuid')
|
||||
|
||||
username, password, org, install_uuid, errors = resolve_registration_credentials(verify_tls=False)
|
||||
|
||||
# Verify _fetch_registration_credentials_from_db was called with verify_tls=False
|
||||
mock_fetch.assert_called_once_with(verify_tls=False)
|
||||
assert username == 'db_user'
|
||||
assert password == 'db_pass'
|
||||
assert org == 'db_org'
|
||||
assert install_uuid == 'install-uuid'
|
||||
assert errors is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.parse_cert')
|
||||
@mock.patch('awx.main.utils.candlepin.settings')
|
||||
def test_save_candlepin_cert_to_db(self, mock_settings, mock_parse_cert):
|
||||
"""Test saving Candlepin cert to conf_settings."""
|
||||
mock_parse_cert.return_value = {
|
||||
'serial': '123456',
|
||||
'cn': 'test-consumer',
|
||||
'not_before': '2026-01-01T00:00:00+00:00',
|
||||
'not_after': '2027-01-01T00:00:00+00:00',
|
||||
'days_remaining': 365,
|
||||
}
|
||||
|
||||
result = _save_candlepin_cert_to_db('new-cert', 'new-key')
|
||||
|
||||
assert result is True
|
||||
# Verify settings were assigned
|
||||
assert mock_settings.CANDLEPIN_CERT_PEM == 'new-cert'
|
||||
assert mock_settings.CANDLEPIN_KEY_PEM == 'new-key'
|
||||
assert mock_settings.CANDLEPIN_SERIAL_NUMBER == '123456'
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.parse_cert')
|
||||
@mock.patch('awx.main.utils.candlepin.settings')
|
||||
def test_save_candlepin_registration_to_db(self, mock_settings, mock_parse_cert):
|
||||
"""Test saving Candlepin registration to conf_settings."""
|
||||
mock_parse_cert.return_value = {
|
||||
'serial': '789012',
|
||||
'cn': 'test-consumer',
|
||||
'not_before': '2026-01-01T00:00:00+00:00',
|
||||
'not_after': '2027-01-01T00:00:00+00:00',
|
||||
'days_remaining': 365,
|
||||
}
|
||||
|
||||
result = _save_candlepin_registration_to_db('cert', 'key', 'uuid')
|
||||
|
||||
assert result is True
|
||||
# Verify all registration data was saved
|
||||
assert mock_settings.CANDLEPIN_CONSUMER_UUID == 'uuid'
|
||||
assert mock_settings.CANDLEPIN_CERT_PEM == 'cert'
|
||||
assert mock_settings.CANDLEPIN_KEY_PEM == 'key'
|
||||
assert mock_settings.CANDLEPIN_SERIAL_NUMBER == '789012'
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._save_candlepin_registration_to_db')
|
||||
@mock.patch('awx.main.utils.candlepin.CandlepinClient')
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db')
|
||||
@mock.patch('awx.main.utils.candlepin.get_proxy_url')
|
||||
@mock.patch('awx.main.utils.candlepin.get_candlepin_ca')
|
||||
@mock.patch('awx.main.utils.candlepin.get_candlepin_url')
|
||||
def test_register_candlepin_consumer_success(self, mock_get_url, mock_get_ca, mock_get_proxy, mock_fetch_creds, mock_client_class, mock_save):
|
||||
"""Test successful Candlepin consumer registration."""
|
||||
mock_get_url.return_value = 'https://candlepin.example.com'
|
||||
mock_get_ca.return_value = '/path/to/ca.pem'
|
||||
mock_get_proxy.return_value = None
|
||||
mock_fetch_creds.return_value = ('user', 'pass', 'org', 'install-uuid')
|
||||
mock_save.return_value = True
|
||||
|
||||
mock_client = mock.Mock()
|
||||
mock_client.register_consumer.return_value = ('cert', 'key', 'uuid')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
cert, key, uuid = _register_candlepin_consumer()
|
||||
|
||||
assert cert == 'cert'
|
||||
assert key == 'key'
|
||||
assert uuid == 'uuid'
|
||||
mock_save.assert_called_once_with('cert', 'key', 'uuid')
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db')
|
||||
def test_register_candlepin_consumer_missing_credentials(self, mock_fetch_creds):
|
||||
"""Test registration fails when credentials are missing."""
|
||||
mock_fetch_creds.return_value = (None, None, None, None)
|
||||
|
||||
cert, key, uuid = _register_candlepin_consumer()
|
||||
|
||||
assert cert is None
|
||||
assert key is None
|
||||
assert uuid is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._save_candlepin_cert_to_db')
|
||||
@mock.patch('awx.main.utils.candlepin.run_candlepin_lifecycle')
|
||||
@mock.patch('awx.main.utils.candlepin.get_proxy_url')
|
||||
@mock.patch('awx.main.utils.candlepin.get_candlepin_ca')
|
||||
@mock.patch('awx.main.utils.candlepin.get_renewal_days')
|
||||
@mock.patch('awx.main.utils.candlepin.get_candlepin_url')
|
||||
def test_run_candlepin_lifecycle_with_renewal(self, mock_get_url, mock_get_days, mock_get_ca, mock_get_proxy, mock_lifecycle, mock_save):
|
||||
"""Test lifecycle with certificate renewal."""
|
||||
mock_get_url.return_value = 'https://candlepin.example.com'
|
||||
mock_get_days.return_value = 90
|
||||
mock_get_ca.return_value = '/path/to/ca.pem'
|
||||
mock_get_proxy.return_value = None
|
||||
mock_lifecycle.return_value = ('new-cert', 'new-key')
|
||||
mock_save.return_value = True
|
||||
|
||||
cert, key = _run_candlepin_lifecycle('old-cert', 'old-key', 'real-uuid')
|
||||
|
||||
assert cert == 'new-cert'
|
||||
assert key == 'new-key'
|
||||
mock_lifecycle.assert_called_once()
|
||||
mock_save.assert_called_once_with('new-cert', 'new-key')
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.is_cert_valid')
|
||||
@mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle')
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db')
|
||||
def test_get_or_generate_candlepin_certificate_existing_valid(self, mock_fetch, mock_lifecycle, mock_is_valid):
|
||||
"""Test get_or_generate with existing valid certificate."""
|
||||
mock_fetch.return_value = ('cert-pem', 'key-pem', 'consumer-uuid')
|
||||
mock_lifecycle.return_value = ('cert-pem', 'key-pem')
|
||||
mock_is_valid.return_value = True
|
||||
|
||||
cert, key = get_or_generate_candlepin_certificate()
|
||||
|
||||
assert cert == 'cert-pem'
|
||||
assert key == 'key-pem'
|
||||
mock_lifecycle.assert_called_once_with('cert-pem', 'key-pem', 'consumer-uuid')
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.is_cert_valid')
|
||||
@mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle')
|
||||
@mock.patch('awx.main.utils.candlepin._register_candlepin_consumer')
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db')
|
||||
def test_get_or_generate_candlepin_certificate_register_new(self, mock_fetch, mock_register, mock_lifecycle, mock_is_valid):
|
||||
"""Test get_or_generate when no certificate exists - registers new."""
|
||||
mock_fetch.return_value = (None, None, None)
|
||||
mock_register.return_value = ('new-cert', 'new-key', 'new-uuid')
|
||||
mock_lifecycle.return_value = ('new-cert', 'new-key')
|
||||
mock_is_valid.return_value = True
|
||||
|
||||
cert, key = get_or_generate_candlepin_certificate()
|
||||
|
||||
assert cert == 'new-cert'
|
||||
assert key == 'new-key'
|
||||
mock_register.assert_called_once()
|
||||
mock_lifecycle.assert_called_once_with('new-cert', 'new-key', 'new-uuid')
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin._register_candlepin_consumer')
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db')
|
||||
def test_get_or_generate_candlepin_certificate_registration_fails(self, mock_fetch, mock_register):
|
||||
"""Test get_or_generate when registration fails."""
|
||||
mock_fetch.return_value = (None, None, None)
|
||||
mock_register.return_value = (None, None, None)
|
||||
|
||||
cert, key = get_or_generate_candlepin_certificate()
|
||||
|
||||
assert cert is None
|
||||
assert key is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.is_cert_valid')
|
||||
@mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle')
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db')
|
||||
def test_get_or_generate_candlepin_certificate_invalid_cert(self, mock_fetch, mock_lifecycle, mock_is_valid):
|
||||
"""Test get_or_generate when certificate is invalid."""
|
||||
mock_fetch.return_value = ('cert-pem', 'key-pem', 'consumer-uuid')
|
||||
mock_lifecycle.return_value = ('cert-pem', 'key-pem')
|
||||
mock_is_valid.return_value = False
|
||||
|
||||
cert, key = get_or_generate_candlepin_certificate()
|
||||
|
||||
assert cert is None
|
||||
assert key is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.is_cert_valid')
|
||||
@mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle')
|
||||
@mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db')
|
||||
def test_get_or_generate_candlepin_certificate_expired_cert_renewed_successfully(self, mock_fetch, mock_lifecycle, mock_is_valid):
|
||||
"""Test get_or_generate with expired certificate that is successfully renewed."""
|
||||
mock_fetch.return_value = ('expired-cert', 'old-key', 'consumer-uuid')
|
||||
# Lifecycle successfully renews
|
||||
mock_lifecycle.return_value = ('new-cert', 'new-key')
|
||||
# New certificate is valid
|
||||
mock_is_valid.return_value = True
|
||||
|
||||
cert, key = get_or_generate_candlepin_certificate()
|
||||
|
||||
assert cert == 'new-cert'
|
||||
assert key == 'new-key'
|
||||
mock_lifecycle.assert_called_once_with('expired-cert', 'old-key', 'consumer-uuid')
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.parse_cert')
|
||||
@mock.patch('awx.main.utils.candlepin.settings')
|
||||
def test_save_candlepin_registration_to_db_cert_parse_failure(self, mock_settings, mock_parse_cert):
|
||||
"""Test _save_candlepin_registration_to_db handles cert parsing failure gracefully."""
|
||||
# Cert parsing fails
|
||||
mock_parse_cert.side_effect = ValueError('Invalid certificate format')
|
||||
|
||||
result = _save_candlepin_registration_to_db('invalid-cert', 'key-pem', 'consumer-uuid')
|
||||
|
||||
# Should still save registration even if parsing fails
|
||||
assert result is True
|
||||
# Verify UUID, cert, key, and serial (empty string) were saved
|
||||
assert mock_settings.CANDLEPIN_CONSUMER_UUID == 'consumer-uuid'
|
||||
assert mock_settings.CANDLEPIN_CERT_PEM == 'invalid-cert'
|
||||
assert mock_settings.CANDLEPIN_KEY_PEM == 'key-pem'
|
||||
assert mock_settings.CANDLEPIN_SERIAL_NUMBER == ''
|
||||
@@ -1,124 +0,0 @@
|
||||
# Copyright (c) 2026 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.utils.candlepin.client import CandlepinClient, _temp_cert_files
|
||||
|
||||
|
||||
class TestCandlepinClient:
|
||||
"""Tests for CandlepinClient."""
|
||||
|
||||
def test_base_url_required(self):
|
||||
"""Test base_url parameter is required."""
|
||||
client = CandlepinClient(base_url='https://subscription.example.com/candlepin')
|
||||
assert client.base_url == 'https://subscription.example.com/candlepin'
|
||||
|
||||
def test_verify_tls_enabled_by_default(self):
|
||||
"""Test TLS verification is enabled by default."""
|
||||
client = CandlepinClient(base_url='https://test.example.com')
|
||||
assert client.verify is True
|
||||
|
||||
def test_verify_tls_with_ca(self):
|
||||
"""Test TLS verification with custom CA."""
|
||||
client = CandlepinClient(base_url='https://test.example.com', candlepin_ca='/path/to/ca.pem')
|
||||
assert client.verify == '/path/to/ca.pem'
|
||||
|
||||
def test_proxy_configuration(self):
|
||||
"""Test proxy configuration."""
|
||||
client = CandlepinClient(base_url='https://test.example.com', proxy='http://proxy.example.com:8080')
|
||||
assert client.proxies == {'https': 'http://proxy.example.com:8080', 'http': 'http://proxy.example.com:8080'}
|
||||
|
||||
def test_temp_cert_files_cleanup(self):
|
||||
"""Test temporary certificate files are created and cleaned up."""
|
||||
cert_pem = '-----BEGIN CERTIFICATE-----\ntest_cert\n-----END CERTIFICATE-----'
|
||||
key_pem = '-----BEGIN PRIVATE KEY-----\ntest_key\n-----END PRIVATE KEY-----'
|
||||
|
||||
with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path):
|
||||
assert os.path.exists(cert_path)
|
||||
assert os.path.exists(key_path)
|
||||
# Verify file permissions
|
||||
cert_stat = os.stat(cert_path)
|
||||
assert oct(cert_stat.st_mode)[-3:] == '600'
|
||||
|
||||
# Verify cleanup
|
||||
assert not os.path.exists(cert_path)
|
||||
assert not os.path.exists(key_path)
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.client.requests.post')
|
||||
def test_register_consumer_success(self, mock_post):
|
||||
"""Test successful consumer registration."""
|
||||
mock_response = mock.Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {
|
||||
'uuid': 'test-consumer-uuid',
|
||||
'idCert': {
|
||||
'cert': '-----BEGIN CERTIFICATE-----\ncert_data\n-----END CERTIFICATE-----',
|
||||
'key': '-----BEGIN PRIVATE KEY-----\nkey_data\n-----END PRIVATE KEY-----',
|
||||
},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = CandlepinClient(base_url='https://test.example.com')
|
||||
cert_pem, key_pem, consumer_uuid = client.register_consumer('test_user', 'test_pass', 'test_org', install_uuid='test-install-uuid')
|
||||
|
||||
assert consumer_uuid == 'test-consumer-uuid'
|
||||
assert '-----BEGIN CERTIFICATE-----' in cert_pem
|
||||
assert '-----BEGIN PRIVATE KEY-----' in key_pem
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.client.requests.put')
|
||||
def test_checkin_success(self, mock_put):
|
||||
"""Test successful check-in."""
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
client = CandlepinClient(base_url='https://test.example.com')
|
||||
cert_pem = '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
|
||||
key_pem = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----'
|
||||
|
||||
result = client.checkin('test-uuid', cert_pem, key_pem)
|
||||
assert result is True
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.client.requests.get')
|
||||
def test_get_consumer_success(self, mock_get):
|
||||
"""Test successful consumer retrieval."""
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'uuid': 'test-consumer-uuid',
|
||||
'name': 'aap-12345678',
|
||||
'idCert': {'cert': '-----BEGIN CERTIFICATE-----\nserver_cert\n-----END CERTIFICATE-----', 'serial': {'serial': 123456789}},
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = CandlepinClient(base_url='https://test.example.com')
|
||||
cert_pem = '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
|
||||
key_pem = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----'
|
||||
|
||||
result = client.get_consumer('test-uuid', cert_pem, key_pem)
|
||||
assert result is not None
|
||||
assert result['uuid'] == 'test-consumer-uuid'
|
||||
assert 'idCert' in result
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.client.requests.post')
|
||||
def test_regenerate_cert_success(self, mock_post):
|
||||
"""Test successful certificate regeneration."""
|
||||
mock_response = mock.Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {
|
||||
'idCert': {
|
||||
'cert': '-----BEGIN CERTIFICATE-----\nnew_cert\n-----END CERTIFICATE-----',
|
||||
'key': '-----BEGIN PRIVATE KEY-----\nnew_key\n-----END PRIVATE KEY-----',
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = CandlepinClient(base_url='https://test.example.com')
|
||||
old_cert = '-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----'
|
||||
old_key = '-----BEGIN PRIVATE KEY-----\nold\n-----END PRIVATE KEY-----'
|
||||
|
||||
new_cert, new_key = client.regenerate_cert('test-uuid', old_cert, old_key)
|
||||
assert 'new_cert' in new_cert
|
||||
assert 'new_key' in new_key
|
||||
@@ -1,222 +0,0 @@
|
||||
# Copyright (c) 2026 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.utils.candlepin.lifecycle import (
|
||||
parse_cert,
|
||||
needs_renewal,
|
||||
run_candlepin_lifecycle,
|
||||
get_candlepin_url,
|
||||
get_renewal_days,
|
||||
get_candlepin_ca,
|
||||
get_proxy_url,
|
||||
)
|
||||
|
||||
# Sample test certificate (expires far in the future for testing)
|
||||
SAMPLE_CERT_PEM = """-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAKJ5VZ2cPQE5MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQwHhcNMjYwMTAxMDAwMDAwWhcNMjcwMTAxMDAwMDAwWjBF
|
||||
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
|
||||
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||
CgKCAQEA0a7Y3l3X4L7pKq3xDl8vCRrRK6qU5dF7r3xQH5YRz4hZJN9wE3xW0qDT
|
||||
-----END CERTIFICATE-----"""
|
||||
|
||||
|
||||
class TestCandlepinLifecycle:
|
||||
"""Tests for Candlepin lifecycle functions."""
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.settings')
|
||||
def test_get_candlepin_url_default(self, mock_settings):
|
||||
"""Test default Candlepin URL from defaults.py."""
|
||||
mock_settings.AWX_ANALYTICS_CANDLEPIN_URL = 'https://subscription.example.com/candlepin/'
|
||||
url = get_candlepin_url()
|
||||
assert url == 'https://subscription.example.com/candlepin/'
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.settings')
|
||||
def test_get_renewal_days_from_settings(self, mock_settings):
|
||||
"""Test renewal days from Django settings."""
|
||||
mock_settings.AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS = 45
|
||||
days = get_renewal_days()
|
||||
assert days == 45
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.os.path.isfile')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.settings')
|
||||
def test_get_candlepin_ca_from_settings(self, mock_settings, mock_isfile):
|
||||
"""Test Candlepin CA from Django settings when file exists."""
|
||||
mock_settings.AWX_ANALYTICS_CANDLEPIN_CA = '/path/to/ca.pem'
|
||||
mock_isfile.return_value = True
|
||||
ca = get_candlepin_ca()
|
||||
assert ca == '/path/to/ca.pem'
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.os.path.isfile')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.settings')
|
||||
def test_get_candlepin_ca_file_not_found(self, mock_settings, mock_isfile):
|
||||
"""Test Candlepin CA returns None when configured path doesn't exist."""
|
||||
mock_settings.AWX_ANALYTICS_CANDLEPIN_CA = '/path/to/missing.pem'
|
||||
mock_isfile.return_value = False
|
||||
ca = get_candlepin_ca()
|
||||
assert ca is None
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.settings')
|
||||
def test_get_proxy_url_from_settings(self, mock_settings):
|
||||
"""Test proxy URL from Django settings."""
|
||||
mock_settings.AWX_ANALYTICS_CANDLEPIN_PROXY_URL = 'http://proxy.example.com:8080'
|
||||
proxy = get_proxy_url()
|
||||
assert proxy == 'http://proxy.example.com:8080'
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.x509.load_pem_x509_certificate')
|
||||
def test_parse_cert(self, mock_load_cert):
|
||||
"""Test certificate parsing."""
|
||||
# Mock a certificate object
|
||||
mock_cert = mock.Mock()
|
||||
mock_cert.serial_number = 123456
|
||||
mock_cert.not_valid_before_utc = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
mock_cert.not_valid_after_utc = datetime(2027, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
# Mock subject and issuer
|
||||
mock_attr = mock.Mock()
|
||||
mock_attr.oid._name = 'commonName'
|
||||
mock_attr.value = 'test-cn'
|
||||
mock_cert.subject = [mock_attr]
|
||||
mock_cert.issuer = [mock_attr]
|
||||
|
||||
mock_load_cert.return_value = mock_cert
|
||||
|
||||
result = parse_cert('fake-pem')
|
||||
|
||||
assert result['serial'] == '123456'
|
||||
assert result['cn'] == 'test-cn'
|
||||
assert 'not_before' in result
|
||||
assert 'not_after' in result
|
||||
assert 'days_remaining' in result
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_needs_renewal_true(self, mock_parse):
|
||||
"""Test needs_renewal returns True when cert is expiring soon."""
|
||||
mock_parse.return_value = {'days_remaining': 10}
|
||||
|
||||
result = needs_renewal('fake-cert', days_before_expiry=30)
|
||||
assert result is True
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_needs_renewal_false(self, mock_parse):
|
||||
"""Test needs_renewal returns False when cert has time remaining."""
|
||||
mock_parse.return_value = {'days_remaining': 100}
|
||||
|
||||
result = needs_renewal('fake-cert', days_before_expiry=30)
|
||||
assert result is False
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_run_candlepin_lifecycle_no_renewal_needed(self, mock_parse, mock_client_class):
|
||||
"""Test lifecycle when no renewal is needed."""
|
||||
mock_parse.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01T00:00:00+00:00', 'days_remaining': 100}
|
||||
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client.get_consumer.return_value = None # Skip serial comparison
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
cert_pem, key_pem = run_candlepin_lifecycle('cert-pem', 'key-pem', 'consumer-uuid', candlepin_url='https://test.example.com', renewal_days=30)
|
||||
|
||||
assert cert_pem == 'cert-pem'
|
||||
assert key_pem == 'key-pem'
|
||||
mock_client.checkin.assert_called_once()
|
||||
mock_client.regenerate_cert.assert_not_called()
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_run_candlepin_lifecycle_with_renewal(self, mock_parse, mock_client_class):
|
||||
"""Test lifecycle when renewal is needed."""
|
||||
# parse_cert is called multiple times:
|
||||
# 1. Parse original cert
|
||||
# 2. In needs_renewal() to check expiry
|
||||
# 3. Parse new cert after renewal for logging
|
||||
mock_parse.side_effect = [
|
||||
{'serial': '123', 'cn': 'test', 'not_after': '2026-02-01', 'days_remaining': 10}, # Original cert
|
||||
{'serial': '123', 'cn': 'test', 'not_after': '2026-02-01', 'days_remaining': 10}, # needs_renewal check
|
||||
{'serial': '456', 'cn': 'test', 'not_after': '2027-02-01', 'days_remaining': 365}, # New cert
|
||||
]
|
||||
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client.get_consumer.return_value = None # Skip serial comparison
|
||||
mock_client.regenerate_cert.return_value = ('new-cert', 'new-key')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
cert_pem, key_pem = run_candlepin_lifecycle('old-cert', 'old-key', 'consumer-uuid', renewal_days=90)
|
||||
|
||||
assert cert_pem == 'new-cert'
|
||||
assert key_pem == 'new-key'
|
||||
mock_client.regenerate_cert.assert_called_once()
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_run_candlepin_lifecycle_expired_cert_renewal(self, mock_parse, mock_client_class):
|
||||
"""Test lifecycle renews an expired certificate."""
|
||||
# parse_cert called for:
|
||||
# 1. Parse original expired cert
|
||||
# 2. needs_renewal check (expired, so returns True)
|
||||
# 3. Parse new cert after renewal
|
||||
mock_parse.side_effect = [
|
||||
{'serial': '123', 'cn': 'test', 'not_after': '2025-12-31', 'days_remaining': -120}, # Expired cert
|
||||
{'serial': '123', 'cn': 'test', 'not_after': '2025-12-31', 'days_remaining': -120}, # needs_renewal
|
||||
{'serial': '456', 'cn': 'test', 'not_after': '2027-06-01', 'days_remaining': 365}, # New cert
|
||||
]
|
||||
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = True
|
||||
mock_client.get_consumer.return_value = None
|
||||
mock_client.regenerate_cert.return_value = ('new-cert', 'new-key')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
cert_pem, key_pem = run_candlepin_lifecycle('expired-cert', 'old-key', 'consumer-uuid', renewal_days=90)
|
||||
|
||||
assert cert_pem == 'new-cert'
|
||||
assert key_pem == 'new-key'
|
||||
mock_client.regenerate_cert.assert_called_once()
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_run_candlepin_lifecycle_checkin_failure_revoked_cert(self, mock_parse, mock_client_class):
|
||||
"""Test lifecycle handles check-in failure (e.g., revoked certificate)."""
|
||||
mock_parse.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 100}
|
||||
|
||||
# Check-in fails (could indicate revoked cert or deleted consumer)
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = False
|
||||
mock_client.get_consumer.return_value = None # get_consumer also fails
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Lifecycle should continue and return original cert
|
||||
cert_pem, key_pem = run_candlepin_lifecycle('cert-pem', 'key-pem', 'consumer-uuid', renewal_days=30)
|
||||
|
||||
assert cert_pem == 'cert-pem'
|
||||
assert key_pem == 'key-pem'
|
||||
mock_client.checkin.assert_called_once()
|
||||
# Regeneration should not be attempted since get_consumer indicates consumer doesn't exist
|
||||
mock_client.regenerate_cert.assert_not_called()
|
||||
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient')
|
||||
@mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert')
|
||||
def test_run_candlepin_lifecycle_consumer_deleted_server_side(self, mock_parse, mock_client_class):
|
||||
"""Test lifecycle detects when consumer was deleted from Candlepin server."""
|
||||
mock_parse.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 100}
|
||||
|
||||
# Both check-in and get_consumer fail (consumer deleted)
|
||||
mock_client = mock.Mock()
|
||||
mock_client.checkin.return_value = False
|
||||
mock_client.get_consumer.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
cert_pem, key_pem = run_candlepin_lifecycle('cert-pem', 'key-pem', 'consumer-uuid', renewal_days=30)
|
||||
|
||||
# Should return original cert (caller can attempt mTLS, which will fail and fall back to service account)
|
||||
assert cert_pem == 'cert-pem'
|
||||
assert key_pem == 'key-pem'
|
||||
mock_client.checkin.assert_called_once()
|
||||
mock_client.get_consumer.assert_called_once()
|
||||
mock_client.regenerate_cert.assert_not_called()
|
||||
@@ -7,7 +7,7 @@ from django.utils.timezone import now
|
||||
from awx.main.models.schedules import _fast_forward_rrule, Schedule
|
||||
from dateutil.rrule import HOURLY, MINUTELY, MONTHLY
|
||||
|
||||
REF_DT = datetime.datetime(2026, 4, 16, tzinfo=datetime.timezone.utc)
|
||||
REF_DT = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -20,10 +20,6 @@ REF_DT = datetime.datetime(2026, 4, 16, tzinfo=datetime.timezone.utc)
|
||||
'DTSTART;TZID=America/New_York:20201118T200000 RRULE:FREQ=MINUTELY;INTERVAL=5;WKST=SU;BYMONTH=2,3;BYMONTHDAY=18;BYHOUR=5;BYMINUTE=35;BYSECOND=0',
|
||||
id='every-5-minutes-at-5:35:00-am-on-the-18th-day-of-feb-or-march-with-week-starting-on-sundays',
|
||||
),
|
||||
pytest.param(
|
||||
'DTSTART;TZID=America/New_York:20251211T130000 RRULE:FREQ=HOURLY;INTERVAL=4;WKST=MO;BYDAY=MO,TU,WE,TH,FR;BYHOUR=1,5,9,13,17,21;BYMINUTE=0',
|
||||
id='every-4-hours-at-1-5-9-13-17-21-am-on-monday-through-friday-with-week-starting-on-monday',
|
||||
),
|
||||
pytest.param(
|
||||
'DTSTART;TZID=America/New_York:20201118T200000 RRULE:FREQ=HOURLY;INTERVAL=5;WKST=SU;BYMONTH=2,3;BYHOUR=5',
|
||||
id='every-5-hours-at-5-am-in-feb-or-march-with-week-starting-on-sundays',
|
||||
@@ -52,7 +48,6 @@ def test_fast_forwarded_rrule_matches_original_occurrence(rrulestr):
|
||||
[
|
||||
pytest.param(datetime.datetime(2024, 12, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-out-of-dst'),
|
||||
pytest.param(datetime.datetime(2024, 6, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-in-dst'),
|
||||
pytest.param(datetime.datetime(2024, 11, 3, 6, 30, tzinfo=datetime.timezone.utc), id='ref-dt-fall-back-day'),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
@@ -63,8 +58,6 @@ def test_fast_forwarded_rrule_matches_original_occurrence(rrulestr):
|
||||
pytest.param(
|
||||
'DTSTART;TZID=Europe/Lisbon:20230703T005800 RRULE:INTERVAL=10;FREQ=MINUTELY;BYHOUR=9,10,11,12,13,14,15,16,17,18,19,20,21', id='rrule-in-dst-by-hour'
|
||||
),
|
||||
pytest.param('DTSTART;TZID=America/New_York:20230313T005800 RRULE:FREQ=MINUTELY;INTERVAL=7', id='rrule-post-dst-7min'),
|
||||
pytest.param('DTSTART;TZID=America/New_York:20230313T005800 RRULE:FREQ=MINUTELY;INTERVAL=13', id='rrule-post-dst-13min'),
|
||||
],
|
||||
)
|
||||
def test_fast_forward_across_dst(rrulestr, ref_dt):
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# Copyright (c) 2026 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
"""
|
||||
Candlepin integration for mTLS-based authentication.
|
||||
|
||||
This package provides Candlepin consumer identity certificate support,
|
||||
enabling AAP controller instances to authenticate analytics uploads using
|
||||
mTLS instead of service account credentials.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .client import CandlepinClient
|
||||
from .lifecycle import (
|
||||
get_candlepin_ca,
|
||||
get_candlepin_url,
|
||||
get_proxy_url,
|
||||
get_renewal_days,
|
||||
is_cert_valid,
|
||||
parse_cert,
|
||||
run_candlepin_lifecycle,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('awx.main.utils.candlepin')
|
||||
|
||||
|
||||
def _fetch_candlepin_cert_from_db():
|
||||
"""Read cert PEM, key PEM, and consumer UUID from AWX conf_settings.
|
||||
|
||||
Returns (cert_pem, key_pem, consumer_uuid) if valid certificate data exists,
|
||||
or (None, None, None) if placeholder/unregistered data.
|
||||
Best-effort: failures are logged as warnings and never propagate.
|
||||
"""
|
||||
try:
|
||||
consumer_uuid = getattr(settings, 'CANDLEPIN_CONSUMER_UUID', '')
|
||||
cert_pem = getattr(settings, 'CANDLEPIN_CERT_PEM', '')
|
||||
key_pem = getattr(settings, 'CANDLEPIN_KEY_PEM', '')
|
||||
|
||||
# Check if we have valid data
|
||||
if not consumer_uuid or not cert_pem or not key_pem:
|
||||
return None, None, None
|
||||
|
||||
return cert_pem, key_pem, consumer_uuid
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not fetch Candlepin lifecycle data from settings: {e}')
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _save_candlepin_cert_to_db(cert_pem, key_pem):
|
||||
"""Persist a renewed Candlepin identity cert and key to AWX conf_settings.
|
||||
|
||||
Returns:
|
||||
bool: True if save succeeded, False on any error.
|
||||
"""
|
||||
try:
|
||||
# Parse certificate to extract metadata
|
||||
try:
|
||||
cert_info = parse_cert(cert_pem)
|
||||
serial_number = cert_info.get('serial', '')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not parse certificate metadata: {e}')
|
||||
serial_number = ''
|
||||
|
||||
# Update conf_settings via settings wrapper
|
||||
settings.CANDLEPIN_CERT_PEM = cert_pem
|
||||
settings.CANDLEPIN_KEY_PEM = key_pem
|
||||
settings.CANDLEPIN_SERIAL_NUMBER = serial_number
|
||||
|
||||
logger.info('Renewed Candlepin cert and key saved to conf_settings.')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Could not save renewed Candlepin cert to conf_settings: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def _discover_org(candlepin_url, username, password, verify_tls=True):
|
||||
"""Discover org key via GET /users/{username}/owners.
|
||||
|
||||
Args:
|
||||
candlepin_url: Candlepin base URL
|
||||
username: Username for authentication
|
||||
password: Password for authentication
|
||||
verify_tls: Whether to verify TLS certificates (default: True)
|
||||
|
||||
Returns:
|
||||
str: Organization key if found, None on any failure.
|
||||
"""
|
||||
try:
|
||||
url = f"{candlepin_url}/users/{username}/owners"
|
||||
if verify_tls:
|
||||
candlepin_ca = get_candlepin_ca()
|
||||
verify = candlepin_ca if candlepin_ca else True
|
||||
else:
|
||||
verify = False
|
||||
|
||||
resp = requests.get(url, auth=(username, password), verify=verify, timeout=30)
|
||||
resp.raise_for_status()
|
||||
|
||||
owners = resp.json()
|
||||
if not owners:
|
||||
logger.warning(f'No organizations found for user {username}')
|
||||
return None
|
||||
|
||||
# Pick the first org, but warn if multiple exist
|
||||
if len(owners) > 1:
|
||||
logger.warning(f'User {username} has access to {len(owners)} organizations. Using first: {owners[0]}')
|
||||
first_org = owners[0]
|
||||
org = first_org.get('key')
|
||||
if not org:
|
||||
logger.warning(f'Organization key missing in first org entry for user {username}')
|
||||
return None
|
||||
|
||||
return org
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f'Failed to discover organization for user {username}: {e}')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f'Unexpected error discovering organization for user {username}: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_registration_credentials_from_db(verify_tls=True):
|
||||
"""Read Candlepin registration credentials from AWX settings.
|
||||
|
||||
Tries several options to retrieve the Candlepin credentials (set by AWX when the
|
||||
customer configures their Red Hat subscription), and to discover the org (org
|
||||
key for the Candlepin /consumers endpoint), and INSTALL_UUID (used as the
|
||||
consumer's aap.instance_uuid fact).
|
||||
|
||||
Priority for authentication credentials:
|
||||
- If both REDHAT_USERNAME and SUBSCRIPTIONS_USERNAME exist: use REDHAT_USERNAME
|
||||
- If only SUBSCRIPTIONS_USERNAME exists: use SUBSCRIPTIONS_USERNAME
|
||||
|
||||
Args:
|
||||
verify_tls: Whether to verify TLS certificates during org discovery (default: True)
|
||||
|
||||
Returns (username, password, org, install_uuid), any of which may be None
|
||||
if the corresponding setting is not configured.
|
||||
"""
|
||||
candlepin_url = get_candlepin_url()
|
||||
try:
|
||||
username = getattr(settings, 'REDHAT_USERNAME', None)
|
||||
password = getattr(settings, 'REDHAT_PASSWORD', None)
|
||||
|
||||
if not (username and password):
|
||||
username = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None)
|
||||
password = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None)
|
||||
|
||||
install_uuid = getattr(settings, 'INSTALL_UUID', None)
|
||||
|
||||
org = _discover_org(candlepin_url, username, password, verify_tls=verify_tls) if username and password else None
|
||||
|
||||
return username, password, org, install_uuid
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not fetch Candlepin registration credentials from settings: {e}')
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def resolve_registration_credentials(username_override=None, password_override=None, org_override=None, verify_tls=True):
|
||||
"""Resolve Candlepin registration credentials with optional overrides.
|
||||
|
||||
Fetches credentials from database settings and merges with any provided overrides.
|
||||
Validates that all required fields are present.
|
||||
|
||||
Args:
|
||||
username_override: Optional username to use instead of database value
|
||||
password_override: Optional password to use instead of database value
|
||||
org_override: Optional org to use instead of auto-discovered value
|
||||
verify_tls: Whether to verify TLS certificates during org discovery (default: True)
|
||||
|
||||
Returns:
|
||||
Tuple (username, password, org, install_uuid) if all required fields present,
|
||||
or (None, None, None, None, error_messages) if validation fails.
|
||||
error_messages is a list of strings describing missing values.
|
||||
"""
|
||||
db_username, db_password, db_org, db_install_uuid = _fetch_registration_credentials_from_db(verify_tls=verify_tls)
|
||||
|
||||
username = username_override or db_username
|
||||
password = password_override or db_password
|
||||
org = org_override or db_org
|
||||
|
||||
# Validate all required fields are present
|
||||
missing = []
|
||||
if not username:
|
||||
missing.append('username (provide --username or set REDHAT_USERNAME in database)')
|
||||
if not password:
|
||||
missing.append('password (provide password or set REDHAT_PASSWORD in database)')
|
||||
if not org:
|
||||
missing.append('org (provide --org or ensure SUBSCRIPTIONS_USERNAME/PASSWORD are configured for auto-discovery)')
|
||||
|
||||
if missing:
|
||||
return None, None, None, None, missing
|
||||
|
||||
return username, password, org, db_install_uuid, None
|
||||
|
||||
|
||||
def _save_candlepin_registration_to_db(cert_pem, key_pem, consumer_uuid):
|
||||
"""Persist a new Candlepin consumer registration (cert, key, UUID) to AWX conf_settings.
|
||||
|
||||
Returns:
|
||||
bool: True if save succeeded, False on any error.
|
||||
"""
|
||||
try:
|
||||
# Parse certificate to extract metadata
|
||||
try:
|
||||
cert_info = parse_cert(cert_pem)
|
||||
serial_number = cert_info.get('serial', '')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not parse certificate metadata: {e}')
|
||||
serial_number = ''
|
||||
|
||||
# Update conf_settings with all registration data via settings wrapper
|
||||
settings.CANDLEPIN_CONSUMER_UUID = consumer_uuid
|
||||
settings.CANDLEPIN_CERT_PEM = cert_pem
|
||||
settings.CANDLEPIN_KEY_PEM = key_pem
|
||||
settings.CANDLEPIN_SERIAL_NUMBER = serial_number
|
||||
|
||||
logger.info(f'Candlepin consumer registration saved to conf_settings (uuid={consumer_uuid}).')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Could not save Candlepin registration to conf_settings: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def _register_candlepin_consumer():
|
||||
"""Register a new Candlepin consumer using credentials from AWX settings.
|
||||
|
||||
Called when no identity cert exists in the DB.
|
||||
|
||||
Reads the Candlepin credentials and the org key and then calls
|
||||
POST /consumers on Candlepin to obtain an identity certificate.
|
||||
On success the cert, key, and consumer UUID are persisted to conf_settings.
|
||||
|
||||
Returns (cert_pem, key_pem, consumer_uuid) on success, (None, None, None) on
|
||||
any failure. Best-effort: logs errors but never propagates.
|
||||
"""
|
||||
username, password, org, install_uuid = _fetch_registration_credentials_from_db()
|
||||
|
||||
if not username or not password:
|
||||
logger.warning('Candlepin registration is enabled but credentials are not set; skipping registration.')
|
||||
return None, None, None
|
||||
|
||||
if not org:
|
||||
logger.warning('Candlepin registration is enabled but subscription org is not available; skipping registration.')
|
||||
return None, None, None
|
||||
|
||||
candlepin_url = get_candlepin_url()
|
||||
candlepin_ca = get_candlepin_ca()
|
||||
proxy = get_proxy_url()
|
||||
client = CandlepinClient(base_url=candlepin_url, candlepin_ca=candlepin_ca, proxy=proxy)
|
||||
|
||||
try:
|
||||
cert_pem, key_pem, consumer_uuid = client.register_consumer(username, password, org, install_uuid)
|
||||
except Exception as e:
|
||||
logger.error(f'Candlepin consumer registration failed: {e}')
|
||||
return None, None, None
|
||||
|
||||
if not _save_candlepin_registration_to_db(cert_pem, key_pem, consumer_uuid):
|
||||
logger.error('Candlepin consumer registration succeeded but failed to save to database.')
|
||||
return None, None, None
|
||||
return cert_pem, key_pem, consumer_uuid
|
||||
|
||||
|
||||
def _run_candlepin_lifecycle(cert_pem, key_pem, consumer_uuid):
|
||||
"""Orchestrate Candlepin check-in and proactive cert renewal.
|
||||
|
||||
Returns the (possibly renewed) (cert_pem, key_pem) tuple. If renewal fails, the
|
||||
original cert is returned and the caller will validate it with is_cert_valid().
|
||||
If invalid, the caller skips mTLS and falls back directly to OIDC authentication.
|
||||
"""
|
||||
if not consumer_uuid:
|
||||
logger.warning('Candlepin lifecycle is enabled but consumer UUID is not set; skipping check-in and renewal.')
|
||||
return cert_pem, key_pem
|
||||
|
||||
candlepin_url = get_candlepin_url()
|
||||
renewal_days = get_renewal_days()
|
||||
candlepin_ca = get_candlepin_ca()
|
||||
proxy = get_proxy_url()
|
||||
|
||||
try:
|
||||
new_cert_pem, new_key_pem = run_candlepin_lifecycle(
|
||||
cert_pem,
|
||||
key_pem,
|
||||
consumer_uuid,
|
||||
candlepin_url=candlepin_url,
|
||||
renewal_days=renewal_days,
|
||||
candlepin_ca=candlepin_ca,
|
||||
proxy=proxy,
|
||||
)
|
||||
if (new_cert_pem, new_key_pem) != (cert_pem, key_pem):
|
||||
if not _save_candlepin_cert_to_db(new_cert_pem, new_key_pem):
|
||||
logger.warning('Renewed certificate will be used for this request, but failed to persist to database for future use.')
|
||||
return new_cert_pem, new_key_pem
|
||||
except Exception as e:
|
||||
logger.error(f'Candlepin lifecycle (check-in / renewal) failed: {e}; will attempt mTLS with existing cert')
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def get_or_generate_candlepin_certificate():
|
||||
"""
|
||||
Get or generate Candlepin certificate for analytics authentication.
|
||||
|
||||
This function provides certificate-based authentication for analytics uploads.
|
||||
It will:
|
||||
1. Check for existing certificate in conf_settings
|
||||
2. If missing, attempt to register with Candlepin (credentials from settings)
|
||||
3. If exists, check for renewal needs and refresh if needed
|
||||
4. Return the certificate and key as PEM strings
|
||||
|
||||
Returns:
|
||||
Tuple (cert_pem, key_pem) as strings if certificate is available, (None, None) otherwise.
|
||||
|
||||
Note:
|
||||
Credentials for registration are retrieved from Django settings internally
|
||||
(REDHAT_USERNAME/PASSWORD, SUBSCRIPTIONS_USERNAME/PASSWORD, or
|
||||
SUBSCRIPTIONS_CLIENT_ID/CLIENT_SECRET in priority order).
|
||||
"""
|
||||
cert_pem, key_pem, consumer_uuid = _fetch_candlepin_cert_from_db()
|
||||
|
||||
# If no certificate exists, attempt registration
|
||||
if not cert_pem or not key_pem:
|
||||
logger.info('No Candlepin certificate found, attempting registration')
|
||||
cert_pem, key_pem, consumer_uuid = _register_candlepin_consumer()
|
||||
|
||||
if not cert_pem or not key_pem:
|
||||
logger.debug('Candlepin certificate registration failed or not configured')
|
||||
return None, None
|
||||
|
||||
# Run lifecycle (check-in and renewal if needed)
|
||||
if consumer_uuid:
|
||||
cert_pem, key_pem = _run_candlepin_lifecycle(cert_pem, key_pem, consumer_uuid)
|
||||
|
||||
# Validate certificate is still usable
|
||||
if not is_cert_valid(cert_pem):
|
||||
logger.warning('Candlepin certificate is not valid (expired or not yet valid)')
|
||||
return None, None
|
||||
|
||||
# Return raw PEM strings - caller will create temp files if needed
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
__all__ = [
|
||||
'get_or_generate_candlepin_certificate',
|
||||
'resolve_registration_credentials',
|
||||
]
|
||||
@@ -1,258 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
import uuid as _uuid_mod
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('awx.main.utils.candlepin')
|
||||
|
||||
|
||||
class _temp_cert_files:
|
||||
"""
|
||||
Context manager: writes cert + key to secure temp files, auto-deletes on exit.
|
||||
|
||||
Uses NamedTemporaryFile with delete=True for better cleanup on process termination.
|
||||
Files are unlinked immediately on Unix systems, providing better security against
|
||||
orphaned private keys in /tmp.
|
||||
"""
|
||||
|
||||
def __init__(self, cert_pem, key_pem):
|
||||
self._cert_pem = cert_pem
|
||||
self._key_pem = key_pem
|
||||
self._cert_file = None
|
||||
self._key_file = None
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
# Create temp file for certificate
|
||||
self._cert_file = tempfile.NamedTemporaryFile(mode='w', prefix='candlepin_cert_', suffix='.pem', delete=True)
|
||||
self._cert_file.write(self._cert_pem)
|
||||
self._cert_file.flush()
|
||||
os.chmod(self._cert_file.name, 0o600)
|
||||
|
||||
# Create temp file for private key
|
||||
self._key_file = tempfile.NamedTemporaryFile(mode='w', prefix='candlepin_key_', suffix='.pem', delete=True)
|
||||
self._key_file.write(self._key_pem)
|
||||
self._key_file.flush()
|
||||
os.chmod(self._key_file.name, 0o600)
|
||||
|
||||
return self._cert_file.name, self._key_file.name
|
||||
except Exception:
|
||||
# Clean up on error
|
||||
if self._cert_file:
|
||||
self._cert_file.close()
|
||||
if self._key_file:
|
||||
self._key_file.close()
|
||||
raise
|
||||
|
||||
def __exit__(self, *_):
|
||||
# Closing NamedTemporaryFile automatically deletes it
|
||||
if self._cert_file:
|
||||
try:
|
||||
self._cert_file.close()
|
||||
except Exception as e:
|
||||
logger.warning(f'Error closing cert temp file: {e}')
|
||||
if self._key_file:
|
||||
try:
|
||||
self._key_file.close()
|
||||
except Exception as e:
|
||||
logger.warning(f'Error closing key temp file: {e}')
|
||||
|
||||
|
||||
class CandlepinClient:
|
||||
"""
|
||||
Minimal Candlepin REST client for certificate lifecycle operations.
|
||||
|
||||
All API calls authenticate with the consumer identity certificate (mTLS),
|
||||
matching the pattern used by subscription-manager after initial registration.
|
||||
|
||||
TLS server verification is **enabled** by default (``verify_tls=True``).
|
||||
Pass ``candlepin_ca`` to verify against a specific CA bundle rather than the
|
||||
system trust store. Verification can only be disabled by explicitly passing
|
||||
``verify_tls=False``; this should be used only in controlled test environments
|
||||
and never in production.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url, candlepin_ca=None, proxy=None, verify_tls=True):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
if candlepin_ca:
|
||||
self.verify = candlepin_ca
|
||||
elif verify_tls:
|
||||
self.verify = True
|
||||
else:
|
||||
# Explicit opt-in required to reach this branch — never set by default.
|
||||
logger.warning('CandlepinClient: TLS verification is DISABLED (verify_tls=False). Do not use in production.')
|
||||
self.verify = False
|
||||
if proxy:
|
||||
# Use the caller-supplied URL as-is for HTTPS targets (preserves the
|
||||
# intended scheme — usually http:// so requests uses plain HTTP to reach
|
||||
# the proxy and issues CONNECT for TLS tunneling, but https:// is also
|
||||
# accepted for the rare case of an HTTPS-fronted proxy).
|
||||
# The http:// key always uses plain HTTP since non-TLS traffic never
|
||||
# needs TLS to the proxy itself.
|
||||
host = proxy.split('://', 1)[-1]
|
||||
self.proxies = {'https': proxy, 'http': f'http://{host}'}
|
||||
else:
|
||||
self.proxies = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def register_consumer(self, username, password, org, install_uuid=None):
|
||||
"""POST /consumers?owner={org} — register a new AAP consumer with basic auth.
|
||||
|
||||
Uses the customer's Red Hat subscription credentials (REDHAT_USERNAME /
|
||||
REDHAT_PASSWORD from AWX conf_setting) to register this controller
|
||||
instance as a Candlepin consumer and obtain an identity certificate for mTLS.
|
||||
|
||||
Args:
|
||||
username: Red Hat subscription username (from REDHAT_USERNAME).
|
||||
password: Red Hat subscription password (from REDHAT_PASSWORD).
|
||||
org: Candlepin owner/org key (retrieved with subscription credentials).
|
||||
install_uuid: AWX INSTALL_UUID used as the consumer's aap.instance_uuid
|
||||
fact; falls back to a random UUID if not provided.
|
||||
|
||||
Returns:
|
||||
Tuple ``(cert_pem, key_pem, consumer_uuid)``.
|
||||
|
||||
Raises:
|
||||
RuntimeError on any network or API failure.
|
||||
"""
|
||||
url = f'{self.base_url}/consumers'
|
||||
instance_uuid = install_uuid or str(_uuid_mod.uuid4())
|
||||
payload = {
|
||||
'name': f'aap-{instance_uuid[:8]}',
|
||||
'type': {'label': 'aap'},
|
||||
'facts': {
|
||||
'system.certificate_version': '3.3',
|
||||
'system.name': 'aap-controller',
|
||||
'aap.instance_uuid': instance_uuid,
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
params={'owner': org},
|
||||
auth=(username, password),
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
verify=self.verify,
|
||||
proxies=self.proxies,
|
||||
timeout=120,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Candlepin register_consumer network error: {e}') from e
|
||||
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f'Candlepin register_consumer failed with status {resp.status_code}: {resp.text}')
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
consumer_uuid = body.get('uuid')
|
||||
id_cert = body.get('idCert', {})
|
||||
cert_pem = id_cert.get('cert')
|
||||
key_pem = id_cert.get('key')
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Candlepin register_consumer: could not parse response JSON: {e}') from e
|
||||
|
||||
if not consumer_uuid or not cert_pem or not key_pem:
|
||||
raise RuntimeError('Candlepin register_consumer: response missing uuid, idCert.cert or idCert.key')
|
||||
|
||||
logger.info(f'Candlepin consumer registered successfully (uuid={consumer_uuid})')
|
||||
return cert_pem, key_pem, consumer_uuid
|
||||
|
||||
def get_consumer(self, consumer_uuid, cert_pem, key_pem):
|
||||
"""GET /consumers/{uuid} — retrieve consumer information from server.
|
||||
|
||||
Best-effort: logs a warning on failure but never raises.
|
||||
|
||||
Returns:
|
||||
Dict with consumer data (including 'idCert' with serial) on success,
|
||||
None on any failure.
|
||||
"""
|
||||
url = f'{self.base_url}/consumers/{consumer_uuid}'
|
||||
try:
|
||||
with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path):
|
||||
resp = requests.get(
|
||||
url,
|
||||
cert=(cert_path, key_path),
|
||||
verify=self.verify,
|
||||
proxies=self.proxies,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.debug(f'Candlepin get_consumer successful for consumer {consumer_uuid}')
|
||||
return resp.json()
|
||||
logger.warning(f'Candlepin get_consumer returned unexpected status {resp.status_code} for consumer {consumer_uuid}')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f'Candlepin get_consumer failed for consumer {consumer_uuid}: {e}')
|
||||
return None
|
||||
|
||||
def checkin(self, consumer_uuid, cert_pem, key_pem):
|
||||
"""PUT /consumers/{uuid} — reset inactivity timer.
|
||||
|
||||
Best-effort: logs a warning on failure but never raises so that a
|
||||
transient Candlepin outage cannot abort a gather run.
|
||||
|
||||
Returns True on success, False on any failure.
|
||||
"""
|
||||
url = f'{self.base_url}/consumers/{consumer_uuid}'
|
||||
try:
|
||||
with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path):
|
||||
resp = requests.put(
|
||||
url,
|
||||
cert=(cert_path, key_path),
|
||||
json={'facts': {'aap.last_checkin': datetime.now(timezone.utc).isoformat()}},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
verify=self.verify,
|
||||
proxies=self.proxies,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code in (200, 204):
|
||||
logger.info(f'Candlepin check-in successful for consumer {consumer_uuid}')
|
||||
return True
|
||||
logger.warning(f'Candlepin check-in returned unexpected status {resp.status_code} for consumer {consumer_uuid}')
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f'Candlepin check-in failed for consumer {consumer_uuid}: {e}')
|
||||
return False
|
||||
|
||||
def regenerate_cert(self, consumer_uuid, cert_pem, key_pem):
|
||||
"""POST /consumers/{uuid} — regenerate the identity certificate.
|
||||
|
||||
Returns ``(new_cert_pem, new_key_pem)`` on success.
|
||||
Raises ``RuntimeError`` on API or parsing failure so the caller can
|
||||
decide whether to fall back to service-account auth.
|
||||
"""
|
||||
url = f'{self.base_url}/consumers/{consumer_uuid}'
|
||||
with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path):
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
cert=(cert_path, key_path),
|
||||
verify=self.verify,
|
||||
proxies=self.proxies,
|
||||
timeout=120,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Candlepin regenerate_cert network error for consumer {consumer_uuid}: {e}') from e
|
||||
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f'Candlepin regenerate_cert failed with status {resp.status_code} for consumer {consumer_uuid}: {resp.text}')
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
id_cert = body.get('idCert', {})
|
||||
new_cert_pem = id_cert.get('cert')
|
||||
new_key_pem = id_cert.get('key')
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Candlepin regenerate_cert: could not parse response JSON: {e}') from e
|
||||
|
||||
if not new_cert_pem or not new_key_pem:
|
||||
raise RuntimeError(f'Candlepin regenerate_cert: response did not contain idCert.cert / idCert.key for consumer {consumer_uuid}')
|
||||
|
||||
logger.info(f'Candlepin cert regenerated successfully for consumer {consumer_uuid}')
|
||||
return new_cert_pem, new_key_pem
|
||||
@@ -1,221 +0,0 @@
|
||||
"""
|
||||
Candlepin certificate lifecycle helpers.
|
||||
|
||||
is_cert_valid — quick parseable/non-expired guard used at ship time
|
||||
parse_cert — extract metadata from a PEM cert string
|
||||
needs_renewal — check whether a cert is within the renewal window
|
||||
run_candlepin_lifecycle — orchestrate check-in + proactive renewal per gather run
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cryptography import x509
|
||||
from django.conf import settings
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('awx.main.utils.candlepin')
|
||||
|
||||
from .client import CandlepinClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Certificate helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_cert(pem_text):
|
||||
"""Parse a PEM certificate and return a metadata dict.
|
||||
|
||||
Returns a dict with keys: serial, cn, issuer_cn, issuer_org,
|
||||
not_before, not_after, days_remaining, validity_days.
|
||||
|
||||
Raises ``ValueError`` if the PEM cannot be parsed.
|
||||
"""
|
||||
data = pem_text.encode('utf-8') if isinstance(pem_text, str) else pem_text
|
||||
try:
|
||||
cert = x509.load_pem_x509_certificate(data)
|
||||
except Exception as e:
|
||||
raise ValueError(f'Could not parse PEM certificate: {e}') from e
|
||||
|
||||
expiry = cert.not_valid_after_utc
|
||||
remaining = expiry - datetime.now(timezone.utc)
|
||||
|
||||
subject = {attr.oid._name: attr.value for attr in cert.subject}
|
||||
issuer = {attr.oid._name: attr.value for attr in cert.issuer}
|
||||
|
||||
return {
|
||||
'serial': str(cert.serial_number),
|
||||
'cn': subject.get('commonName', 'unknown'),
|
||||
'issuer_cn': issuer.get('commonName', 'unknown'),
|
||||
'issuer_org': issuer.get('organizationName', 'unknown'),
|
||||
'not_before': cert.not_valid_before_utc.isoformat(),
|
||||
'not_after': expiry.isoformat(),
|
||||
'days_remaining': remaining.days,
|
||||
'validity_days': (expiry - cert.not_valid_before_utc).days,
|
||||
}
|
||||
|
||||
|
||||
def is_cert_valid(cert_pem: str) -> bool:
|
||||
"""Return True if cert_pem is parseable, already valid, and not yet expired.
|
||||
|
||||
Logs a warning (suitable for operator visibility) when the cert is not yet
|
||||
valid, expired, or unparseable, then returns False so the caller can fall
|
||||
back to service-account authentication.
|
||||
"""
|
||||
try:
|
||||
info = parse_cert(cert_pem)
|
||||
now = datetime.now(timezone.utc)
|
||||
not_before = datetime.fromisoformat(info['not_before'])
|
||||
if now < not_before:
|
||||
logger.warning(f'Candlepin cert is not yet valid (not_before={info["not_before"]}); falling back to service account auth')
|
||||
return False
|
||||
if info['days_remaining'] < 0:
|
||||
logger.warning(f'Candlepin cert expired at {info["not_after"]}; falling back to service account auth')
|
||||
return False
|
||||
return True
|
||||
except ValueError as e:
|
||||
logger.warning(f'Could not parse Candlepin cert: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def needs_renewal(pem_text, days_before_expiry):
|
||||
"""Return True if the cert expires within ``days_before_expiry`` days.
|
||||
|
||||
Also returns True if the cert is already expired (days_remaining < 0).
|
||||
Raises ``ValueError`` if the PEM cannot be parsed.
|
||||
"""
|
||||
info = parse_cert(pem_text)
|
||||
return info['days_remaining'] <= days_before_expiry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_candlepin_lifecycle(cert_pem, key_pem, consumer_uuid, *, candlepin_url=None, renewal_days=90, candlepin_ca=None, proxy=None):
|
||||
"""Perform check-in and, if needed, proactive cert renewal.
|
||||
|
||||
Called once per gather run. Returns ``(cert_pem, key_pem)`` — either
|
||||
the originals (if no renewal was needed) or the freshly regenerated pair.
|
||||
|
||||
Args:
|
||||
cert_pem: Consumer identity certificate PEM string.
|
||||
key_pem: Consumer identity key PEM string.
|
||||
consumer_uuid: Candlepin consumer UUID string.
|
||||
candlepin_url: Candlepin base URL (defaults to prod).
|
||||
renewal_days: Renew if expiry is within this many days (default 90).
|
||||
candlepin_ca: Path to Candlepin CA cert for server verification
|
||||
(default None → uses system trust store).
|
||||
proxy: Optional HTTP/HTTPS proxy URL string.
|
||||
|
||||
Returns:
|
||||
Tuple ``(cert_pem, key_pem)`` — possibly updated after renewal.
|
||||
|
||||
Raises:
|
||||
RuntimeError if cert regeneration is attempted and fails.
|
||||
"""
|
||||
client = CandlepinClient(base_url=candlepin_url, candlepin_ca=candlepin_ca, proxy=proxy)
|
||||
|
||||
# Step 1: Inspect cert metadata for diagnostics and renewal decision.
|
||||
try:
|
||||
info = parse_cert(cert_pem)
|
||||
except ValueError as e:
|
||||
logger.warning(f'Candlepin lifecycle: could not parse cert, skipping lifecycle: {e}')
|
||||
return cert_pem, key_pem
|
||||
|
||||
logger.info(f'Candlepin cert: serial={info["serial"]}, CN={info["cn"]}, expires={info["not_after"]}, days_remaining={info["days_remaining"]}')
|
||||
|
||||
# Step 2: Check-in (best-effort, never raises).
|
||||
checkin_success = client.checkin(consumer_uuid, cert_pem, key_pem)
|
||||
if not checkin_success:
|
||||
logger.warning(
|
||||
f'Candlepin check-in failed for consumer {consumer_uuid}. '
|
||||
f'Consumer may have been deleted server-side or certificate is invalid. '
|
||||
f'Lifecycle will continue but may fail.'
|
||||
)
|
||||
|
||||
# Step 3: Compare local cert serial with server's serial.
|
||||
# If they differ, the server has issued a new cert (e.g., admin regenerated it).
|
||||
consumer_data = client.get_consumer(consumer_uuid, cert_pem, key_pem)
|
||||
if not consumer_data:
|
||||
if not checkin_success:
|
||||
logger.error(
|
||||
f'Both check-in and get_consumer failed for consumer {consumer_uuid}. '
|
||||
f'Consumer was likely deleted from Candlepin server. '
|
||||
f'Re-registration may be required. Will attempt cert renewal anyway.'
|
||||
)
|
||||
else:
|
||||
logger.warning(f'Could not retrieve consumer data for {consumer_uuid} but check-in succeeded. Continuing lifecycle.')
|
||||
else:
|
||||
server_cert_pem = consumer_data.get('idCert', {}).get('cert')
|
||||
if server_cert_pem:
|
||||
try:
|
||||
server_info = parse_cert(server_cert_pem)
|
||||
server_serial = server_info['serial']
|
||||
local_serial = info['serial']
|
||||
|
||||
if server_serial != local_serial:
|
||||
logger.warning(
|
||||
f'Candlepin cert serial mismatch: local={local_serial}, server={server_serial}. '
|
||||
f'Server has issued a new certificate; requesting updated cert.'
|
||||
)
|
||||
# Fetch the new cert from the server
|
||||
new_cert_pem, new_key_pem = client.regenerate_cert(consumer_uuid, cert_pem, key_pem)
|
||||
|
||||
try:
|
||||
new_info = parse_cert(new_cert_pem)
|
||||
logger.info(f'Candlepin cert updated: old serial={local_serial}, new serial={new_info["serial"]}, new expiry={new_info["not_after"]}')
|
||||
except ValueError:
|
||||
logger.warning('Candlepin lifecycle: could not parse updated cert for logging')
|
||||
|
||||
return new_cert_pem, new_key_pem
|
||||
else:
|
||||
logger.debug(f'Candlepin cert serial matches server: {local_serial}')
|
||||
except ValueError as e:
|
||||
logger.warning(f'Candlepin lifecycle: could not parse server cert from get_consumer: {e}')
|
||||
|
||||
# Step 4: Proactive renewal if within the renewal window (or already expired).
|
||||
if needs_renewal(cert_pem, renewal_days):
|
||||
logger.info(f'Candlepin cert expires in {info["days_remaining"]} days (threshold: {renewal_days}); requesting renewal for consumer {consumer_uuid}')
|
||||
new_cert_pem, new_key_pem = client.regenerate_cert(consumer_uuid, cert_pem, key_pem)
|
||||
|
||||
try:
|
||||
new_info = parse_cert(new_cert_pem)
|
||||
logger.info(f'Candlepin cert renewed: old serial={info["serial"]}, new serial={new_info["serial"]}, new expiry={new_info["not_after"]}')
|
||||
except ValueError:
|
||||
logger.warning('Candlepin lifecycle: could not parse renewed cert for logging')
|
||||
|
||||
return new_cert_pem, new_key_pem
|
||||
|
||||
logger.info(f'Candlepin cert is healthy ({info["days_remaining"]} days remaining); no renewal needed')
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def get_candlepin_url():
|
||||
"""Get Candlepin base URL from Django settings."""
|
||||
return settings.AWX_ANALYTICS_CANDLEPIN_URL
|
||||
|
||||
|
||||
def get_renewal_days():
|
||||
"""Get certificate renewal threshold in days from Django settings."""
|
||||
return settings.AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS
|
||||
|
||||
|
||||
def get_candlepin_ca():
|
||||
"""Get Candlepin CA certificate path from Django settings.
|
||||
|
||||
Returns:
|
||||
str: Path to CA certificate file if configured and exists, None otherwise.
|
||||
"""
|
||||
ca_path = settings.AWX_ANALYTICS_CANDLEPIN_CA
|
||||
if ca_path and not os.path.isfile(ca_path):
|
||||
logger.warning(f'Configured Candlepin CA certificate not found at {ca_path}, using system default CA bundle')
|
||||
return None
|
||||
return ca_path
|
||||
|
||||
|
||||
def get_proxy_url():
|
||||
"""Get proxy URL from Django settings."""
|
||||
return settings.AWX_ANALYTICS_CANDLEPIN_PROXY_URL
|
||||
@@ -93,7 +93,6 @@ __all__ = [
|
||||
'get_event_partition_epoch',
|
||||
'cleanup_new_process',
|
||||
'unified_job_class_to_event_table_name',
|
||||
'get_job_variable_prefixes',
|
||||
]
|
||||
|
||||
|
||||
@@ -774,21 +773,6 @@ def get_cpu_effective_capacity(cpu_count, is_control_node=False):
|
||||
return max(1, int(cpu_count * forkcpu))
|
||||
|
||||
|
||||
def get_job_variable_prefixes():
|
||||
"""Return the list of active job variable prefixes based on INCLUDE_DEPRECATED_AWX_VAR_PREFIX setting.
|
||||
|
||||
When True (default), returns both 'awx' and 'tower' prefixes for backward compatibility.
|
||||
When False, returns only 'tower'. The 'awx' prefix is deprecated and this setting
|
||||
will default to False in a future release.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
include_awx = getattr(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', True)
|
||||
if include_awx:
|
||||
return ['awx', 'tower']
|
||||
return ['tower']
|
||||
|
||||
|
||||
def convert_mem_str_to_bytes(mem_str):
|
||||
"""Convert string with suffix indicating units to memory in bytes (base 2)
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
from ansible_base.resource_registry.workload_identity_client import get_workload_identity_client
|
||||
|
||||
__all__ = ['retrieve_workload_identity_jwt_with_claims']
|
||||
|
||||
|
||||
def retrieve_workload_identity_jwt_with_claims(
|
||||
claims: dict,
|
||||
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.
|
||||
"""
|
||||
client = get_workload_identity_client()
|
||||
if client is None:
|
||||
raise RuntimeError("Workload identity client is not configured")
|
||||
kwargs = {"claims": claims, "scope": scope, "audience": audience}
|
||||
if workload_ttl_seconds:
|
||||
kwargs["workload_ttl_seconds"] = workload_ttl_seconds
|
||||
return client.request_workload_jwt(**kwargs).jwt
|
||||
|
||||
@@ -17,13 +17,6 @@ DOCUMENTATION = '''
|
||||
requirements:
|
||||
- Whitelist in configuration
|
||||
- Set AWX_ISOLATED_DATA_DIR, AWX will do this
|
||||
options:
|
||||
collect_host_queries:
|
||||
description: When enabled, scan collections for host query files used in indirect node counting.
|
||||
type: bool
|
||||
default: false
|
||||
env:
|
||||
- name: AWX_COLLECT_HOST_QUERIES
|
||||
'''
|
||||
|
||||
import os
|
||||
@@ -175,28 +168,29 @@ class CallbackModule(CallbackBase):
|
||||
if not artifact_dir:
|
||||
raise RuntimeError('Only suitable in AWX, did not find private_data_dir')
|
||||
|
||||
collect_host_queries = self.get_option('collect_host_queries')
|
||||
|
||||
collections_print = {}
|
||||
# Loop over collections, from ansible-core these are Candidate objects
|
||||
for candidate in list_collections():
|
||||
collection_print = {
|
||||
'version': candidate.ver,
|
||||
}
|
||||
|
||||
if collect_host_queries:
|
||||
embedded_query_file = files(f'ansible_collections.{candidate.namespace}.{candidate.name}') / 'extensions' / 'audit' / 'event_query.yml'
|
||||
if embedded_query_file.exists():
|
||||
with embedded_query_file.open('r') as f:
|
||||
collection_print['host_query'] = f.read()
|
||||
self._display.vv(f"Using embedded query for {candidate.fqcn} v{candidate.ver}")
|
||||
else:
|
||||
query_content, fallback_used, version_used = find_external_query_with_fallback(candidate.namespace, candidate.name, candidate.ver)
|
||||
if query_content:
|
||||
collection_print['host_query'] = query_content
|
||||
if fallback_used:
|
||||
self._display.v(f"Using external query {version_used} for {candidate.fqcn} v{candidate.ver}.")
|
||||
else:
|
||||
self._display.v(f"Using external query for {candidate.fqcn} v{candidate.ver}")
|
||||
# 1. Check for embedded query file (takes precedence)
|
||||
embedded_query_file = files(f'ansible_collections.{candidate.namespace}.{candidate.name}') / 'extensions' / 'audit' / 'event_query.yml'
|
||||
if embedded_query_file.exists():
|
||||
with embedded_query_file.open('r') as f:
|
||||
collection_print['host_query'] = f.read()
|
||||
self._display.vv(f"Using embedded query for {candidate.fqcn} v{candidate.ver}")
|
||||
else:
|
||||
# 2. Check for external query file with version fallback
|
||||
query_content, fallback_used, version_used = find_external_query_with_fallback(candidate.namespace, candidate.name, candidate.ver)
|
||||
if query_content:
|
||||
collection_print['host_query'] = query_content
|
||||
if fallback_used:
|
||||
# AC5.6: Log when fallback is used
|
||||
self._display.v(f"Using external query {version_used} for {candidate.fqcn} v{candidate.ver}.")
|
||||
else:
|
||||
self._display.v(f"Using external query for {candidate.fqcn} v{candidate.ver}")
|
||||
|
||||
collections_print[candidate.fqcn] = collection_print
|
||||
|
||||
|
||||
@@ -538,9 +538,6 @@ AUTOMATION_ANALYTICS_LAST_GATHER = None
|
||||
# Last gathered entries for expensive Analytics
|
||||
AUTOMATION_ANALYTICS_LAST_ENTRIES = ''
|
||||
|
||||
# Candlepin integration settings for analytics authentication
|
||||
AWX_ANALYTICS_CANDLEPIN_URL = 'https://subscription.rhsm.redhat.com/subscription/'
|
||||
|
||||
# Default list of modules allowed for ad hoc commands.
|
||||
# Note: This setting may be overridden by database settings.
|
||||
AD_HOC_COMMANDS = [
|
||||
|
||||
@@ -34,6 +34,9 @@ def get_urlpatterns(prefix=None):
|
||||
re_path(r'^(?:api/)?500.html$', handle_500),
|
||||
re_path(r'^csp-violation/', handle_csp_violation),
|
||||
re_path(r'^login/', handle_login_redirect),
|
||||
# want api/v2/doesnotexist to return a 404, not match the ui urls,
|
||||
# so use a negative lookahead assertion here
|
||||
re_path(r'^(?!api/).*', include('awx.ui.urls', namespace='ui')),
|
||||
]
|
||||
|
||||
if settings.DYNACONF.is_development_mode:
|
||||
@@ -44,12 +47,6 @@ def get_urlpatterns(prefix=None):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# want api/v2/doesnotexist to return a 404, not match the ui urls,
|
||||
# so use a negative lookahead assertion in the pattern below
|
||||
urlpatterns += [
|
||||
re_path(r'^(?!api/).*', include('awx.ui.urls', namespace='ui')),
|
||||
]
|
||||
|
||||
return urlpatterns
|
||||
|
||||
|
||||
|
||||
@@ -55,20 +55,6 @@ options:
|
||||
- Defaults to 10s, but this is handled by the shared module_utils code
|
||||
type: float
|
||||
aliases: [ aap_request_timeout ]
|
||||
max_retries:
|
||||
description:
|
||||
- Specify the max retries to be used with some connection issues.
|
||||
- Defaults to 5.
|
||||
- If value not set, will try environment variable C(AAP_MAX_RETRIES) and then config files.
|
||||
type: int
|
||||
aliases: [ aap_max_retries ]
|
||||
retry_backoff_factor:
|
||||
description:
|
||||
- Backoff factor used when retrying connections.
|
||||
- Defaults to 2.
|
||||
- If value not set, will try environment variable C(AAP_RETRY_BACKOFF_FACTOR) and then config files.
|
||||
type: int
|
||||
aliases: [ aap_retry_backoff_factor ]
|
||||
controller_config_file:
|
||||
description:
|
||||
- Path to the controller config file.
|
||||
|
||||
@@ -76,24 +76,6 @@ options:
|
||||
why: Support for AAP variables
|
||||
alternatives: 'AAP_REQUEST_TIMEOUT'
|
||||
aliases: [ aap_request_timeout ]
|
||||
max_retries:
|
||||
description:
|
||||
- Specify the max retries to be used with some connection issues.
|
||||
- Defaults to 5.
|
||||
- This will not work with the export or import modules.
|
||||
type: int
|
||||
env:
|
||||
- name: AAP_MAX_RETRIES
|
||||
aliases: [ aap_max_retries ]
|
||||
retry_backoff_factor:
|
||||
description:
|
||||
- Backoff factor used when retrying connections.
|
||||
- Defaults to 2.
|
||||
- This will not work with the export or import modules.
|
||||
type: int
|
||||
env:
|
||||
- name: AAP_RETRY_BACKOFF_FACTOR
|
||||
aliases: [ aap_retry_backoff_factor ]
|
||||
notes:
|
||||
- If no I(config_file) is provided we will attempt to use the tower-cli library
|
||||
defaults to find your host information.
|
||||
|
||||
@@ -15,7 +15,6 @@ from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionEr
|
||||
from base64 import b64encode
|
||||
from socket import getaddrinfo, IPPROTO_TCP
|
||||
import time
|
||||
import random
|
||||
from json import loads, dumps
|
||||
from os.path import isfile, expanduser, split, join, exists, isdir
|
||||
from os import access, R_OK, getcwd, environ, getenv
|
||||
@@ -38,19 +37,6 @@ except ImportError:
|
||||
|
||||
CONTROLLER_BASE_PATH_ENV_VAR = "CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX"
|
||||
|
||||
# 502/503: request never reached the server — always safe to retry any method
|
||||
ALWAYS_RETRYABLE = {
|
||||
502: ['GET', 'POST', 'PATCH', 'DELETE'], # Bad Gateway
|
||||
503: ['GET', 'POST', 'PATCH', 'DELETE'], # Service Unavailable
|
||||
}
|
||||
|
||||
# 500/504: idempotent methods only — GETs are reads, PATCH/DELETE are
|
||||
# idempotent by definition; POST is excluded unless we know it's safe.
|
||||
IDEMPOTENT_RETRYABLE = {
|
||||
500: ['GET', 'PATCH', 'DELETE'], # Internal Server Error
|
||||
504: ['GET', 'PATCH', 'DELETE'], # Gateway Timeout
|
||||
}
|
||||
|
||||
|
||||
class ConfigFileException(Exception):
|
||||
pass
|
||||
@@ -86,16 +72,6 @@ class ControllerModule(AnsibleModule):
|
||||
aliases=['aap_request_timeout'],
|
||||
required=False,
|
||||
fallback=(env_fallback, ['CONTROLLER_REQUEST_TIMEOUT', 'AAP_REQUEST_TIMEOUT'])),
|
||||
max_retries=dict(
|
||||
type='int',
|
||||
aliases=['aap_max_retries'],
|
||||
required=False,
|
||||
fallback=(env_fallback, ['AAP_MAX_RETRIES'])),
|
||||
retry_backoff_factor=dict(
|
||||
type='int',
|
||||
aliases=['aap_retry_backoff_factor'],
|
||||
required=False,
|
||||
fallback=(env_fallback, ['AAP_RETRY_BACKOFF_FACTOR'])),
|
||||
aap_token=dict(
|
||||
type='raw',
|
||||
no_log=True,
|
||||
@@ -116,16 +92,12 @@ class ControllerModule(AnsibleModule):
|
||||
'password': 'controller_password',
|
||||
'verify_ssl': 'validate_certs',
|
||||
'request_timeout': 'request_timeout',
|
||||
'max_retries': 'max_retries',
|
||||
'retry_backoff_factor': 'retry_backoff_factor',
|
||||
}
|
||||
host = '127.0.0.1'
|
||||
username = None
|
||||
password = None
|
||||
verify_ssl = True
|
||||
request_timeout = 10
|
||||
max_retries = 5
|
||||
retry_backoff_factor = 2
|
||||
authenticated = False
|
||||
config_name = 'tower_cli.cfg'
|
||||
version_checked = False
|
||||
@@ -516,49 +488,6 @@ class ControllerAPIModule(ControllerModule):
|
||||
def resolve_name_to_id(self, endpoint, name_or_id):
|
||||
return self.get_exactly_one(endpoint, name_or_id)['id']
|
||||
|
||||
def is_retryable(self, status_code, method, endpoint):
|
||||
"""
|
||||
Determine whether a failed request is safe to retry.
|
||||
|
||||
Args:
|
||||
status_code (int): HTTP status code returned by the server.
|
||||
method (str): HTTP verb in uppercase ('GET', 'POST', etc.).
|
||||
endpoint (str): The API endpoint path (e.g. '/api/v2/job_templates/1/launch/').
|
||||
|
||||
Returns:
|
||||
bool: True if the request can safely be retried.
|
||||
"""
|
||||
# --- Always safe: 502/503 mean the request never reached AWX ---
|
||||
if method in ALWAYS_RETRYABLE.get(status_code, []):
|
||||
return True
|
||||
|
||||
# --- Safe for inherently idempotent methods (GET, PATCH, DELETE) ---
|
||||
if method in IDEMPOTENT_RETRYABLE.get(status_code, []):
|
||||
return True
|
||||
|
||||
# --- POST/PATCH on 500/504: safe UNLESS the endpoint triggers execution ---
|
||||
if method in ('POST', 'PATCH') and status_code in (500, 504):
|
||||
|
||||
# /launch, /relaunch, /callback etc. — retrying would double-execute
|
||||
# Catches: /job_templates/1/launch/, /workflow_job_templates/1/launch/,
|
||||
# /jobs/1/relaunch/, /ad_hoc_commands/1/relaunch/ …
|
||||
launch_keywords = ('/launch', '/relaunch', '/callback')
|
||||
if any(kw in endpoint for kw in launch_keywords):
|
||||
return False
|
||||
|
||||
# POST to the ad_hoc_commands collection root creates AND immediately
|
||||
# executes the command — not safe to retry.
|
||||
# PATCH to /ad_hoc_commands/<id>/ is fine (handled by PATCH branch above
|
||||
# but would also pass through here correctly).
|
||||
if method == 'POST' and endpoint.rstrip('/').endswith('/ad_hoc_commands'):
|
||||
return False
|
||||
|
||||
# All other POST/PATCH endpoints (create resource, update resource) are
|
||||
# safe: a 500/504 before the DB transaction commits means no side-effect.
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def make_request(self, method, endpoint, *args, **kwargs):
|
||||
# In case someone is calling us directly; make sure we were given a method, let's not just assume a GET
|
||||
if not method:
|
||||
@@ -583,155 +512,121 @@ class ControllerAPIModule(ControllerModule):
|
||||
headers.setdefault('Content-Type', 'application/json')
|
||||
kwargs['headers'] = headers
|
||||
|
||||
data = None
|
||||
data = None # Important, if content type is not JSON, this should not be dict type
|
||||
if headers.get('Content-Type', '') == 'application/json':
|
||||
data = dumps(kwargs.get('data', {}))
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Retry loop — wraps only the session.open() + HTTPError handling
|
||||
# Everything above (auth, URL building) happens once before the loop
|
||||
# ----------------------------------------------------------------
|
||||
max_retries = self.max_retries
|
||||
backoff_factor = self.retry_backoff_factor
|
||||
last_response = None
|
||||
|
||||
for attempt in range(max_retries + 1): # attempt 0 = first try
|
||||
|
||||
if attempt > 0:
|
||||
sleep_time = (backoff_factor ** (attempt - 1)) * (0.5 + random.random())
|
||||
self.warn(
|
||||
'Retrying {0} {1} (attempt {2}/{3}) after {4}s due to status {5}'.format(
|
||||
method, url.path, attempt, max_retries, sleep_time,
|
||||
last_response if last_response else 'connection error'
|
||||
)
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
try:
|
||||
response = self.session.open(
|
||||
method, url.geturl(),
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
validate_certs=self.verify_ssl,
|
||||
follow_redirects=True,
|
||||
data=data
|
||||
)
|
||||
|
||||
except (SSLValidationError) as ssl_err:
|
||||
# SSL errors are never retryable — cert problems won't fix themselves
|
||||
self.fail_json(msg="Could not establish a secure connection to your host ({0}): {1}.".format(url.netloc, ssl_err))
|
||||
|
||||
except (ConnectionError) as con_err:
|
||||
# Connection errors may be transient — retry if we have attempts left
|
||||
last_response = 'ConnectionError'
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
self.fail_json(msg="There was a network error of some kind trying to connect to your host ({0}): {1}.".format(url.netloc, con_err))
|
||||
|
||||
except (HTTPError) as he:
|
||||
# ---- Retryable HTTP errors ----
|
||||
if self.is_retryable(he.code, method, url.path):
|
||||
# Exhausted retries on a retryable error go on to regular failure checks.
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
# Exhausted retries - provide informative message
|
||||
self.fail_json(
|
||||
msg="Request to {0} failed with status {1} after {2} retries. "
|
||||
"This may indicate the server is overloaded.".format(url.path, he.code, max_retries)
|
||||
)
|
||||
# ---- Non-retryable HTTP errors (existing behaviour preserved) ----
|
||||
if he.code >= 500:
|
||||
self.fail_json(msg='The host sent back a server error ({1}): {0}. Please check the logs and try again later'.format(url.path, he))
|
||||
elif he.code == 401:
|
||||
self.fail_json(msg='Invalid authentication credentials for {0} (HTTP 401).'.format(url.path))
|
||||
elif he.code == 403:
|
||||
body = he.read()
|
||||
raw = body.decode('utf-8') if isinstance(body, bytes) else str(body)
|
||||
if 'unable to connect to database' in raw.lower():
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
self.fail_json(
|
||||
msg="Request to {0} failed with status 403 (database unavailable) after {1} retries.".format(url.path, max_retries),
|
||||
)
|
||||
# Reuse raw instead of reading again
|
||||
try:
|
||||
err_msg = loads(raw)
|
||||
err_msg = err_msg['detail']
|
||||
except (ValueError, KeyError):
|
||||
err_msg = raw
|
||||
prepend_msg = " Use the collection ansible.platform to modify resources Organization, User, or Team." if (
|
||||
"this resource via the platform ingress") in err_msg else ""
|
||||
self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).{2}".format(url.path, method, prepend_msg))
|
||||
elif he.code == 404:
|
||||
if kwargs.get('return_none_on_404', False):
|
||||
return None
|
||||
self.fail_json(msg='The requested object could not be found at {0}.'.format(url.path))
|
||||
elif he.code == 405:
|
||||
self.fail_json(msg="Cannot make a request with the {0} method to this endpoint {1}".format(method, url.path))
|
||||
elif he.code >= 400:
|
||||
page_data = he.read()
|
||||
try:
|
||||
return {'status_code': he.code, 'json': loads(page_data)}
|
||||
except ValueError:
|
||||
return {'status_code': he.code, 'text': page_data}
|
||||
else:
|
||||
self.fail_json(msg="Unexpected return code when calling {0}: {1}".format(url.geturl(), he))
|
||||
|
||||
except (Exception) as e:
|
||||
self.fail_json(msg="There was an unknown error when trying to connect to {2}: {0} {1}".format(type(e).__name__, e, url.geturl()))
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Successful response — fall through from session.open()
|
||||
# The version check and response parsing happen once on success
|
||||
# ----------------------------------------------------------------
|
||||
if not self.version_checked:
|
||||
try:
|
||||
response = self.session.open(
|
||||
method, url.geturl(),
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
validate_certs=self.verify_ssl,
|
||||
follow_redirects=True,
|
||||
data=data
|
||||
)
|
||||
except (SSLValidationError) as ssl_err:
|
||||
self.fail_json(msg="Could not establish a secure connection to your host ({1}): {0}.".format(url.netloc, ssl_err))
|
||||
except (ConnectionError) as con_err:
|
||||
self.fail_json(msg="There was a network error of some kind trying to connect to your host ({1}): {0}.".format(url.netloc, con_err))
|
||||
except (HTTPError) as he:
|
||||
# Sanity check: Did the server send back some kind of internal error?
|
||||
if he.code >= 500:
|
||||
self.fail_json(msg='The host sent back a server error ({1}): {0}. Please check the logs and try again later'.format(url.path, he))
|
||||
# Sanity check: Did we fail to authenticate properly? If so, fail out now; this is always a failure.
|
||||
elif he.code == 401:
|
||||
self.fail_json(msg='Invalid authentication credentials for {0} (HTTP 401).'.format(url.path))
|
||||
# Sanity check: Did we get a forbidden response, which means that the user isn't allowed to do this? Report that.
|
||||
elif he.code == 403:
|
||||
# Hack: Tell the customer to use the platform supported collection when interacting with Org, Team, User Controller endpoints
|
||||
err_msg = he.fp.read().decode('utf-8')
|
||||
try:
|
||||
controller_type = response.getheader('X-API-Product-Name', None)
|
||||
controller_version = response.getheader('X-API-Product-Version', None)
|
||||
except Exception:
|
||||
controller_type = response.info().getheader('X-API-Product-Name', None)
|
||||
controller_version = response.info().getheader('X-API-Product-Version', None)
|
||||
|
||||
parsed_collection_version = Version(self._COLLECTION_VERSION).version
|
||||
if controller_version:
|
||||
parsed_controller_version = Version(controller_version).version
|
||||
if controller_type == 'AWX':
|
||||
collection_compare_ver = parsed_collection_version[0]
|
||||
controller_compare_ver = parsed_controller_version[0]
|
||||
else:
|
||||
collection_compare_ver = "{0}.{1}".format(parsed_collection_version[0], parsed_collection_version[1])
|
||||
controller_compare_ver = '{0}.{1}'.format(parsed_controller_version[0], parsed_controller_version[1])
|
||||
|
||||
if self._COLLECTION_TYPE not in self.collection_to_version or self.collection_to_version[self._COLLECTION_TYPE] != controller_type:
|
||||
self.warn("You are using the {0} version of this collection but connecting to {1}".format(self._COLLECTION_TYPE, controller_type))
|
||||
elif collection_compare_ver != controller_compare_ver:
|
||||
self.warn(
|
||||
"You are running collection version {0} but connecting to {2} version {1}".format(
|
||||
self._COLLECTION_VERSION, controller_version, controller_type
|
||||
)
|
||||
)
|
||||
|
||||
self.version_checked = True
|
||||
|
||||
response_body = ''
|
||||
try:
|
||||
response_body = response.read()
|
||||
except (Exception) as e:
|
||||
self.fail_json(msg="Failed to read response body: {0}".format(e))
|
||||
|
||||
response_json = {}
|
||||
if response_body and response_body != '':
|
||||
# Defensive coding. Handle json responses and non-json responses
|
||||
err_msg = loads(err_msg)
|
||||
err_msg = err_msg['detail']
|
||||
# JSONDecodeError only available on Python 3.5+
|
||||
except ValueError:
|
||||
pass
|
||||
prepend_msg = " Use the collection ansible.platform to modify resources Organization, User, or Team." if (
|
||||
"this resource via the platform ingress") in err_msg else ""
|
||||
self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).{2}".format(url.path, method, prepend_msg))
|
||||
# Sanity check: Did we get a 404 response?
|
||||
# Requests with primary keys will return a 404 if there is no response, and we want to consistently trap these.
|
||||
elif he.code == 404:
|
||||
if kwargs.get('return_none_on_404', False):
|
||||
return None
|
||||
self.fail_json(msg='The requested object could not be found at {0}.'.format(url.path))
|
||||
# Sanity check: Did we get a 405 response?
|
||||
# A 405 means we used a method that isn't allowed. Usually this is a bad request, but it requires special treatment because the
|
||||
# API sends it as a logic error in a few situations (e.g. trying to cancel a job that isn't running).
|
||||
elif he.code == 405:
|
||||
self.fail_json(msg="Cannot make a request with the {0} method to this endpoint {1}".format(method, url.path))
|
||||
# Sanity check: Did we get some other kind of error? If so, write an appropriate error message.
|
||||
elif he.code >= 400:
|
||||
# We are going to return a 400 so the module can decide what to do with it
|
||||
page_data = he.read()
|
||||
try:
|
||||
response_json = loads(response_body)
|
||||
except (Exception) as e:
|
||||
self.fail_json(msg="Failed to parse the response json: {0}".format(e))
|
||||
|
||||
if PY2:
|
||||
status_code = response.getcode()
|
||||
return {'status_code': he.code, 'json': loads(page_data)}
|
||||
# JSONDecodeError only available on Python 3.5+
|
||||
except ValueError:
|
||||
return {'status_code': he.code, 'text': page_data}
|
||||
elif he.code == 204 and method == 'DELETE':
|
||||
# A 204 is a normal response for a delete function
|
||||
pass
|
||||
else:
|
||||
status_code = response.status
|
||||
self.fail_json(msg="Unexpected return code when calling {0}: {1}".format(url.geturl(), he))
|
||||
except (Exception) as e:
|
||||
self.fail_json(msg="There was an unknown error when trying to connect to {2}: {0} {1}".format(type(e).__name__, e, url.geturl()))
|
||||
|
||||
return {'status_code': status_code, 'json': response_json}
|
||||
if not self.version_checked:
|
||||
# In PY2 we get back an HTTPResponse object but PY2 is returning an addinfourl
|
||||
# First try to get the headers in PY3 format and then drop down to PY2.
|
||||
try:
|
||||
controller_type = response.getheader('X-API-Product-Name', None)
|
||||
controller_version = response.getheader('X-API-Product-Version', None)
|
||||
except Exception:
|
||||
controller_type = response.info().getheader('X-API-Product-Name', None)
|
||||
controller_version = response.info().getheader('X-API-Product-Version', None)
|
||||
|
||||
parsed_collection_version = Version(self._COLLECTION_VERSION).version
|
||||
if controller_version:
|
||||
parsed_controller_version = Version(controller_version).version
|
||||
if controller_type == 'AWX':
|
||||
collection_compare_ver = parsed_collection_version[0]
|
||||
controller_compare_ver = parsed_controller_version[0]
|
||||
else:
|
||||
collection_compare_ver = "{0}.{1}".format(parsed_collection_version[0], parsed_collection_version[1])
|
||||
controller_compare_ver = '{0}.{1}'.format(parsed_controller_version[0], parsed_controller_version[1])
|
||||
|
||||
if self._COLLECTION_TYPE not in self.collection_to_version or self.collection_to_version[self._COLLECTION_TYPE] != controller_type:
|
||||
self.warn("You are using the {0} version of this collection but connecting to {1}".format(self._COLLECTION_TYPE, controller_type))
|
||||
elif collection_compare_ver != controller_compare_ver:
|
||||
self.warn(
|
||||
"You are running collection version {0} but connecting to {2} version {1}".format(
|
||||
self._COLLECTION_VERSION, controller_version, controller_type
|
||||
)
|
||||
)
|
||||
|
||||
self.version_checked = True
|
||||
|
||||
response_body = ''
|
||||
try:
|
||||
response_body = response.read()
|
||||
except (Exception) as e:
|
||||
self.fail_json(msg="Failed to read response body: {0}".format(e))
|
||||
|
||||
response_json = {}
|
||||
if response_body and response_body != '':
|
||||
try:
|
||||
response_json = loads(response_body)
|
||||
except (Exception) as e:
|
||||
self.fail_json(msg="Failed to parse the response json: {0}".format(e))
|
||||
|
||||
if PY2:
|
||||
status_code = response.getcode()
|
||||
else:
|
||||
status_code = response.status
|
||||
return {'status_code': status_code, 'json': response_json}
|
||||
|
||||
def api_path(self, app_key=None):
|
||||
|
||||
|
||||
@@ -276,7 +276,6 @@ options:
|
||||
- ''
|
||||
- 'github'
|
||||
- 'gitlab'
|
||||
- 'bitbucket_dc'
|
||||
webhook_credential:
|
||||
description:
|
||||
- Personal Access Token for posting back the status to the service API
|
||||
@@ -437,7 +436,7 @@ def main():
|
||||
scm_branch=dict(),
|
||||
ask_scm_branch_on_launch=dict(type='bool'),
|
||||
job_slice_count=dict(type='int'),
|
||||
webhook_service=dict(choices=['github', 'gitlab', 'bitbucket_dc', '']),
|
||||
webhook_service=dict(choices=['github', 'gitlab', '']),
|
||||
webhook_credential=dict(),
|
||||
labels=dict(type="list", elements='str'),
|
||||
notification_templates_started=dict(type="list", elements='str'),
|
||||
|
||||
@@ -117,7 +117,6 @@ options:
|
||||
choices:
|
||||
- github
|
||||
- gitlab
|
||||
- bitbucket_dc
|
||||
webhook_credential:
|
||||
description:
|
||||
- Personal Access Token for posting back the status to the service API
|
||||
@@ -829,7 +828,7 @@ def main():
|
||||
ask_inventory_on_launch=dict(type='bool'),
|
||||
ask_scm_branch_on_launch=dict(type='bool'),
|
||||
ask_limit_on_launch=dict(type='bool'),
|
||||
webhook_service=dict(choices=['github', 'gitlab', 'bitbucket_dc']),
|
||||
webhook_service=dict(choices=['github', 'gitlab']),
|
||||
webhook_credential=dict(),
|
||||
labels=dict(type="list", elements='str'),
|
||||
notification_templates_started=dict(type="list", elements='str'),
|
||||
|
||||
@@ -236,7 +236,6 @@ def run_module(request, collection_import, mocker):
|
||||
|
||||
with mock.patch.object(resource_class, '_load_params', new=mock_load_params):
|
||||
mocker.patch('ansible.module_utils.basic._ANSIBLE_PROFILE', 'legacy')
|
||||
mocker.patch('ansible.module_utils.basic._PARSED_MODULE_ARGS', {'_ansible_inject_invocation': True}, create=True)
|
||||
|
||||
with mock.patch('ansible.module_utils.urls.Request.open', new=new_open):
|
||||
with _get_tower_cli_mgr(new_request):
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
from awx.main.models import JobTemplate, WorkflowJobTemplate
|
||||
|
||||
|
||||
# The backend supports these webhook services on job/workflow templates
|
||||
# (see awx/main/models/mixins.py). The collection modules must accept all of
|
||||
# them in their argument_spec ``choices`` list. This test guards against the
|
||||
# module's choices drifting from the backend -- see AAP-45980, where
|
||||
# ``bitbucket_dc`` had been supported by the API since migration 0188 but was
|
||||
# still being rejected by the job_template/workflow_job_template modules.
|
||||
WEBHOOK_SERVICES = ['github', 'gitlab', 'bitbucket_dc']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('webhook_service', WEBHOOK_SERVICES)
|
||||
def test_job_template_accepts_webhook_service(run_module, admin_user, project, inventory, webhook_service):
|
||||
result = run_module(
|
||||
'job_template',
|
||||
{
|
||||
'name': 'foo',
|
||||
'playbook': 'helloworld.yml',
|
||||
'project': project.name,
|
||||
'inventory': inventory.name,
|
||||
'webhook_service': webhook_service,
|
||||
'state': 'present',
|
||||
},
|
||||
admin_user,
|
||||
)
|
||||
|
||||
assert not result.get('failed', False), result.get('msg', result)
|
||||
assert result.get('changed', False), result
|
||||
|
||||
jt = JobTemplate.objects.get(name='foo')
|
||||
assert jt.webhook_service == webhook_service
|
||||
|
||||
# Re-running with the same args must be a no-op (idempotence).
|
||||
result = run_module(
|
||||
'job_template',
|
||||
{
|
||||
'name': 'foo',
|
||||
'playbook': 'helloworld.yml',
|
||||
'project': project.name,
|
||||
'inventory': inventory.name,
|
||||
'webhook_service': webhook_service,
|
||||
'state': 'present',
|
||||
},
|
||||
admin_user,
|
||||
)
|
||||
assert not result.get('failed', False), result.get('msg', result)
|
||||
assert not result.get('changed', True), result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('webhook_service', WEBHOOK_SERVICES)
|
||||
def test_workflow_job_template_accepts_webhook_service(run_module, admin_user, organization, webhook_service):
|
||||
result = run_module(
|
||||
'workflow_job_template',
|
||||
{
|
||||
'name': 'foo-workflow',
|
||||
'organization': organization.name,
|
||||
'webhook_service': webhook_service,
|
||||
'state': 'present',
|
||||
},
|
||||
admin_user,
|
||||
)
|
||||
|
||||
assert not result.get('failed', False), result.get('msg', result)
|
||||
assert result.get('changed', False), result
|
||||
|
||||
wfjt = WorkflowJobTemplate.objects.get(name='foo-workflow')
|
||||
assert wfjt.webhook_service == webhook_service
|
||||
|
||||
# Re-running with the same args must be a no-op (idempotence).
|
||||
result = run_module(
|
||||
'workflow_job_template',
|
||||
{
|
||||
'name': 'foo-workflow',
|
||||
'organization': organization.name,
|
||||
'webhook_service': webhook_service,
|
||||
'state': 'present',
|
||||
},
|
||||
admin_user,
|
||||
)
|
||||
assert not result.get('failed', False), result.get('msg', result)
|
||||
assert not result.get('changed', True), result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_rejects_unknown_webhook_service(run_module, admin_user, project, inventory):
|
||||
result = run_module(
|
||||
'job_template',
|
||||
{
|
||||
'name': 'foo',
|
||||
'playbook': 'helloworld.yml',
|
||||
'project': project.name,
|
||||
'inventory': inventory.name,
|
||||
'webhook_service': 'not_a_real_service',
|
||||
'state': 'present',
|
||||
},
|
||||
admin_user,
|
||||
)
|
||||
assert result.get('failed', False), result
|
||||
assert 'webhook_service' in result.get('msg', '')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workflow_job_template_rejects_unknown_webhook_service(run_module, admin_user, organization):
|
||||
result = run_module(
|
||||
'workflow_job_template',
|
||||
{
|
||||
'name': 'foo-workflow',
|
||||
'organization': organization.name,
|
||||
'webhook_service': 'not_a_real_service',
|
||||
'state': 'present',
|
||||
},
|
||||
admin_user,
|
||||
)
|
||||
assert result.get('failed', False), result
|
||||
assert 'webhook_service' in result.get('msg', '')
|
||||
@@ -85,23 +85,15 @@ def as_user(v, username, password=None):
|
||||
if config.use_sessions:
|
||||
session_id = None
|
||||
domain = None
|
||||
cookie_name = connection.session_cookie_name
|
||||
# requests doesn't provide interface for retrieving
|
||||
# domain segregated cookies other than iterating.
|
||||
for cookie in connection.session.cookies:
|
||||
if cookie.name == cookie_name:
|
||||
if cookie.name == connection.session_cookie_name:
|
||||
session_id = cookie.value
|
||||
domain = cookie.domain
|
||||
break
|
||||
if session_id is None and cookie_name != 'gateway_sessionid':
|
||||
for cookie in connection.session.cookies:
|
||||
if cookie.name == 'gateway_sessionid':
|
||||
session_id = cookie.value
|
||||
domain = cookie.domain
|
||||
cookie_name = 'gateway_sessionid'
|
||||
break
|
||||
if session_id:
|
||||
del connection.session.cookies[cookie_name]
|
||||
del connection.session.cookies[connection.session_cookie_name]
|
||||
kwargs = connection.get_session_requirements()
|
||||
else:
|
||||
previous_auth = connection.session.auth
|
||||
@@ -110,11 +102,9 @@ def as_user(v, username, password=None):
|
||||
yield
|
||||
finally:
|
||||
if config.use_sessions:
|
||||
for name in {connection.session_cookie_name, cookie_name}:
|
||||
with suppress(KeyError):
|
||||
del connection.session.cookies[name]
|
||||
del connection.session.cookies[connection.session_cookie_name]
|
||||
if session_id:
|
||||
connection.session.cookies.set(cookie_name, session_id, domain=domain)
|
||||
connection.session.cookies.set(connection.session_cookie_name, session_id, domain=domain)
|
||||
else:
|
||||
connection.session.auth = previous_auth
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ setup(
|
||||
install_requires=[
|
||||
'PyYAML',
|
||||
'requests',
|
||||
'packaging',
|
||||
'setuptools',
|
||||
],
|
||||
python_requires=">=3.11",
|
||||
extras_require={'formatting': ['jq'], 'websockets': ['websocket-client==0.57.0'], 'crypto': ['cryptography']},
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
from http.cookiejar import Cookie
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from awxkit.api.client import Connection
|
||||
from awxkit.awx.utils import as_user
|
||||
from awxkit.config import config
|
||||
|
||||
|
||||
def _make_cookie(name, value, domain='.example.com'):
|
||||
return Cookie(
|
||||
version=0,
|
||||
name=name,
|
||||
value=value,
|
||||
port=None,
|
||||
port_specified=False,
|
||||
domain=domain,
|
||||
domain_specified=True,
|
||||
domain_initial_dot=True,
|
||||
path='/',
|
||||
path_specified=True,
|
||||
secure=False,
|
||||
expires=None,
|
||||
discard=True,
|
||||
comment=None,
|
||||
comment_url=None,
|
||||
rest={},
|
||||
rfc2109=False,
|
||||
)
|
||||
|
||||
|
||||
class FakeCookieJar:
|
||||
def __init__(self):
|
||||
self._cookies = {}
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cookies.values())
|
||||
|
||||
def get(self, name, default=None):
|
||||
c = self._cookies.get(name)
|
||||
return c.value if c else default
|
||||
|
||||
def set(self, name, value, domain='.example.com'):
|
||||
self._cookies[name] = _make_cookie(name, value, domain)
|
||||
|
||||
def __delitem__(self, name):
|
||||
del self._cookies[name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connection():
|
||||
conn = mock.MagicMock(spec=Connection)
|
||||
conn.session = mock.MagicMock()
|
||||
conn.session.cookies = FakeCookieJar()
|
||||
conn.session_cookie_name = 'sessionid'
|
||||
conn.get_session_requirements.return_value = {'next': '/api/controller/'}
|
||||
yield conn
|
||||
|
||||
|
||||
class TestAsUserSessionAuth:
|
||||
"""Tests for as_user() with session-based authentication."""
|
||||
|
||||
def setup_method(self):
|
||||
self._orig = config.use_sessions
|
||||
config.use_sessions = True
|
||||
|
||||
def teardown_method(self):
|
||||
config.use_sessions = self._orig
|
||||
|
||||
def test_swaps_sessionid_cookie(self, connection):
|
||||
connection.session.cookies.set('sessionid', 'admin_session')
|
||||
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
connection.login.assert_called_once_with('testuser', 'testpass', next='/api/controller/')
|
||||
|
||||
assert connection.session.cookies.get('sessionid') == 'admin_session'
|
||||
|
||||
def test_gateway_sessionid_fallback(self, connection):
|
||||
"""When session_cookie_name is 'sessionid' but actual cookie is 'gateway_sessionid',
|
||||
as_user() should find and swap the gateway cookie."""
|
||||
connection.session.cookies.set('gateway_sessionid', 'admin_gw_session')
|
||||
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
connection.login.assert_called_once_with('testuser', 'testpass', next='/api/controller/')
|
||||
assert connection.session.cookies.get('gateway_sessionid') is None
|
||||
|
||||
assert connection.session.cookies.get('gateway_sessionid') == 'admin_gw_session'
|
||||
|
||||
def test_gateway_fallback_not_triggered_when_sessionid_exists(self, connection):
|
||||
"""When sessionid cookie exists, gateway_sessionid fallback should not trigger."""
|
||||
connection.session.cookies.set('sessionid', 'admin_session')
|
||||
connection.session.cookies.set('gateway_sessionid', 'admin_gw_session')
|
||||
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
pass
|
||||
|
||||
assert connection.session.cookies.get('sessionid') == 'admin_session'
|
||||
assert connection.session.cookies.get('gateway_sessionid') == 'admin_gw_session'
|
||||
|
||||
def test_accepts_user_object(self, connection):
|
||||
from awxkit.api import User
|
||||
|
||||
user = mock.MagicMock(spec=User)
|
||||
user.username = 'bob'
|
||||
user.password = 'secret'
|
||||
connection.session.cookies.set('sessionid', 'admin_session')
|
||||
|
||||
with as_user(connection, user):
|
||||
connection.login.assert_called_once_with('bob', 'secret', next='/api/controller/')
|
||||
|
||||
def test_restores_gateway_cookie_after_exception(self, connection):
|
||||
connection.session.cookies.set('gateway_sessionid', 'admin_gw_session')
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
assert connection.session.cookies.get('gateway_sessionid') == 'admin_gw_session'
|
||||
|
||||
def test_no_session_cookie_at_all(self, connection):
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
connection.login.assert_called_once()
|
||||
|
||||
|
||||
class TestAsUserBasicAuth:
|
||||
"""Tests for as_user() with basic authentication."""
|
||||
|
||||
def setup_method(self):
|
||||
self._orig = config.use_sessions
|
||||
config.use_sessions = False
|
||||
|
||||
def teardown_method(self):
|
||||
config.use_sessions = self._orig
|
||||
|
||||
def test_swaps_basic_auth(self, connection):
|
||||
connection.session.auth = ('admin', 'adminpass')
|
||||
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
connection.login.assert_called_once_with('testuser', 'testpass')
|
||||
|
||||
assert connection.session.auth == ('admin', 'adminpass')
|
||||
|
||||
def test_restores_basic_auth_after_exception(self, connection):
|
||||
connection.session.auth = ('admin', 'adminpass')
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with as_user(connection, 'testuser', 'testpass'):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
assert connection.session.auth == ('admin', 'adminpass')
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Update aap-ci tekton-catalog pipeline bundles",
|
||||
"matchPackageNames": ["/^quay\\.io\\/aap-ci\\/tekton-catalog\\/pipeline\\//"],
|
||||
"matchManagers": ["tekton"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,7 +24,6 @@ atomicwrites
|
||||
flake8
|
||||
yamllint
|
||||
pip>=25.3 # PEP 660 – Editable installs for pyproject.toml based builds (wheel based)
|
||||
awx-tui
|
||||
|
||||
# python debuggers
|
||||
debugpy
|
||||
|
||||
@@ -42,7 +42,6 @@ services:
|
||||
DJANGO_SUPERUSER_PASSWORD: {{ admin_password }}
|
||||
UWSGI_MOUNT_PATH: {{ ingress_path }}
|
||||
DJANGO_COLORS: "${DJANGO_COLORS:-}"
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION: "${SETUPTOOLS_SCM_PRETEND_VERSION:-}"
|
||||
{% if loop.index == 1 %}
|
||||
RUN_MIGRATIONS: 1
|
||||
{% endif %}
|
||||
|
||||
@@ -35,30 +35,6 @@ if output=$(ANSIBLE_REVERSE_RESOURCE_SYNC=false awx-manage createsuperuser --noi
|
||||
fi
|
||||
echo "Admin password: ${DJANGO_SUPERUSER_PASSWORD}"
|
||||
|
||||
# Configure awx-tui to connect to the local AWX instance
|
||||
AWX_TUI_CONFIG_DIR="${HOME}/.config/awx-tui"
|
||||
AWX_TUI_CONFIG_FILE="${AWX_TUI_CONFIG_DIR}/config.yaml"
|
||||
mkdir -p "${AWX_TUI_CONFIG_DIR}"
|
||||
python3 -c "
|
||||
import yaml, os
|
||||
config = {
|
||||
'instances': {
|
||||
'local': {
|
||||
'url': 'https://localhost:8043',
|
||||
'auth': {
|
||||
'method': 'password',
|
||||
'username': 'admin',
|
||||
'password': os.environ['DJANGO_SUPERUSER_PASSWORD'],
|
||||
},
|
||||
'verify_ssl': False,
|
||||
}
|
||||
}
|
||||
}
|
||||
with open('${AWX_TUI_CONFIG_FILE}', 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
"
|
||||
chmod 600 "${AWX_TUI_CONFIG_FILE}"
|
||||
|
||||
ANSIBLE_REVERSE_RESOURCE_SYNC=false awx-manage create_preload_data
|
||||
awx-manage register_default_execution_environments
|
||||
|
||||
|
||||
Reference in New Issue
Block a user