Compare commits

..

77 Commits

Author SHA1 Message Date
Shane McDonald
05e9b29460 Merge pull request #13963 from Akasurde/doc_fix
Minor typo fix in docs
2023-05-10 08:33:01 -04:00
John Westcott IV
7f020052db Make state exists universal in collection (#13890)
Make state: exists available for all API modules

Make state:exists return the ID just like it would if it created the resource
2023-05-10 09:05:29 -03:00
Rick Elrod
53260213ba Issue template: Remind people to use security@ (#13971)
Signed-off-by: Rick Elrod <rick@elrod.me>
2023-05-09 11:00:02 -05:00
Abhijeet Kasurde
7d1ee37689 Minor typo fix in docs
Signed-off-by: Abhijeet Kasurde <akasurde@redhat.com>
2023-05-08 07:47:07 -07:00
Seth Foster
45c13c25a4 Set receptor log level to info (#13958) 2023-05-05 15:01:21 -04:00
Alan Rominger
ba0e9831d2 Fix bug with parent_key filtering (#13957)
This was making host sub-list views non-functional
  specifically for constructed and smart inventory
  views would always return 0 results before this fix
2023-05-05 14:10:55 -04:00
Shane McDonald
92dce85468 Merge pull request #13955 from shanemcd/dark-processed
Add missing comma in host_status_counts list
2023-05-05 10:55:47 -04:00
Shane McDonald
77139e4138 Add missing comma in host_status_counts list 2023-05-05 08:02:38 -04:00
Sarah Akus
b28e14c630 Merge pull request #13941 from vidyanambiar/freq-details
Fix for incorrect value for 'Run on' field in frequency details
2023-05-02 13:19:06 -04:00
Alan Rominger
bf5594e338 Merge pull request #13930 from sean-m-sullivan/collection_role_update
In collection, allow roles to be added to multiple teams and users
2023-05-02 12:54:22 -04:00
Alan Rominger
f012a69c93 Allow running AWX checks on forks (#13938) 2023-05-02 11:47:29 -04:00
sean-m-sullivan
0fb334e372 collection, allow roles to be added to multiple teams and users 2023-05-02 07:34:38 -04:00
Vidya Nambiar
b7c5cbac3f Fix for 'Run on' field in frequency details 2023-05-01 17:03:51 -04:00
Sarah Akus
eb7407593f Merge pull request #13915 from marshmalien/10877-dup-freq-types-schedule
Show schedule details warning when RRule is unsupported
2023-04-28 14:21:23 -04:00
Sarah Akus
287596234c Merge pull request #13874 from marshmalien/8898-fix-update-vault-credentials
Fix vault credential update error when vault_id is missing
2023-04-28 13:50:46 -04:00
Sarah Akus
ee7b3470da Merge pull request #13873 from marshmalien/10799-bug-prompt-launch-credential-type-dropdown-complete
Fix screen crash when changing credential type in launch prompt dropdown
2023-04-28 13:25:40 -04:00
Jessica Steurer
0faa1c8a24 Merge branch 'devel' into 8898-fix-update-vault-credentials 2023-04-28 10:37:15 -03:00
Alan Rominger
77175d2862 Consolidate get_queryset methods (#13906)
In a prior merge, we added the ability to slap filter_read_permission = False on a view to get a certain functionality where it didn't filter a sublist the view is showing.

This logic already existed in a highly duplicated form among a number of views, so this deletes those methods in favor of the flag.
2023-04-28 09:10:18 -04:00
Klaas Demter
22464a5838 Enhance secret retrieval documentation (#13914) 2023-04-26 19:32:40 +00:00
Sarah Akus
3919ea6270 Merge pull request #13905 from vidyanambiar/topology-rbac
Make Topology view and Instances visible only to system admin/auditor
2023-04-26 15:13:32 -04:00
Marliana Lara
9d9f650051 Show schedule details warning when RRule is unsupported 2023-04-26 14:49:43 -04:00
jessicamack
66a3cb6b09 Merge pull request #13858 from jessicamack/13322-catch-sigterm
Catch SIGTERM or SIGINT and send offline message
2023-04-26 12:24:34 -04:00
jessicamack
d282393035 change exit code
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
jessicamack
6ea3b20912 reverse previous commit to break into separate PR
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
jessicamack
3025ef0dfa move with block inside of while to free up persistent db connection
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
jessicamack
397d58c459 removed TODO. moved signal catches to handle()
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
jessicamack
d739a4a90a updated black and ran again to fix lint formatting
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
jessicamack
3fe64ad101 fix signal handler. black reformats
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
jessicamack
919d1e5d40 catch SIGTERM or SIGINT and send offline message
Signed-off-by: jessicamack <jmack@redhat.com>
2023-04-26 11:37:59 -04:00
John Westcott IV
7fda4b0675 Merge pull request #13903 from john-westcott-iv/collection_intergration_tests
Enhance collection intergration tests
2023-04-26 09:08:00 -04:00
Gabriel Muniz
d8af19d169 Fix organization not showing all galaxy credentials for org admin (#13676)
* Fix organization not showing all galaxy credentials for org admin

* Add basic test to ensure counts

* refactored approach to allow removal of redundant code

* Allow configurable prefetch_related

* implicitly get related fields

* Removed extra queryset code
2023-04-25 15:33:42 -04:00
Vidya Nambiar
1821e540f7 Merge branch 'devel' into topology-rbac 2023-04-25 15:32:17 -04:00
Vidya Nambiar
77be6c7495 tests 2023-04-25 14:18:05 -04:00
John Westcott IV
baed869d93 Remove project_manual integration test
This test can no longer be performed without manual intervention because of how jobs are now run in EEs
2023-04-25 13:49:50 -04:00
John Westcott IV
b87ff45c07 Enhance collection test
ad_hoc_command_cancel really can no longer timeout on a cancel (it happens sub second) and remove unneeded block

Modified all test to respect test_id parameter so that all tests can be run togeather as a single ID

Fix a check in group since its group2 is deleted from being a sub group of group1

The UI now allows to propage sub groups to the inventory which we may want to support within the collection

Only run instance integration test if we are running on k8s and assume we are not by default

Fix hard coded names in manual_project
2023-04-25 13:48:37 -04:00
Alan Rominger
7acc0067f5 Remove Ansible config override to validate group names (#13837) 2023-04-25 13:37:13 -04:00
Alan Rominger
0a13762f11 Use separate module for pytest settings (#13895)
* Use separate module for test settings

* Further refine some pre-existing comments in settings

* Add CACHES to setting snapshot exceptions to accommodate changed load order
2023-04-25 13:31:46 -04:00
Vidya Nambiar
2c673c8f1f Make Topology view and Instances visible only to system admin/auditor 2023-04-25 12:44:27 -04:00
John Westcott IV
8c187c74fc Adding "password": "$encrypted$" to user serializer (#13704)
Co-authored-by: Jessica Steurer <70719005+jay-steurer@users.noreply.github.com>
2023-04-25 10:18:01 -03:00
Jesse Wattenbarger
2ce9440bab Merge pull request #13896 from jjwatt/jjwatt-pyver
Fallback on PYTHON path in Makefile
2023-04-24 10:10:30 -04:00
Jesse Wattenbarger
765487390f Fallback on PYTHON path in Makefile
- Change default PYTHON in Makefile to be ranked choice
- Fix `PYTHON_VERSION` target that expects just a word
- Use native GNU Make `$(subst ,,)` instead of `sed`
- Add 'version-for-buildyml' target to simplify ci

If I understand correctly, this change should make
'$(PYTHON)' work how we want it to everywhere. Before
this change, on develpers' machines that don't have
a 'python3.9' in their path, make would fail. With this
change, we will prefer python3.9 if it's available, but
we'll take python3 otherwise.
2023-04-21 09:50:05 -04:00
Alan Rominger
086722149c Avoid recursive include of DEFAULT_SETTINGS, add sanity test (#13236)
* Avoid recursive include of DEFAULT_SETTINGS, add sanity test to avoid similar surprises

* Implement review comments for more clear code order and readability

* Clarify comment about order of app name, which is last in order so that it can modify user settings
2023-04-20 15:15:34 -04:00
Sarah Akus
c10ada6f44 Merge pull request #13876 from marshmalien/9668-adhoc-credentials-search
Fix credentials search in adhoc prompt modal
2023-04-20 13:41:36 -04:00
Sarah Akus
b350cd053d Merge pull request #13886 from marshmalien/fix-wf-approval-job-details
Fix incorrect workflow approval job details
2023-04-20 13:31:32 -04:00
Alan Rominger
d0acb1c53f Delete cp of local_settings.py file in test running, because path no longer exists (#13894)
* Change reference to moved local_settings.py file

* Do not appy local_settings to test runner
2023-04-20 13:19:00 -04:00
Hao Liu
f61b73010a Merge pull request #13889 from TheRealHaoLiu/egg-liminate
Remove unnecessary egg-link linking
2023-04-19 17:12:28 -04:00
Hao Liu
adb89cd48f Remove unnecessary egg-link linking
we link awx.egg-link from `tools/docker-compose/awx.egg-link` to `/tmp/awx.egg-link` than we move `/tmp/awx.egg-link` to `/var/lib/awx/venv/awx/lib/python3.9/site-packages/awx.egg-link`

bonus... now we dont have to set PYTHON=python3.9
2023-04-19 16:36:51 -04:00
Hao Liu
3e509b3d55 Merge pull request #13883 from ZitaNemeckova/remove_inventories_from_host_metrics
Remove Inventories column for now
2023-04-19 15:41:32 -04:00
Hao Liu
f0badea9d3 Merge pull request #13888 from TheRealHaoLiu/correct-make-call-make
Make target should not call make directly
2023-04-19 15:38:58 -04:00
Hao Liu
6a1ec0dc89 Merge pull request #13887 from TheRealHaoLiu/no-make-run-stuff-in-docker-compose
Stop using make to start awx processes part 1
2023-04-19 15:35:32 -04:00
Hao Liu
329fb88bbb Make target should not call make directly
https://www.gnu.org/software/make/manual/html_node/MAKE-Variable.html

make target should always call make with $(MAKE)
2023-04-19 15:01:16 -04:00
Hao Liu
177f8cb7b2 Stop using make to start processes
part 1...

we dont need to run awx processes through make
because awx-manage uses awx-python which is already activating the correct venv
2023-04-19 14:51:38 -04:00
Marliana Lara
b43107a5e9 Fix credentials search in adhoc prompt modal 2023-04-19 13:59:08 -04:00
Marliana Lara
4857685e1c Fix vault credential update server error 2023-04-19 13:58:39 -04:00
Marliana Lara
8ba1a2bcf7 Reset search params when prompt launch credential type dropdown changes
* Fix credential validation bugs
2023-04-19 13:58:11 -04:00
Marliana Lara
e7c80fe1e8 Fix incorrect workflow approval job details 2023-04-19 13:57:05 -04:00
Hao Liu
33f1c35292 Merge pull request #13658 from TheRealHaoLiu/different-dockerfile
Use different dockerfile for docker-compose-build
2023-04-19 12:12:54 -04:00
Hao Liu
ba899324f2 Merge pull request #13856 from TheRealHaoLiu/kube-dev-autoreload
Auto reload services in kube dev env
2023-04-19 12:08:52 -04:00
Hao Liu
9c236eb8dd Merge pull request #13882 from TheRealHaoLiu/link-launch-n-supervisord
Link launch script and supervisor conf in kube dev
2023-04-19 12:03:22 -04:00
Zita Nemeckova
36559a4539 Remove Inventories column for now. Revert this commit once the backend is ready. 2023-04-19 15:55:02 +02:00
Hao Liu
7a4b3ed139 Merge pull request #13881 from TheRealHaoLiu/fix-copy
Fix copy API
2023-04-19 09:39:39 -04:00
Gabriel Muniz
cd5cc64d6a Fix 500 on missing inventory for provisioning callbacks (#13862)
* Fix 500 on missing inventory for provisioning callbacks

* Added test to cover bug fix

* Reworded msg to clear what is missing to start the callback
2023-04-19 09:27:41 -04:00
Hao Liu
71a11ea3ad Link launch script and supervisor conf in kube dev
Linking launch script and supervisor conf file in kube development environment so we no longer have to rebuild kube devel images for superviosr conf file and launch script changes
2023-04-18 23:22:53 -04:00
Hao Liu
cfbbc4cb92 Auto reload services in kube dev env 2023-04-18 23:15:47 -04:00
Hao Liu
592920ee51 Use different dockerfile for docker-compose-build
- use different dockerfile for awx_devel and awx image
- make all Dockerfile* targets PHONY (bc its cheap to run)
- fix HEADLESS not working for awx-kube-build
2023-04-18 21:45:31 -04:00
Hao Liu
b75b84e282 Merge pull request #13725 from l3acon/collection-existential-state-for-credential-module
[collection] Add "exists" state for credential module
2023-04-18 20:51:14 -04:00
Sarah Akus
f4b80c70e3 Merge pull request #13849 from marshmalien/10854-instances-403-error
Check user permissions before fetching system settings
2023-04-18 16:41:40 -04:00
Hao Liu
9870187af5 Fix copy API
In web/task split deployment web and task container no longer share the same redis cache

In the original code we use redis cache to pass the list of sub objects that need to be copied to the new object

In this PR we extracted out the logic that computes the sub_object_list and move it into deep_copy_model_obj task
2023-04-18 16:03:04 -04:00
Matthew Fernandez
d57f549a4c Merge branch 'devel' into collection-existential-state-for-credential-module 2023-04-18 09:51:54 -06:00
matt
93e6f974f6 remove redundant loop 2023-04-18 09:51:20 -06:00
Michael Abashian
68b32b9b4f Merge branch 'devel' into 10854-instances-403-error 2023-04-17 10:14:44 -04:00
Marliana Lara
105609ec20 Check user permissions before fetching system settings 2023-04-12 11:19:37 -04:00
matt
4a3d437b32 spaces for pep8 2023-04-11 11:35:36 -06:00
Matthew Fernandez
184719e9f2 Merge branch 'devel' into collection-existential-state-for-credential-module 2023-04-10 15:31:11 -06:00
matt
b0c416334f add test coverage 2023-03-23 15:44:00 -06:00
matt
7c4aedf716 exit from module 2023-03-20 13:36:24 -06:00
matt
76f03b9adc add exists to awx.awx.credential 2023-03-20 09:59:24 -06:00
119 changed files with 1834 additions and 817 deletions

View File

@@ -19,6 +19,8 @@ body:
required: true
- label: I understand that AWX is open source software provided for free and that I might not receive a timely response.
required: true
- label: I am **NOT** reporting a (potential) security vulnerability. (These should be emailed to `security@ansible.com` instead.)
required: true
- type: textarea
id: summary

View File

@@ -3,7 +3,7 @@ name: CI
env:
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
CI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEV_DOCKER_TAG_BASE: ghcr.io/${{ github.repository_owner }}
DEV_DOCKER_OWNER: ${{ github.repository_owner }}
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
on:
pull_request:

3
.gitignore vendored
View File

@@ -157,9 +157,10 @@ use_dev_supervisor.txt
*.unison.tmp
*.#
/awx/ui/.ui-built
/Dockerfile
/_build/
/_build_kube_dev/
/Dockerfile
/Dockerfile.dev
/Dockerfile.kube-dev
awx/ui_next/src

View File

@@ -1,6 +1,6 @@
-include awx/ui_next/Makefile
PYTHON ?= python3.9
PYTHON := $(notdir $(shell for i in python3.9 python3; do command -v $$i; done|sed 1q))
DOCKER_COMPOSE ?= docker-compose
OFFICIAL ?= no
NODE ?= node
@@ -42,7 +42,10 @@ TACACS ?= false
VENV_BASE ?= /var/lib/awx/venv
DEV_DOCKER_TAG_BASE ?= ghcr.io/ansible
DEV_DOCKER_OWNER ?= ansible
# Docker will only accept lowercase, so github names like Paul need to be paul
DEV_DOCKER_OWNER_LOWER = $(shell echo $(DEV_DOCKER_OWNER) | tr A-Z a-z)
DEV_DOCKER_TAG_BASE ?= ghcr.io/$(DEV_DOCKER_OWNER_LOWER)
DEVEL_IMAGE_NAME ?= $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
RECEPTOR_IMAGE ?= quay.io/ansible/receptor:devel
@@ -296,13 +299,13 @@ swagger: reports
check: black
api-lint:
BLACK_ARGS="--check" make black
BLACK_ARGS="--check" $(MAKE) black
flake8 awx
yamllint -s .
## Run egg_info_dev to generate awx.egg-info for development.
awx-link:
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/tools/scripts/egg_info_dev
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
PYTEST_ARGS ?= -n auto
@@ -321,7 +324,7 @@ github_ci_setup:
# CI_GITHUB_TOKEN is defined in .github files
echo $(CI_GITHUB_TOKEN) | docker login ghcr.io -u $(GITHUB_ACTOR) --password-stdin
docker pull $(DEVEL_IMAGE_NAME) || : # Pre-pull image to warm build cache
make docker-compose-build
$(MAKE) docker-compose-build
## Runs AWX_DOCKER_CMD inside a new docker container.
docker-runner:
@@ -371,7 +374,7 @@ test_collection_sanity:
rm -rf $(COLLECTION_INSTALL)
if ! [ -x "$(shell command -v ansible-test)" ]; then pip install ansible-core; fi
ansible --version
COLLECTION_VERSION=1.0.0 make install_collection
COLLECTION_VERSION=1.0.0 $(MAKE) install_collection
cd $(COLLECTION_INSTALL) && ansible-test sanity $(COLLECTION_SANITY_ARGS)
test_collection_integration: install_collection
@@ -556,12 +559,21 @@ docker-compose-container-group-clean:
fi
rm -rf tools/docker-compose-minikube/_sources/
## Base development image build
docker-compose-build:
ansible-playbook tools/ansible/dockerfile.yml -e build_dev=True -e receptor_image=$(RECEPTOR_IMAGE)
DOCKER_BUILDKIT=1 docker build -t $(DEVEL_IMAGE_NAME) \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
.PHONY: Dockerfile.dev
## Generate Dockerfile.dev for awx_devel image
Dockerfile.dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
ansible-playbook tools/ansible/dockerfile.yml \
-e dockerfile_name=Dockerfile.dev \
-e build_dev=True \
-e receptor_image=$(RECEPTOR_IMAGE)
## Build awx_devel image for docker compose development environment
docker-compose-build: Dockerfile.dev
DOCKER_BUILDKIT=1 docker build \
-f Dockerfile.dev \
-t $(DEVEL_IMAGE_NAME) \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
docker-clean:
-$(foreach container_id,$(shell docker ps -f name=tools_awx -aq && docker ps -f name=tools_receptor -aq),docker stop $(container_id); docker rm -f $(container_id);)
@@ -580,7 +592,7 @@ docker-compose-cluster-elk: awx/projects docker-compose-sources
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
docker-compose-container-group:
MINIKUBE_CONTAINER_GROUP=true make docker-compose
MINIKUBE_CONTAINER_GROUP=true $(MAKE) docker-compose
clean-elk:
docker stop tools_kibana_1
@@ -597,12 +609,36 @@ VERSION:
@echo "awx: $(VERSION)"
PYTHON_VERSION:
@echo "$(PYTHON)" | sed 's:python::'
@echo "$(subst python,,$(PYTHON))"
.PHONY: version-for-buildyml
version-for-buildyml:
@echo $(firstword $(subst +, ,$(VERSION)))
# version-for-buildyml prints a special version string for build.yml,
# chopping off the sha after the '+' sign.
# tools/ansible/build.yml was doing this: make print-VERSION | cut -d + -f -1
# This does the same thing in native make without
# the pipe or the extra processes, and now the pb does `make version-for-buildyml`
# Example:
# 22.1.1.dev38+g523c0d9781 becomes 22.1.1.dev38
.PHONY: Dockerfile
## Generate Dockerfile for awx image
Dockerfile: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
ansible-playbook tools/ansible/dockerfile.yml -e receptor_image=$(RECEPTOR_IMAGE)
ansible-playbook tools/ansible/dockerfile.yml \
-e receptor_image=$(RECEPTOR_IMAGE) \
-e headless=$(HEADLESS)
## Build awx image for deployment on Kubernetes environment.
awx-kube-build: Dockerfile
DOCKER_BUILDKIT=1 docker build -f Dockerfile \
--build-arg VERSION=$(VERSION) \
--build-arg SETUPTOOLS_SCM_PRETEND_VERSION=$(VERSION) \
--build-arg HEADLESS=$(HEADLESS) \
-t $(DEV_DOCKER_TAG_BASE)/awx:$(COMPOSE_TAG) .
.PHONY: Dockerfile.kube-dev
## Generate Docker.kube-dev for awx_kube_devel image
Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
ansible-playbook tools/ansible/dockerfile.yml \
-e dockerfile_name=Dockerfile.kube-dev \
@@ -617,13 +653,6 @@ awx-kube-dev-build: Dockerfile.kube-dev
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
## Build awx image for deployment on Kubernetes environment.
awx-kube-build: Dockerfile
DOCKER_BUILDKIT=1 docker build -f Dockerfile \
--build-arg VERSION=$(VERSION) \
--build-arg SETUPTOOLS_SCM_PRETEND_VERSION=$(VERSION) \
--build-arg HEADLESS=$(HEADLESS) \
-t $(DEV_DOCKER_TAG_BASE)/awx:$(COMPOSE_TAG) .
# Translation TASKS
# --------------------------------------
@@ -643,6 +672,7 @@ messages:
fi; \
$(PYTHON) manage.py makemessages -l en_us --keep-pot
.PHONY: print-%
print-%:
@echo $($*)
@@ -654,12 +684,12 @@ HELP_FILTER=.PHONY
## Display help targets
help:
@printf "Available targets:\n"
@make -s help/generate | grep -vE "\w($(HELP_FILTER))"
@$(MAKE) -s help/generate | grep -vE "\w($(HELP_FILTER))"
## Display help for all targets
help/all:
@printf "Available targets:\n"
@make -s help/generate
@$(MAKE) -s help/generate
## Generate help output from MAKEFILE_LIST
help/generate:
@@ -683,4 +713,4 @@ help/generate:
## Display help for ui-next targets
help/ui-next:
@make -s help MAKEFILE_LIST="awx/ui_next/Makefile"
@$(MAKE) -s help MAKEFILE_LIST="awx/ui_next/Makefile"

View File

@@ -5,13 +5,11 @@
import inspect
import logging
import time
import uuid
# Django
from django.conf import settings
from django.contrib.auth import views as auth_views
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import FieldDoesNotExist
from django.db import connection, transaction
from django.db.models.fields.related import OneToOneRel
@@ -35,7 +33,7 @@ from rest_framework.negotiation import DefaultContentNegotiation
# AWX
from awx.api.filters import FieldLookupBackend
from awx.main.models import UnifiedJob, UnifiedJobTemplate, User, Role, Credential, WorkflowJobTemplateNode, WorkflowApprovalTemplate
from awx.main.access import access_registry
from awx.main.access import optimize_queryset
from awx.main.utils import camelcase_to_underscore, get_search_fields, getattrd, get_object_or_400, decrypt_field, get_awx_version
from awx.main.utils.db import get_all_field_names
from awx.main.utils.licensing import server_product_name
@@ -364,12 +362,7 @@ class GenericAPIView(generics.GenericAPIView, APIView):
return self.queryset._clone()
elif self.model is not None:
qs = self.model._default_manager
if self.model in access_registry:
access_class = access_registry[self.model]
if access_class.select_related:
qs = qs.select_related(*access_class.select_related)
if access_class.prefetch_related:
qs = qs.prefetch_related(*access_class.prefetch_related)
qs = optimize_queryset(qs)
return qs
else:
return super(GenericAPIView, self).get_queryset()
@@ -512,6 +505,9 @@ class SubListAPIView(ParentMixin, ListAPIView):
# And optionally (user must have given access permission on parent object
# to view sublist):
# parent_access = 'read'
# filter_read_permission sets whether or not to override the default intersection behavior
# implemented here
filter_read_permission = True
def get_description_context(self):
d = super(SubListAPIView, self).get_description_context()
@@ -526,12 +522,16 @@ class SubListAPIView(ParentMixin, ListAPIView):
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model).distinct()
sublist_qs = self.get_sublist_queryset(parent)
return qs & sublist_qs
if not self.filter_read_permission:
return optimize_queryset(self.get_sublist_queryset(parent))
qs = self.request.user.get_queryset(self.model)
if hasattr(self, 'parent_key'):
# This is vastly preferable for ReverseForeignKey relationships
return qs.filter(**{self.parent_key: parent})
return qs.distinct() & self.get_sublist_queryset(parent).distinct()
def get_sublist_queryset(self, parent):
return getattrd(parent, self.relationship).distinct()
return getattrd(parent, self.relationship)
class DestroyAPIView(generics.DestroyAPIView):
@@ -580,15 +580,6 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView):
d.update({'parent_key': getattr(self, 'parent_key', None)})
return d
def get_queryset(self):
if hasattr(self, 'parent_key'):
# Prefer this filtering because ForeignKey allows us more assumptions
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model)
return qs.filter(**{self.parent_key: parent})
return super(SubListCreateAPIView, self).get_queryset()
def create(self, request, *args, **kwargs):
# If the object ID was not specified, it probably doesn't exist in the
# DB yet. We want to see if we can create it. The URL may choose to
@@ -967,16 +958,11 @@ class CopyAPIView(GenericAPIView):
if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role.members.all():
new_obj.admin_role.members.add(request.user)
if sub_objs:
# store the copied object dict into cache, because it's
# often too large for postgres' notification bus
# (which has a default maximum message size of 8k)
key = 'deep-copy-{}'.format(str(uuid.uuid4()))
cache.set(key, sub_objs, timeout=3600)
permission_check_func = None
if hasattr(type(self), 'deep_copy_permission_check_func'):
permission_check_func = (type(self).__module__, type(self).__name__, 'deep_copy_permission_check_func')
trigger_delayed_deep_copy(
self.model.__module__, self.model.__name__, obj.pk, new_obj.pk, request.user.pk, key, permission_check_func=permission_check_func
self.model.__module__, self.model.__name__, obj.pk, new_obj.pk, request.user.pk, permission_check_func=permission_check_func
)
serializer = self._get_copy_return_serializer(new_obj)
headers = {'Location': new_obj.get_absolute_url(request=request)}

View File

@@ -954,7 +954,7 @@ class UnifiedJobStdoutSerializer(UnifiedJobSerializer):
class UserSerializer(BaseSerializer):
password = serializers.CharField(required=False, default='', write_only=True, help_text=_('Write-only field used to change the password.'))
password = serializers.CharField(required=False, default='', help_text=_('Field used to change the password.'))
ldap_dn = serializers.CharField(source='profile.ldap_dn', read_only=True)
external_account = serializers.SerializerMethodField(help_text=_('Set if the account is managed by an external service'))
is_system_auditor = serializers.BooleanField(default=False)
@@ -981,7 +981,12 @@ class UserSerializer(BaseSerializer):
def to_representation(self, obj):
ret = super(UserSerializer, self).to_representation(obj)
ret.pop('password', None)
if self.get_external_account(obj):
# If this is an external account it shouldn't have a password field
ret.pop('password', None)
else:
# If its an internal account lets assume there is a password and return $encrypted$ to the user
ret['password'] = '$encrypted$'
if obj and type(self) is UserSerializer:
ret['auth'] = obj.social_auth.values('provider', 'uid')
return ret
@@ -1019,7 +1024,7 @@ class UserSerializer(BaseSerializer):
# For now we're not raising an error, just not saving password for
# users managed by LDAP who already have an unusable password set.
# Get external password will return something like ldap or enterprise or None if the user isn't external. We only want to allow a password update for a None option
if new_password and not self.get_external_account(obj):
if new_password and new_password != '$encrypted$' and not self.get_external_account(obj):
obj.set_password(new_password)
obj.save(update_fields=['password'])
@@ -2185,7 +2190,7 @@ class BulkHostCreateSerializer(serializers.Serializer):
host_data = []
for r in result:
item = {k: getattr(r, k) for k in return_keys}
if not settings.IS_TESTING_MODE:
if settings.DATABASES and ('sqlite3' not in settings.DATABASES.get('default', {}).get('ENGINE')):
# sqlite acts different with bulk_create -- it doesn't return the id of the objects
# to get it, you have to do an additional query, which is not useful for our tests
item['url'] = reverse('api:host_detail', kwargs={'pk': r.id})

View File

@@ -62,7 +62,7 @@ from wsgiref.util import FileWrapper
# AWX
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
from awx.main.access import get_user_queryset, HostAccess
from awx.main.access import get_user_queryset
from awx.api.generics import (
APIView,
BaseUsersList,
@@ -794,13 +794,7 @@ class ExecutionEnvironmentActivityStreamList(SubListAPIView):
parent_model = models.ExecutionEnvironment
relationship = 'activitystream_set'
search_fields = ('changes',)
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model)
return qs.filter(execution_environment=parent)
filter_read_permission = False
class ProjectList(ListCreateAPIView):
@@ -1634,13 +1628,7 @@ class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIVie
parent_model = models.Inventory
relationship = 'hosts'
parent_key = 'inventory'
def get_queryset(self):
inventory = self.get_parent_object()
qs = getattrd(inventory, self.relationship).all()
# Apply queryset optimizations
qs = qs.select_related(*HostAccess.select_related).prefetch_related(*HostAccess.prefetch_related)
return qs
filter_read_permission = False
class HostGroupsList(SubListCreateAttachDetachAPIView):
@@ -2581,16 +2569,7 @@ class JobTemplateCredentialsList(SubListCreateAttachDetachAPIView):
serializer_class = serializers.CredentialSerializer
parent_model = models.JobTemplate
relationship = 'credentials'
def get_queryset(self):
# Return the full list of credentials
parent = self.get_parent_object()
self.check_parent_access(parent)
sublist_qs = getattrd(parent, self.relationship)
sublist_qs = sublist_qs.prefetch_related(
'created_by', 'modified_by', 'admin_role', 'use_role', 'read_role', 'admin_role__parents', 'admin_role__members'
)
return sublist_qs
filter_read_permission = False
def is_valid_relation(self, parent, sub, created=False):
if sub.unique_hash() in [cred.unique_hash() for cred in parent.credentials.all()]:
@@ -2692,7 +2671,10 @@ class JobTemplateCallback(GenericAPIView):
# Permission class should have already validated host_config_key.
job_template = self.get_object()
# Attempt to find matching hosts based on remote address.
matching_hosts = self.find_matching_hosts()
if job_template.inventory:
matching_hosts = self.find_matching_hosts()
else:
return Response({"msg": _("Cannot start automatically, an inventory is required.")}, status=status.HTTP_400_BAD_REQUEST)
# If the host is not found, update the inventory before trying to
# match again.
inventory_sources_already_updated = []
@@ -2777,6 +2759,7 @@ class JobTemplateInstanceGroupsList(SubListAttachDetachAPIView):
serializer_class = serializers.InstanceGroupSerializer
parent_model = models.JobTemplate
relationship = 'instance_groups'
filter_read_permission = False
class JobTemplateAccessList(ResourceAccessList):
@@ -2867,16 +2850,7 @@ class WorkflowJobTemplateNodeChildrenBaseList(EnforceParentRelationshipMixin, Su
relationship = ''
enforce_parent_relationship = 'workflow_job_template'
search_fields = ('unified_job_template__name', 'unified_job_template__description')
'''
Limit the set of WorkflowJobTemplateNodes to the related nodes of specified by
'relationship'
'''
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
return getattr(parent, self.relationship).all()
filter_read_permission = False
def is_valid_relation(self, parent, sub, created=False):
if created:
@@ -2951,14 +2925,7 @@ class WorkflowJobNodeChildrenBaseList(SubListAPIView):
parent_model = models.WorkflowJobNode
relationship = ''
search_fields = ('unified_job_template__name', 'unified_job_template__description')
#
# Limit the set of WorkflowJobNodes to the related nodes of specified by self.relationship
#
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
return getattr(parent, self.relationship).all()
filter_read_permission = False
class WorkflowJobNodeSuccessNodesList(WorkflowJobNodeChildrenBaseList):
@@ -3137,11 +3104,8 @@ class WorkflowJobTemplateWorkflowNodesList(SubListCreateAPIView):
relationship = 'workflow_job_template_nodes'
parent_key = 'workflow_job_template'
search_fields = ('unified_job_template__name', 'unified_job_template__description')
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
return getattr(parent, self.relationship).order_by('id')
ordering = ('id',) # assure ordering by id for consistency
filter_read_permission = False
class WorkflowJobTemplateJobsList(SubListAPIView):
@@ -3233,11 +3197,8 @@ class WorkflowJobWorkflowNodesList(SubListAPIView):
relationship = 'workflow_job_nodes'
parent_key = 'workflow_job'
search_fields = ('unified_job_template__name', 'unified_job_template__description')
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
return getattr(parent, self.relationship).order_by('id')
ordering = ('id',) # assure ordering by id for consistency
filter_read_permission = False
class WorkflowJobCancel(GenericCancelView):
@@ -3551,11 +3512,7 @@ class BaseJobHostSummariesList(SubListAPIView):
relationship = 'job_host_summaries'
name = _('Job Host Summaries List')
search_fields = ('host_name',)
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
return getattr(parent, self.relationship).select_related('job', 'job__job_template', 'host')
filter_read_permission = False
class HostJobHostSummariesList(BaseJobHostSummariesList):

View File

@@ -61,12 +61,6 @@ class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
model = Organization
serializer_class = OrganizationSerializer
def get_queryset(self):
qs = Organization.accessible_objects(self.request.user, 'read_role')
qs = qs.select_related('admin_role', 'auditor_role', 'member_role', 'read_role')
qs = qs.prefetch_related('created_by', 'modified_by')
return qs
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Organization
@@ -207,6 +201,7 @@ class OrganizationInstanceGroupsList(SubListAttachDetachAPIView):
serializer_class = InstanceGroupSerializer
parent_model = Organization
relationship = 'instance_groups'
filter_read_permission = False
class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView):
@@ -214,6 +209,7 @@ class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView):
serializer_class = CredentialSerializer
parent_model = Organization
relationship = 'galaxy_credentials'
filter_read_permission = False
def is_valid_relation(self, parent, sub, created=False):
if sub.kind != 'galaxy_api_token':

View File

@@ -2952,3 +2952,19 @@ class WorkflowApprovalTemplateAccess(BaseAccess):
for cls in BaseAccess.__subclasses__():
access_registry[cls.model] = cls
access_registry[UnpartitionedJobEvent] = UnpartitionedJobEventAccess
def optimize_queryset(queryset):
"""
A utility method in case you already have a queryset and just want to
apply the standard optimizations for that model.
In other words, use if you do not want to start from filtered_queryset for some reason.
"""
if not queryset.model or queryset.model not in access_registry:
return queryset
access_class = access_registry[queryset.model]
if access_class.select_related:
queryset = queryset.select_related(*access_class.select_related)
if access_class.prefetch_related:
queryset = queryset.prefetch_related(*access_class.prefetch_related)
return queryset

View File

@@ -2,6 +2,8 @@ import json
import logging
import os
import time
import signal
import sys
from django.core.management.base import BaseCommand
from django.conf import settings
@@ -50,6 +52,11 @@ class Command(BaseCommand):
}
return json.dumps(payload)
def notify_listener_and_exit(self, *args):
with pg_bus_conn(new_connection=False) as conn:
conn.notify('web_heartbeet', self.construct_payload(action='offline'))
sys.exit(0)
def do_hearbeat_loop(self):
with pg_bus_conn(new_connection=True) as conn:
while True:
@@ -57,10 +64,10 @@ class Command(BaseCommand):
conn.notify('web_heartbeet', self.construct_payload())
time.sleep(settings.BROADCAST_WEBSOCKET_BEACON_FROM_WEB_RATE_SECONDS)
# TODO: Send a message with action=offline if we notice a SIGTERM or SIGINT
# (wsrelay can use this to remove the node quicker)
def handle(self, *arg, **options):
self.print_banner()
signal.signal(signal.SIGTERM, self.notify_listener_and_exit)
signal.signal(signal.SIGINT, self.notify_listener_and_exit)
# Note: We don't really try any reconnect logic to pg_notify here,
# just let supervisor restart if we fail.

View File

@@ -1479,8 +1479,6 @@ class PluginFileInjector(object):
def build_env(self, inventory_update, env, private_data_dir, private_data_files):
injector_env = self.get_plugin_env(inventory_update, private_data_dir, private_data_files)
env.update(injector_env)
# Preserves current behavior for Ansible change in default planned for 2.10
env['ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS'] = 'never'
# All CLOUD_PROVIDERS sources implement as inventory plugin from collection
env['ANSIBLE_INVENTORY_ENABLED'] = 'auto'
return env

View File

@@ -284,7 +284,7 @@ class JobNotificationMixin(object):
'workflow_url',
'scm_branch',
'artifacts',
{'host_status_counts': ['skipped', 'ok', 'changed', 'failed', 'failures', 'dark' 'processed', 'rescued', 'ignored']},
{'host_status_counts': ['skipped', 'ok', 'changed', 'failed', 'failures', 'dark', 'processed', 'rescued', 'ignored']},
{
'summary_fields': [
{

View File

@@ -639,7 +639,7 @@ class AWXReceptorJob:
#
RECEPTOR_CONFIG_STARTER = (
{'local-only': None},
{'log-level': 'debug'},
{'log-level': 'info'},
{'node': {'firewallrules': [{'action': 'reject', 'tonode': settings.CLUSTER_HOST_ID, 'toservice': 'control'}]}},
{'control-service': {'service': 'control', 'filename': '/var/run/receptor/receptor.sock', 'permissions': '0660'}},
{'work-command': {'worktype': 'local', 'command': 'ansible-runner', 'params': 'worker', 'allowruntimeparams': True}},

View File

@@ -893,15 +893,8 @@ def _reconstruct_relationships(copy_mapping):
@task(queue=get_task_queuename)
def deep_copy_model_obj(model_module, model_name, obj_pk, new_obj_pk, user_pk, uuid, permission_check_func=None):
sub_obj_list = cache.get(uuid)
if sub_obj_list is None:
logger.error('Deep copy {} from {} to {} failed unexpectedly.'.format(model_name, obj_pk, new_obj_pk))
return
def deep_copy_model_obj(model_module, model_name, obj_pk, new_obj_pk, user_pk, permission_check_func=None):
logger.debug('Deep copy {} from {} to {}.'.format(model_name, obj_pk, new_obj_pk))
from awx.api.generics import CopyAPIView
from awx.main.signals import disable_activity_stream
model = getattr(importlib.import_module(model_module), model_name, None)
if model is None:
@@ -913,6 +906,28 @@ def deep_copy_model_obj(model_module, model_name, obj_pk, new_obj_pk, user_pk, u
except ObjectDoesNotExist:
logger.warning("Object or user no longer exists.")
return
o2m_to_preserve = {}
fields_to_preserve = set(getattr(model, 'FIELDS_TO_PRESERVE_AT_COPY', []))
for field in model._meta.get_fields():
if field.name in fields_to_preserve:
if field.one_to_many:
try:
field_val = getattr(obj, field.name)
except AttributeError:
continue
o2m_to_preserve[field.name] = field_val
sub_obj_list = []
for o2m in o2m_to_preserve:
for sub_obj in o2m_to_preserve[o2m].all():
sub_model = type(sub_obj)
sub_obj_list.append((sub_model.__module__, sub_model.__name__, sub_obj.pk))
from awx.api.generics import CopyAPIView
from awx.main.signals import disable_activity_stream
with transaction.atomic(), ignore_inventory_computed_fields(), disable_activity_stream():
copy_mapping = {}
for sub_obj_setup in sub_obj_list:

View File

@@ -1,9 +1,8 @@
{
"ANSIBLE_JINJA2_NATIVE": "True",
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"AZURE_CLIENT_ID": "fooo",
"AZURE_CLOUD_ENVIRONMENT": "fooo",
"AZURE_SECRET": "fooo",
"AZURE_SUBSCRIPTION_ID": "fooo",
"AZURE_TENANT": "fooo"
}
}

View File

@@ -1,5 +1,4 @@
{
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"TOWER_HOST": "https://foo.invalid",
"TOWER_PASSWORD": "fooo",
"TOWER_USERNAME": "fooo",
@@ -10,4 +9,4 @@
"CONTROLLER_USERNAME": "fooo",
"CONTROLLER_OAUTH_TOKEN": "",
"CONTROLLER_VERIFY_SSL": "False"
}
}

View File

@@ -1,8 +1,7 @@
{
"ANSIBLE_JINJA2_NATIVE": "True",
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"AWS_ACCESS_KEY_ID": "fooo",
"AWS_SECRET_ACCESS_KEY": "fooo",
"AWS_SECURITY_TOKEN": "fooo",
"AWS_SESSION_TOKEN": "fooo"
}
}

View File

@@ -1,6 +1,5 @@
{
"ANSIBLE_JINJA2_NATIVE": "True",
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}",
"GOOGLE_APPLICATION_CREDENTIALS": "{{ file_reference }}",
"GCP_AUTH_KIND": "serviceaccount",

View File

@@ -1,5 +1,4 @@
{
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"INSIGHTS_USER": "fooo",
"INSIGHTS_PASSWORD": "fooo"
}
}

View File

@@ -1,4 +1,3 @@
{
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"OS_CLIENT_CONFIG_FILE": "{{ file_reference }}"
}
}

View File

@@ -1,7 +1,6 @@
{
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"OVIRT_INI_PATH": "{{ file_reference }}",
"OVIRT_PASSWORD": "fooo",
"OVIRT_URL": "https://foo.invalid",
"OVIRT_USERNAME": "fooo"
}
}

View File

@@ -1,6 +1,5 @@
{
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"FOREMAN_PASSWORD": "fooo",
"FOREMAN_SERVER": "https://foo.invalid",
"FOREMAN_USER": "fooo"
}
}

View File

@@ -1,7 +1,6 @@
{
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
"VMWARE_HOST": "https://foo.invalid",
"VMWARE_PASSWORD": "fooo",
"VMWARE_USER": "fooo",
"VMWARE_VALIDATE_CERTS": "False"
}
}

View File

@@ -3,7 +3,7 @@ import pytest
# AWX
from awx.api.serializers import JobTemplateSerializer
from awx.api.versioning import reverse
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization, Project
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization, Project, Inventory
from awx.main.migrations import _save_password_keys as save_password_keys
# Django
@@ -353,3 +353,19 @@ def test_job_template_branch_prompt_error(project, inventory, post, admin_user):
expect=400,
)
assert 'Project does not allow overriding branch' in str(r.data['ask_scm_branch_on_launch'])
@pytest.mark.django_db
def test_job_template_missing_inventory(project, inventory, admin_user, post):
jt = JobTemplate.objects.create(
name='test-jt', inventory=inventory, ask_inventory_on_launch=True, project=project, playbook='helloworld.yml', host_config_key='abcd'
)
Inventory.objects.get(pk=inventory.pk).delete()
r = post(
url=reverse('api:job_template_callback', kwargs={'pk': jt.pk}),
data={'host_config_key': 'abcd'},
user=admin_user,
expect=400,
)
assert r.status_code == 400
assert "Cannot start automatically, an inventory is required." in str(r.data)

View File

@@ -329,3 +329,21 @@ def test_galaxy_credential_association(alice, admin, organization, post, get):
'Public Galaxy 4',
'Public Galaxy 5',
]
@pytest.mark.django_db
def test_org_admin_credential_count(org_admin, admin, organization, post, get):
galaxy = CredentialType.defaults['galaxy_api_token']()
galaxy.save()
for i in range(3):
cred = Credential.objects.create(credential_type=galaxy, name=f'test_{i}', inputs={'url': 'https://galaxy.ansible.com/'})
url = reverse('api:organization_galaxy_credentials_list', kwargs={'pk': organization.pk})
post(url, {'associate': True, 'id': cred.pk}, user=admin, expect=204)
# org admin should see all associated galaxy credentials
resp = get(url, user=org_admin)
assert resp.data['count'] == 3
# removing one to validate new count
post(url, {'disassociate': True, 'id': Credential.objects.get(name='test_1').pk}, user=admin, expect=204)
resp_new = get(url, user=org_admin)
assert resp_new.data['count'] == 2

View File

@@ -0,0 +1,28 @@
# Python
from unittest import mock
import uuid
# patch python-ldap
with mock.patch('__main__.__builtins__.dir', return_value=[]):
import ldap # NOQA
# Load development settings for base variables.
from awx.settings.development import * # NOQA
# Some things make decisions based on settings.SETTINGS_MODULE, so this is done for that
SETTINGS_MODULE = 'awx.settings.development'
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
# commented out, Django will create the test_awx-dev database in PostgreSQL to
# run unit tests.
CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'unique-{}'.format(str(uuid.uuid4()))}}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'), # noqa
'TEST': {
# Test database cannot be :memory: for inventory tests.
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3') # noqa
},
}
}

View File

@@ -1,8 +1,32 @@
from split_settings.tools import include
LOCAL_SETTINGS = (
'ALLOWED_HOSTS',
'BROADCAST_WEBSOCKET_PORT',
'BROADCAST_WEBSOCKET_VERIFY_CERT',
'BROADCAST_WEBSOCKET_PROTOCOL',
'BROADCAST_WEBSOCKET_SECRET',
'DATABASES',
'CACHES',
'DEBUG',
'NAMED_URL_GRAPH',
)
def test_postprocess_auth_basic_enabled():
locals().update({'__file__': __file__})
include('../../../settings/defaults.py', scope=locals())
assert 'awx.api.authentication.LoggedBasicAuthentication' in locals()['REST_FRAMEWORK']['DEFAULT_AUTHENTICATION_CLASSES']
def test_default_settings():
from django.conf import settings
for k in dir(settings):
if k not in settings.DEFAULTS_SNAPSHOT or k in LOCAL_SETTINGS:
continue
default_val = getattr(settings.default_settings, k, None)
snapshot_val = settings.DEFAULTS_SNAPSHOT[k]
assert default_val == snapshot_val, f'Setting for {k} does not match shapshot:\nsnapshot: {snapshot_val}\ndefault: {default_val}'

View File

@@ -1,24 +1,16 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Python
import base64
import os
import re # noqa
import sys
import tempfile
import socket
from datetime import timedelta
if "pytest" in sys.modules:
IS_TESTING_MODE = True
from unittest import mock
with mock.patch('__main__.__builtins__.dir', return_value=[]):
import ldap
else:
IS_TESTING_MODE = False
import ldap
# python-ldap
import ldap
DEBUG = True

View File

@@ -9,7 +9,6 @@ import socket
import copy
import sys
import traceback
import uuid
# Centos-7 doesn't include the svg mime type
# /usr/lib64/python/mimetypes.py
@@ -62,38 +61,9 @@ DEBUG_TOOLBAR_CONFIG = {'ENABLE_STACKTRACES': True}
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
INSTALL_UUID = '00000000-0000-0000-0000-000000000000'
# Store a snapshot of default settings at this point before loading any
# customizable config files.
DEFAULTS_SNAPSHOT = {}
this_module = sys.modules[__name__]
for setting in dir(this_module):
if setting == setting.upper():
DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting))
# If there is an `/etc/tower/settings.py`, include it.
# If there is a `/etc/tower/conf.d/*.py`, include them.
include(optional('/etc/tower/settings.py'), scope=locals())
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
BASE_VENV_PATH = "/var/lib/awx/venv/"
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
# commented out, Django will create the test_awx-dev database in PostgreSQL to
# run unit tests.
if "pytest" in sys.modules:
CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'unique-{}'.format(str(uuid.uuid4()))}}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'), # noqa
'TEST': {
# Test database cannot be :memory: for inventory tests.
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3') # noqa
},
}
}
CLUSTER_HOST_ID = socket.gethostname()
AWX_CALLBACK_PROFILE = True
@@ -105,11 +75,28 @@ AWX_CALLBACK_PROFILE = True
AWX_DISABLE_TASK_MANAGERS = False
# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!=================================
from .application_name import set_application_name
# Store a snapshot of default settings at this point before loading any
# customizable config files.
this_module = sys.modules[__name__]
local_vars = dir(this_module)
DEFAULTS_SNAPSHOT = {} # define after we save local_vars so we do not snapshot the snapshot
for setting in local_vars:
if setting.isupper():
DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting))
set_application_name(DATABASES, CLUSTER_HOST_ID)
del local_vars # avoid temporary variables from showing up in dir(settings)
del this_module
#
###############################################################################################
#
# Any settings defined after this point will be marked as as a read_only database setting
#
################################################################################################
del set_application_name
# If there is an `/etc/tower/settings.py`, include it.
# If there is a `/etc/tower/conf.d/*.py`, include them.
include(optional('/etc/tower/settings.py'), scope=locals())
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
# If any local_*.py files are present in awx/settings/, use them to override
# default settings for development. If not present, we can still run using
@@ -123,3 +110,11 @@ try:
except ImportError:
traceback.print_exc()
sys.exit(1)
# The below runs AFTER all of the custom settings are imported
# because conf.d files will define DATABASES and this should modify that
from .application_name import set_application_name
set_application_name(DATABASES, CLUSTER_HOST_ID) # NOQA
del set_application_name

View File

@@ -47,17 +47,21 @@ AWX_ISOLATION_SHOW_PATHS = [
# Store a snapshot of default settings at this point before loading any
# customizable config files.
this_module = sys.modules[__name__]
local_vars = dir(this_module)
DEFAULTS_SNAPSHOT = {} # define after we save local_vars so we do not snapshot the snapshot
for setting in local_vars:
if setting.isupper():
DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting))
del local_vars # avoid temporary variables from showing up in dir(settings)
del this_module
#
###############################################################################################
#
# Any settings defined after this point will be marked as as a read_only database setting
#
################################################################################################
DEFAULTS_SNAPSHOT = {}
this_module = sys.modules[__name__]
for setting in dir(this_module):
if setting == setting.upper():
DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting))
# Load settings from any .py files in the global conf.d directory specified in
# the environment, defaulting to /etc/tower/conf.d/.
@@ -98,8 +102,8 @@ except IOError:
else:
raise
# The below runs AFTER all of the custom settings are imported.
# The below runs AFTER all of the custom settings are imported
# because conf.d files will define DATABASES and this should modify that
from .application_name import set_application_name
set_application_name(DATABASES, CLUSTER_HOST_ID) # NOQA

View File

@@ -115,16 +115,16 @@ function AdHocCredentialStep({ credentialTypeId }) {
searchColumns={[
{
name: t`Name`,
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: t`Created By (Username)`,
key: 'created_by__username',
key: 'created_by__username__icontains',
},
{
name: t`Modified By (Username)`,
key: 'modified_by__username',
key: 'modified_by__username__icontains',
},
]}
sortColumns={[

View File

@@ -43,6 +43,7 @@ function LaunchButton({ resource, children }) {
const [surveyConfig, setSurveyConfig] = useState(null);
const [labels, setLabels] = useState([]);
const [isLaunching, setIsLaunching] = useState(false);
const [resourceCredentials, setResourceCredentials] = useState([]);
const [error, setError] = useState(null);
const handleLaunch = async () => {
@@ -83,6 +84,13 @@ function LaunchButton({ resource, children }) {
setLabels(allLabels);
}
if (launch.ask_credential_on_launch) {
const {
data: { results: templateCredentials },
} = await JobTemplatesAPI.readCredentials(resource.id);
setResourceCredentials(templateCredentials);
}
if (canLaunchWithoutPrompt(launch)) {
await launchWithParams({});
} else {
@@ -208,6 +216,7 @@ function LaunchButton({ resource, children }) {
labels={labels}
onLaunch={launchWithParams}
onCancel={() => setShowLaunchPrompt(false)}
resourceDefaultCredentials={resourceCredentials}
/>
)}
</>

View File

@@ -47,6 +47,12 @@ describe('LaunchButton', () => {
variables_needed_to_start: [],
},
});
JobTemplatesAPI.readCredentials.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
});
afterEach(() => jest.clearAllMocks());

View File

@@ -19,6 +19,7 @@ function PromptModalForm({
labels,
surveyConfig,
instanceGroups,
resourceDefaultCredentials,
}) {
const { setFieldTouched, values } = useFormikContext();
const [showDescription, setShowDescription] = useState(false);
@@ -35,9 +36,9 @@ function PromptModalForm({
surveyConfig,
resource,
labels,
instanceGroups
instanceGroups,
resourceDefaultCredentials
);
const handleSubmit = async () => {
const postValues = {};
const setValue = (key, value) => {

View File

@@ -69,6 +69,20 @@ describe('LaunchPrompt', () => {
spec: [{ type: 'text', variable: 'foo' }],
},
});
JobTemplatesAPI.readCredentials.mockResolvedValue({
data: {
results: [
{
id: 5,
name: 'cred that prompts',
credential_type: 1,
inputs: {
password: 'ASK',
},
},
],
},
});
InstanceGroupsAPI.read.mockResolvedValue({
data: {
results: [
@@ -212,6 +226,16 @@ describe('LaunchPrompt', () => {
],
},
}}
resourceDefaultCredentials={[
{
id: 5,
name: 'cred that prompts',
credential_type: 1,
inputs: {
password: 'ASK',
},
},
]}
onLaunch={noop}
onCancel={noop}
surveyConfig={{
@@ -289,6 +313,16 @@ describe('LaunchPrompt', () => {
resource={resource}
onLaunch={noop}
onCancel={noop}
resourceDefaultCredentials={[
{
id: 5,
name: 'cred that prompts',
credential_type: 1,
inputs: {
password: 'ASK',
},
},
]}
/>
);
});

View File

@@ -1,6 +1,6 @@
import 'styled-components/macro';
import React, { useState, useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
import { t } from '@lingui/macro';
import { useField } from 'formik';
@@ -8,7 +8,7 @@ import styled from 'styled-components';
import { Alert, ToolbarItem } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from 'api';
import { getSearchableKeys } from 'components/PaginatedTable';
import { getQSConfig, parseQueryString } from 'util/qs';
import { getQSConfig, parseQueryString, updateQueryString } from 'util/qs';
import useRequest from 'hooks/useRequest';
import AnsibleSelect from '../../AnsibleSelect';
import OptionsList from '../../OptionsList';
@@ -31,18 +31,18 @@ function CredentialsStep({
allowCredentialsWithPasswords,
defaultCredentials = [],
}) {
const history = useHistory();
const location = useLocation();
const [field, meta, helpers] = useField({
name: 'credentials',
validate: (val) =>
credentialsValidator(
allowCredentialsWithPasswords,
val,
defaultCredentials
defaultCredentials ?? []
),
});
const [selectedType, setSelectedType] = useState(null);
const history = useHistory();
const {
result: types,
error: typesError,
@@ -104,12 +104,32 @@ function CredentialsStep({
credentialsValidator(
allowCredentialsWithPasswords,
field.value,
defaultCredentials
defaultCredentials ?? []
)
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []);
const removeAllSearchTerms = (qsConfig) => {
const oldParams = parseQueryString(qsConfig, location.search);
Object.keys(oldParams).forEach((key) => {
oldParams[key] = null;
});
const defaultParams = {
...oldParams,
page: 1,
page_size: 5,
order_by: 'name',
};
const qs = updateQueryString(qsConfig, location.search, defaultParams);
pushHistoryState(qs);
};
const pushHistoryState = (qs) => {
const { pathname } = history.location;
history.push(qs ? `${pathname}?${qs}` : pathname);
};
if (isTypesLoading) {
return <ContentLoading />;
}
@@ -154,9 +174,7 @@ function CredentialsStep({
value={selectedType && selectedType.id}
onChange={(e, id) => {
// Reset query params when the category of credentials is changed
history.replace({
search: '',
});
removeAllSearchTerms(QS_CONFIG);
setSelectedType(types.find((o) => o.id === parseInt(id, 10)));
}}
/>

View File

@@ -168,7 +168,9 @@ describe('CredentialsStep', () => {
test('should reset query params (credential.page) when selected credential type is changed', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['?credential.page=2'],
initialEntries: [
'?credential.page=2&credential.page_size=5&credential.order_by=name',
],
});
await act(async () => {
wrapper = mountWithContexts(

View File

@@ -46,7 +46,8 @@ export default function useLaunchSteps(
surveyConfig,
resource,
labels,
instanceGroups
instanceGroups,
resourceDefaultCredentials
) {
const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false);
@@ -56,7 +57,7 @@ export default function useLaunchSteps(
useCredentialsStep(
launchConfig,
resource,
resource.summary_fields.credentials || [],
resourceDefaultCredentials,
true
),
useCredentialPasswordsStep(

View File

@@ -122,6 +122,18 @@ function sortWeekday(a, b) {
}
function RunOnDetail({ type, options, prefix }) {
const weekdays = {
sunday: t`Sunday`,
monday: t`Monday`,
tuesday: t`Tuesday`,
wednesday: t`Wednesday`,
thursday: t`Thursday`,
friday: t`Friday`,
saturday: t`Saturday`,
day: t`day`,
weekday: t`weekday`,
weekendDay: t`weekend day`,
};
if (type === 'month') {
if (options.runOn === 'day') {
return (
@@ -132,16 +144,16 @@ function RunOnDetail({ type, options, prefix }) {
/>
);
}
const dayOfWeek = options.runOnTheDay;
const dayOfWeek = weekdays[options.runOnTheDay];
return (
<Detail
label={t`Run on`}
value={
options.runOnDayNumber === -1 ? (
options.runOnTheOccurrence === -1 ? (
t`The last ${dayOfWeek}`
) : (
<SelectOrdinal
value={options.runOnDayNumber}
value={options.runOnTheOccurrence}
one={`The first ${dayOfWeek}`}
two={`The second ${dayOfWeek}`}
_3={`The third ${dayOfWeek}`}
@@ -178,18 +190,6 @@ function RunOnDetail({ type, options, prefix }) {
/>
);
}
const weekdays = {
sunday: t`Sunday`,
monday: t`Monday`,
tuesday: t`Tuesday`,
wednesday: t`Wednesday`,
thursday: t`Thursday`,
friday: t`Friday`,
saturday: t`Saturday`,
day: t`day`,
weekday: t`weekday`,
weekendDay: t`weekend day`,
};
const weekday = weekdays[options.runOnTheDay];
const month = months[options.runOnTheMonth];
return (

View File

@@ -11,7 +11,8 @@ import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api';
import { parseVariableField, jsonToYaml } from 'util/yaml';
import { useConfig } from 'contexts/Config';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import parseRuleObj from '../shared/parseRuleObj';
import parseRuleObj, { UnsupportedRRuleError } from '../shared/parseRuleObj';
import UnsupportedRRuleAlert from '../shared/UnsupportedRRuleAlert';
import FrequencyDetails from './FrequencyDetails';
import AlertModal from '../../AlertModal';
import { CardBody, CardActionsRow } from '../../Card';
@@ -182,8 +183,20 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
month: t`Month`,
year: t`Year`,
};
const { frequency, frequencyOptions, exceptionFrequency, exceptionOptions } =
parseRuleObj(schedule);
let rruleError;
let frequency = [];
let frequencyOptions = {};
let exceptionFrequency = [];
let exceptionOptions = {};
try {
({ frequency, frequencyOptions, exceptionFrequency, exceptionOptions } =
parseRuleObj(schedule));
} catch (parseRuleError) {
if (parseRuleError instanceof UnsupportedRRuleError) {
rruleError = parseRuleError;
}
}
const repeatFrequency = frequency.length
? frequency.map((f) => frequencies[f]).join(', ')
: t`None (Run Once)`;
@@ -602,6 +615,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
</PromptDetailList>
</>
)}
{rruleError && <UnsupportedRRuleAlert schedule={schedule} />}
<CardActionsRow>
{summary_fields?.user_capabilities?.edit && (
<Button

View File

@@ -587,4 +587,31 @@ describe('<ScheduleDetail />', () => {
(el) => el.prop('isDisabled') === true
);
});
test('should display warning for unsupported recurrence rules ', async () => {
const unsupportedSchedule = {
...schedule,
rrule:
'DTSTART:20221220T161500Z RRULE:FREQ=HOURLY;INTERVAL=1 EXRULE:FREQ=HOURLY;INTERVAL=1;BYDAY=TU;BYMONTHDAY=1,2,3,4,5,6,7 EXRULE:FREQ=HOURLY;INTERVAL=1;BYDAY=WE;BYMONTHDAY=2,3,4,5,6,7,8',
};
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules/:scheduleId"
component={() => <ScheduleDetail schedule={unsupportedSchedule} />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 1 } },
},
},
},
}
);
});
expect(wrapper.find('UnsupportedRRuleAlert').length).toBe(1);
});
});

View File

@@ -0,0 +1,32 @@
import React from 'react';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import { Alert } from '@patternfly/react-core';
const AlertWrapper = styled.div`
margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg);
`;
const RulesTitle = styled.p`
margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg);
font-weight: var(--pf-global--FontWeight--bold);
`;
export default function UnsupportedRRuleAlert({ schedule }) {
return (
<AlertWrapper>
<Alert
isInline
variant="danger"
ouiaId="schedule-warning"
title={t`This schedule uses complex rules that are not supported in the
UI. Please use the API to manage this schedule.`}
/>
<RulesTitle>{t`Schedule Rules`}:</RulesTitle>
<pre css="white-space: pre; font-family: var(--pf-global--FontFamily--monospace)">
{schedule.rrule.split(' ').join('\n')}
</pre>
</AlertWrapper>
);
}

View File

@@ -82,11 +82,7 @@ const frequencyTypes = {
};
function parseRrule(rruleString, schedule, values) {
const { frequency, options } = parseRule(
rruleString,
schedule,
values.exceptionFrequency
);
const { frequency, options } = parseRule(rruleString, schedule);
if (values.frequencyOptions[frequency]) {
throw new UnsupportedRRuleError(
@@ -105,11 +101,7 @@ function parseRrule(rruleString, schedule, values) {
}
function parseExRule(exruleString, schedule, values) {
const { frequency, options } = parseRule(
exruleString,
schedule,
values.exceptionFrequency
);
const { frequency, options } = parseRule(exruleString, schedule);
if (values.exceptionOptions[frequency]) {
throw new UnsupportedRRuleError(
@@ -129,7 +121,7 @@ function parseExRule(exruleString, schedule, values) {
};
}
function parseRule(ruleString, schedule, frequencies) {
function parseRule(ruleString, schedule) {
const {
origOptions: {
bymonth,
@@ -178,9 +170,6 @@ function parseRule(ruleString, schedule, frequencies) {
throw new Error(`Unexpected rrule frequency: ${freq}`);
}
const frequency = frequencyTypes[freq];
if (frequencies.includes(frequency)) {
throw new Error(`Duplicate frequency types not supported (${frequency})`);
}
if (freq === RRule.WEEKLY && byweekday) {
options.daysOfWeek = byweekday;

View File

@@ -195,9 +195,9 @@ function getRouteConfig(userProfile = {}) {
deleteRoute('host_metrics');
deleteRouteGroup('settings');
deleteRoute('management_jobs');
if (userProfile?.isOrgAdmin) return routeConfig;
deleteRoute('topology_view');
deleteRoute('instances');
if (userProfile?.isOrgAdmin) return routeConfig;
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
return routeConfig;

View File

@@ -101,10 +101,8 @@ describe('getRouteConfig', () => {
'/credential_types',
'/notification_templates',
'/instance_groups',
'/instances',
'/applications',
'/execution_environments',
'/topology_view',
]);
});
@@ -237,10 +235,8 @@ describe('getRouteConfig', () => {
'/credential_types',
'/notification_templates',
'/instance_groups',
'/instances',
'/applications',
'/execution_environments',
'/topology_view',
]);
});
@@ -268,10 +264,8 @@ describe('getRouteConfig', () => {
'/credential_types',
'/notification_templates',
'/instance_groups',
'/instances',
'/applications',
'/execution_environments',
'/topology_view',
]);
});
});

View File

@@ -91,6 +91,11 @@ function CredentialEdit({ credential }) {
modifiedData.user = me.id;
}
}
if (credential.kind === 'vault' && !credential.inputs?.vault_id) {
delete modifiedData.inputs.vault_id;
}
const [{ data }] = await Promise.all([
CredentialsAPI.update(credId, modifiedData),
...destroyInputSources(),
@@ -100,7 +105,7 @@ function CredentialEdit({ credential }) {
return data;
},
[me, credId]
[me, credId, credential]
)
);

View File

@@ -131,12 +131,6 @@ function HostMetrics() {
>
{t`Automation`}
</HeaderCell>
<HeaderCell
sortKey="used_in_inventories"
tooltip={t`How many inventories is the host in, recomputed on a weekly schedule`}
>
{t`Inventories`}
</HeaderCell>
<HeaderCell
sortKey="deleted_counter"
tooltip={t`How many times was the host deleted`}

View File

@@ -21,7 +21,6 @@ function HostMetricsListItem({ item, isSelected, onSelect, rowIndex }) {
{formatDateString(item.last_automation)}
</Td>
<Td dataLabel={t`Automation`}>{item.automated_counter}</Td>
<Td dataLabel={t`Inventories`}>{item.used_in_inventories || 0}</Td>
<Td dataLabel={t`Deleted`}>{item.deleted_counter}</Td>
</Tr>
);

View File

@@ -4,6 +4,7 @@ import { t } from '@lingui/macro';
import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core';
import { useConfig } from 'contexts/Config';
import ContentError from 'components/ContentError';
import RoutedTabs from 'components/RoutedTabs';
import useRequest from 'hooks/useRequest';
@@ -13,6 +14,9 @@ import InstanceDetail from './InstanceDetail';
import InstancePeerList from './InstancePeers';
function Instance({ setBreadcrumb }) {
const { me } = useConfig();
const canReadSettings = me.is_superuser || me.is_system_auditor;
const match = useRouteMatch();
const tabsArray = [
{
@@ -30,19 +34,21 @@ function Instance({ setBreadcrumb }) {
];
const {
result: { isK8s },
result: isK8s,
error,
isLoading,
request,
} = useRequest(
useCallback(async () => {
if (!canReadSettings) {
return false;
}
const { data } = await SettingsAPI.readCategory('system');
return {
isK8s: data.IS_K8S,
};
}, []),
return data?.IS_K8S ?? false;
}, [canReadSettings]),
{ isK8s: false, isLoading: true }
);
useEffect(() => {
request();
}, [request]);

View File

@@ -36,6 +36,7 @@ const QS_CONFIG = getQSConfig('instance', {
function InstanceList() {
const location = useLocation();
const { me } = useConfig();
const canReadSettings = me.is_superuser || me.is_system_auditor;
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
@@ -48,18 +49,24 @@ function InstanceList() {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, responseActions, sysSettings] = await Promise.all([
const [response, responseActions] = await Promise.all([
InstancesAPI.read(params),
InstancesAPI.readOptions(),
SettingsAPI.readCategory('system'),
]);
let sysSettings = {};
if (canReadSettings) {
sysSettings = await SettingsAPI.readCategory('system');
}
const isPending = response.data.results.some(
(i) => i.health_check_pending === true
);
setPendingHealthCheck(isPending);
return {
instances: response.data.results,
isK8s: sysSettings.data.IS_K8S,
isK8s: sysSettings?.data?.IS_K8S ?? false,
count: response.data.count,
actions: responseActions.data.actions,
relatedSearchableKeys: (
@@ -67,7 +74,7 @@ function InstanceList() {
).map((val) => val.slice(0, -8)),
searchableKeys: getSearchableKeys(responseActions.data.actions?.GET),
};
}, [location.search]),
}, [location.search, canReadSettings]),
{
instances: [],
count: 0,

View File

@@ -17,11 +17,7 @@ import { CardBody, CardActionsRow } from 'components/Card';
import { Detail, DetailList, UserDateDetail } from 'components/DetailList';
import { VariablesDetail } from 'components/CodeEditor';
import { formatDateString, secondsToHHMMSS } from 'util/dates';
import {
WorkflowApprovalsAPI,
WorkflowJobTemplatesAPI,
WorkflowJobsAPI,
} from 'api';
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import { WorkflowApproval } from 'types';
import StatusLabel from 'components/StatusLabel';
@@ -67,8 +63,10 @@ function WorkflowApprovalDetail({ workflowApproval, fetchWorkflowApproval }) {
const { error: deleteError, dismissError: dismissDeleteError } =
useDismissableError(deleteApprovalError);
const workflowJobTemplateId =
workflowApproval.summary_fields.workflow_job_template.id;
const sourceWorkflowJob =
workflowApproval?.summary_fields?.source_workflow_job;
const sourceWorkflowJobTemplate =
workflowApproval?.summary_fields?.workflow_job_template;
const {
error: fetchWorkflowJobError,
@@ -77,23 +75,10 @@ function WorkflowApprovalDetail({ workflowApproval, fetchWorkflowApproval }) {
result: workflowJob,
} = useRequest(
useCallback(async () => {
if (!workflowJobTemplateId) {
return {};
}
const { data: workflowJobTemplate } =
await WorkflowJobTemplatesAPI.readDetail(workflowJobTemplateId);
let jobId = null;
if (workflowJobTemplate.summary_fields?.current_job) {
jobId = workflowJobTemplate.summary_fields.current_job.id;
} else if (workflowJobTemplate.summary_fields?.last_job) {
jobId = workflowJobTemplate.summary_fields.last_job.id;
}
const { data } = await WorkflowJobsAPI.readDetail(jobId);
if (!sourceWorkflowJob?.id) return {};
const { data } = await WorkflowJobsAPI.readDetail(sourceWorkflowJob?.id);
return data;
}, [workflowJobTemplateId]),
}, [sourceWorkflowJob?.id]),
{
workflowJob: null,
isLoading: true,
@@ -116,11 +101,6 @@ function WorkflowApprovalDetail({ workflowApproval, fetchWorkflowApproval }) {
},
[addToast, fetchWorkflowApproval]
);
const sourceWorkflowJob =
workflowApproval?.summary_fields?.source_workflow_job;
const sourceWorkflowJobTemplate =
workflowApproval?.summary_fields?.workflow_job_template;
const isLoading = isDeleteLoading || isLoadingWorkflowJob;

View File

@@ -1,10 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
WorkflowApprovalsAPI,
WorkflowJobTemplatesAPI,
WorkflowJobsAPI,
} from 'api';
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
import { formatDateString } from 'util/dates';
import {
mountWithContexts,
@@ -23,146 +19,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
const workflowJobTemplate = {
id: 8,
type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/8/',
related: {
named_url: '/api/v2/workflow_job_templates/00++/',
created_by: '/api/v2/users/1/',
modified_by: '/api/v2/users/1/',
last_job: '/api/v2/workflow_jobs/111/',
workflow_jobs: '/api/v2/workflow_job_templates/8/workflow_jobs/',
schedules: '/api/v2/workflow_job_templates/8/schedules/',
launch: '/api/v2/workflow_job_templates/8/launch/',
webhook_key: '/api/v2/workflow_job_templates/8/webhook_key/',
webhook_receiver: '/api/v2/workflow_job_templates/8/github/',
workflow_nodes: '/api/v2/workflow_job_templates/8/workflow_nodes/',
labels: '/api/v2/workflow_job_templates/8/labels/',
activity_stream: '/api/v2/workflow_job_templates/8/activity_stream/',
notification_templates_started:
'/api/v2/workflow_job_templates/8/notification_templates_started/',
notification_templates_success:
'/api/v2/workflow_job_templates/8/notification_templates_success/',
notification_templates_error:
'/api/v2/workflow_job_templates/8/notification_templates_error/',
notification_templates_approvals:
'/api/v2/workflow_job_templates/8/notification_templates_approvals/',
access_list: '/api/v2/workflow_job_templates/8/access_list/',
object_roles: '/api/v2/workflow_job_templates/8/object_roles/',
survey_spec: '/api/v2/workflow_job_templates/8/survey_spec/',
copy: '/api/v2/workflow_job_templates/8/copy/',
},
summary_fields: {
last_job: {
id: 111,
name: '00',
description: '',
finished: '2022-05-10T17:29:52.978531Z',
status: 'successful',
failed: false,
},
last_update: {
id: 111,
name: '00',
description: '',
status: 'successful',
failed: false,
},
created_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
modified_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
object_roles: {
admin_role: {
description: 'Can manage all aspects of the workflow job template',
name: 'Admin',
id: 34,
},
execute_role: {
description: 'May run the workflow job template',
name: 'Execute',
id: 35,
},
read_role: {
description: 'May view settings for the workflow job template',
name: 'Read',
id: 36,
},
approval_role: {
description: 'Can approve or deny a workflow approval node',
name: 'Approve',
id: 37,
},
},
user_capabilities: {
edit: true,
delete: true,
start: true,
schedule: true,
copy: true,
},
labels: {
count: 1,
results: [
{
id: 2,
name: 'Test2',
},
],
},
survey: {
title: '',
description: '',
},
recent_jobs: [
{
id: 111,
status: 'successful',
finished: '2022-05-10T17:29:52.978531Z',
canceled_on: null,
type: 'workflow_job',
},
{
id: 104,
status: 'failed',
finished: '2022-05-10T15:26:22.233170Z',
canceled_on: null,
type: 'workflow_job',
},
],
},
created: '2022-05-05T14:13:36.123027Z',
modified: '2022-05-05T17:44:44.071447Z',
name: '00',
description: '',
last_job_run: '2022-05-10T17:29:52.978531Z',
last_job_failed: false,
next_job_run: null,
status: 'successful',
extra_vars: '{\n "foo": "bar",\n "baz": "qux"\n}',
organization: null,
survey_enabled: true,
allow_simultaneous: true,
ask_variables_on_launch: true,
inventory: null,
limit: null,
scm_branch: '',
ask_inventory_on_launch: true,
ask_scm_branch_on_launch: true,
ask_limit_on_launch: true,
webhook_service: 'github',
webhook_credential: null,
};
const workflowJob = {
id: 111,
type: 'workflow_job',
@@ -270,9 +126,6 @@ const workflowJob = {
describe('<WorkflowApprovalDetail />', () => {
beforeEach(() => {
WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({
data: workflowJobTemplate,
});
WorkflowJobsAPI.readDetail.mockResolvedValue({ data: workflowJob });
});
@@ -482,9 +335,6 @@ describe('<WorkflowApprovalDetail />', () => {
});
test('should not load Labels', async () => {
WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({
data: workflowJobTemplate,
});
WorkflowJobsAPI.readDetail.mockResolvedValue({
data: {
...workflowApproval,
@@ -621,4 +471,16 @@ describe('<WorkflowApprovalDetail />', () => {
(el) => el.length === 0
);
});
test('should fetch its workflow job details', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
);
});
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(WorkflowJobsAPI.readDetail).toHaveBeenCalledTimes(1);
expect(WorkflowJobsAPI.readDetail).toHaveBeenCalledWith(216);
});
});

View File

@@ -47,35 +47,14 @@ class ItemNotDefined(Exception):
class ControllerModule(AnsibleModule):
url = None
AUTH_ARGSPEC = dict(
controller_host=dict(
required=False,
aliases=['tower_host'],
fallback=(env_fallback, ['CONTROLLER_HOST', 'TOWER_HOST'])),
controller_username=dict(
required=False,
aliases=['tower_username'],
fallback=(env_fallback, ['CONTROLLER_USERNAME', 'TOWER_USERNAME'])),
controller_password=dict(
no_log=True,
aliases=['tower_password'],
required=False,
fallback=(env_fallback, ['CONTROLLER_PASSWORD', 'TOWER_PASSWORD'])),
validate_certs=dict(
type='bool',
aliases=['tower_verify_ssl'],
required=False,
fallback=(env_fallback, ['CONTROLLER_VERIFY_SSL', 'TOWER_VERIFY_SSL'])),
controller_host=dict(required=False, aliases=['tower_host'], fallback=(env_fallback, ['CONTROLLER_HOST', 'TOWER_HOST'])),
controller_username=dict(required=False, aliases=['tower_username'], fallback=(env_fallback, ['CONTROLLER_USERNAME', 'TOWER_USERNAME'])),
controller_password=dict(no_log=True, aliases=['tower_password'], required=False, fallback=(env_fallback, ['CONTROLLER_PASSWORD', 'TOWER_PASSWORD'])),
validate_certs=dict(type='bool', aliases=['tower_verify_ssl'], required=False, fallback=(env_fallback, ['CONTROLLER_VERIFY_SSL', 'TOWER_VERIFY_SSL'])),
controller_oauthtoken=dict(
type='raw',
no_log=True,
aliases=['tower_oauthtoken'],
required=False,
fallback=(env_fallback, ['CONTROLLER_OAUTH_TOKEN', 'TOWER_OAUTH_TOKEN'])),
controller_config_file=dict(
type='path',
aliases=['tower_config_file'],
required=False,
default=None),
type='raw', no_log=True, aliases=['tower_oauthtoken'], required=False, fallback=(env_fallback, ['CONTROLLER_OAUTH_TOKEN', 'TOWER_OAUTH_TOKEN'])
),
controller_config_file=dict(type='path', aliases=['tower_config_file'], required=False, default=None),
)
short_params = {
'host': 'controller_host',
@@ -320,9 +299,7 @@ class ControllerAPIModule(ControllerModule):
def __init__(self, argument_spec, direct_params=None, error_callback=None, warn_callback=None, **kwargs):
kwargs['supports_check_mode'] = True
super().__init__(
argument_spec=argument_spec, direct_params=direct_params, error_callback=error_callback, warn_callback=warn_callback, **kwargs
)
super().__init__(argument_spec=argument_spec, direct_params=direct_params, error_callback=error_callback, warn_callback=warn_callback, **kwargs)
self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl)
if 'update_secrets' in self.params:
@@ -330,11 +307,6 @@ class ControllerAPIModule(ControllerModule):
else:
self.update_secrets = True
@staticmethod
def param_to_endpoint(name):
exceptions = {'inventory': 'inventories', 'target_team': 'teams', 'workflow': 'workflow_job_templates'}
return exceptions.get(name, '{0}s'.format(name))
@staticmethod
def get_name_field_from_endpoint(endpoint):
return ControllerAPIModule.IDENTITY_FIELDS.get(endpoint, 'name')
@@ -405,7 +377,7 @@ class ControllerAPIModule(ControllerModule):
response['json']['next'] = next_page
return response
def get_one(self, endpoint, name_or_id=None, allow_none=True, **kwargs):
def get_one(self, endpoint, name_or_id=None, allow_none=True, check_exists=False, **kwargs):
new_kwargs = kwargs.copy()
if name_or_id:
name_field = self.get_name_field_from_endpoint(endpoint)
@@ -446,6 +418,11 @@ class ControllerAPIModule(ControllerModule):
# Or we weren't running with a or search and just got back too many to begin with.
self.fail_wanted_one(response, endpoint, new_kwargs.get('data'))
if check_exists:
name_field = self.get_name_field_from_endpoint(endpoint)
self.json_output['id'] = response['json']['results'][0]['id']
self.exit_json(**self.json_output)
return response['json']['results'][0]
def fail_wanted_one(self, response, endpoint, query_params):
@@ -453,7 +430,8 @@ class ControllerAPIModule(ControllerModule):
if len(sample['json']['results']) > 1:
sample['json']['results'] = sample['json']['results'][:2] + ['...more results snipped...']
url = self.build_url(endpoint, query_params)
display_endpoint = url.geturl()[len(self.host):] # truncate to not include the base URL
host_length = len(self.host)
display_endpoint = url.geturl()[host_length:] # truncate to not include the base URL
self.fail_json(
msg="Request to {0} returned {1} items, expected 1".format(display_endpoint, response['json']['count']),
query=query_params,
@@ -975,11 +953,7 @@ class ControllerAPIModule(ControllerModule):
# Attempt to delete our current token from /api/v2/tokens/
# Post to the tokens endpoint with baisc auth to try and get a token
endpoint = self.url_prefix.rstrip('/') + '/api/v2/tokens/{0}/'.format(self.oauth_token_id)
api_token_url = (
self.url._replace(
path=endpoint, query=None # in error cases, fail_json exists before exception handling
)
).geturl()
api_token_url = (self.url._replace(path=endpoint, query=None)).geturl() # in error cases, fail_json exists before exception handling
try:
self.session.open(

View File

@@ -60,7 +60,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
skip_authorization:
description:
@@ -106,7 +106,7 @@ def main():
client_type=dict(choices=['public', 'confidential']),
organization=dict(required=True),
redirect_uris=dict(type="list", elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
skip_authorization=dict(type='bool'),
)
@@ -127,7 +127,7 @@ def main():
org_id = module.resolve_name_to_id('organizations', organization)
# Attempt to look up application based on the provided name and org ID
application = module.get_one('applications', name_or_id=name, **{'data': {'organization': org_id}})
application = module.get_one('applications', name_or_id=name, check_exists=(state == 'exists'), **{'data': {'organization': org_id}})
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -87,7 +87,7 @@ options:
update_secrets:
description:
- C(true) will always update encrypted values.
- C(false) will only updated encrypted values if a change is absolutely known to be needed.
- C(false) will only update encrypted values if a change is absolutely known to be needed.
type: bool
default: true
user:
@@ -100,8 +100,8 @@ options:
type: str
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
- Desired state of the resource. C(exists) will not modify the resource if it is present.
choices: ["present", "absent", "exists"]
default: "present"
type: str
@@ -216,7 +216,7 @@ def main():
update_secrets=dict(type='bool', default=True, no_log=False),
user=dict(),
team=dict(),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -247,7 +247,7 @@ def main():
if organization:
lookup_data['organization'] = org_id
credential = module.get_one('credentials', name_or_id=name, **{'data': lookup_data})
credential = module.get_one('credentials', name_or_id=name, check_exists=(state == 'exists'), **{'data': lookup_data})
# Attempt to look up credential to copy based on the provided name
if copy_from:

View File

@@ -48,7 +48,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
@@ -80,7 +80,7 @@ def main():
target_credential=dict(required=True),
source_credential=dict(),
metadata=dict(type="dict"),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -101,7 +101,7 @@ def main():
'target_credential': target_credential_id,
'input_field_name': input_field_name,
}
credential_input_source = module.get_one('credential_input_sources', **{'data': lookup_data})
credential_input_source = module.get_one('credential_input_sources', check_exists=(state == 'exists'), **{'data': lookup_data})
if state == 'absent':
module.delete_if_needed(credential_input_source)

View File

@@ -59,7 +59,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
extends_documentation_fragment: awx.awx.auth
'''
@@ -98,7 +98,7 @@ def main():
kind=dict(choices=list(KIND_CHOICES.keys())),
inputs=dict(type='dict'),
injectors=dict(type='dict'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -124,7 +124,7 @@ def main():
credential_type_params['injectors'] = module.params.get('injectors')
# Attempt to look up credential_type based on the provided name
credential_type = module.get_one('credential_types', name_or_id=name)
credential_type = module.get_one('credential_types', name_or_id=name, check_exists=(state == 'exists'))
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -50,7 +50,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
pull:
@@ -83,7 +83,7 @@ def main():
description=dict(),
organization=dict(),
credential=dict(),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
# NOTE: Default for pull differs from API (which is blank by default)
pull=dict(choices=['always', 'missing', 'never'], default='missing'),
)
@@ -99,7 +99,7 @@ def main():
state = module.params.get('state')
pull = module.params.get('pull')
existing_item = module.get_one('execution_environments', name_or_id=name)
existing_item = module.get_one('execution_environments', name_or_id=name, check_exists=(state == 'exists'))
if state == 'absent':
module.delete_if_needed(existing_item)

View File

@@ -67,7 +67,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
new_name:
description:
@@ -115,7 +115,7 @@ def main():
children=dict(type='list', elements='str', aliases=['groups']),
preserve_existing_hosts=dict(type='bool', default=False),
preserve_existing_children=dict(type='bool', default=False, aliases=['preserve_existing_groups']),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -135,7 +135,7 @@ def main():
inventory_id = module.resolve_name_to_id('inventories', inventory)
# Attempt to look up the object based on the provided name and inventory ID
group = module.get_one('groups', name_or_id=name, **{'data': {'inventory': inventory_id}})
group = module.get_one('groups', name_or_id=name, check_exists=(state == 'exists'), **{'data': {'inventory': inventory_id}})
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -50,7 +50,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
extends_documentation_fragment: awx.awx.auth
@@ -83,7 +83,7 @@ def main():
inventory=dict(required=True),
enabled=dict(type='bool'),
variables=dict(type='dict'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -102,7 +102,7 @@ def main():
inventory_id = module.resolve_name_to_id('inventories', inventory)
# Attempt to look up host based on the provided name and inventory ID
host = module.get_one('hosts', name_or_id=name, **{'data': {'inventory': inventory_id}})
host = module.get_one('hosts', name_or_id=name, check_exists=(state == 'exists'), **{'data': {'inventory': inventory_id}})
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -81,7 +81,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
extends_documentation_fragment: awx.awx.auth
@@ -107,7 +107,7 @@ def main():
policy_instance_list=dict(type='list', elements='str'),
pod_spec_override=dict(),
instances=dict(required=False, type="list", elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -128,7 +128,7 @@ def main():
state = module.params.get('state')
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('instance_groups', name_or_id=name)
existing_item = module.get_one('instance_groups', name_or_id=name, check_exists=(state == 'exists'))
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -78,7 +78,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
extends_documentation_fragment: awx.awx.auth
'''
@@ -149,7 +149,7 @@ def main():
host_filter=dict(),
instance_groups=dict(type="list", elements='str'),
prevent_instance_group_fallback=dict(type='bool'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
input_inventories=dict(type='list', elements='str'),
)
@@ -172,7 +172,7 @@ def main():
org_id = module.resolve_name_to_id('organizations', organization)
# Attempt to look up inventory based on the provided name and org ID
inventory = module.get_one('inventories', name_or_id=name, **{'data': {'organization': org_id}})
inventory = module.get_one('inventories', name_or_id=name, check_exists=(state == 'exists'), **{'data': {'organization': org_id}})
# Attempt to look up credential to copy based on the provided name
if copy_from:

View File

@@ -118,7 +118,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
notification_templates_started:
description:
@@ -192,7 +192,7 @@ def main():
notification_templates_started=dict(type="list", elements='str'),
notification_templates_success=dict(type="list", elements='str'),
notification_templates_error=dict(type="list", elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -219,6 +219,7 @@ def main():
inventory_source_object = module.get_one(
'inventory_sources',
name_or_id=name,
check_exists=(state == 'exists'),
**{
'data': {
'inventory': inventory_object['id'],

View File

@@ -295,7 +295,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
notification_templates_started:
description:
@@ -444,7 +444,7 @@ def main():
notification_templates_success=dict(type="list", elements='str'),
notification_templates_error=dict(type="list", elements='str'),
prevent_instance_group_fallback=dict(type="bool"),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -484,7 +484,7 @@ def main():
new_fields['execution_environment'] = module.resolve_name_to_id('execution_environments', ee)
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('job_templates', name_or_id=name, **{'data': search_fields})
existing_item = module.get_one('job_templates', name_or_id=name, check_exists=(state == 'exists'), **{'data': search_fields})
# Attempt to look up credential to copy based on the provided name
if copy_from:

View File

@@ -41,7 +41,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present"]
choices: ["present", "exists"]
type: str
extends_documentation_fragment: awx.awx.auth
'''
@@ -62,7 +62,7 @@ def main():
name=dict(required=True),
new_name=dict(),
organization=dict(required=True),
state=dict(choices=['present'], default='present'),
state=dict(choices=['present', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -72,6 +72,7 @@ def main():
name = module.params.get('name')
new_name = module.params.get("new_name")
organization = module.params.get('organization')
state = module.params.get("state")
# Attempt to look up the related items the user specified (these will fail the module if not found)
organization_id = None
@@ -82,6 +83,7 @@ def main():
existing_item = module.get_one(
'labels',
name_or_id=name,
check_exists=(state == 'exists'),
**{
'data': {
'organization': organization_id,

View File

@@ -97,7 +97,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
extends_documentation_fragment: awx.awx.auth
'''
@@ -222,7 +222,7 @@ def main():
notification_type=dict(choices=['email', 'grafana', 'irc', 'mattermost', 'pagerduty', 'rocketchat', 'slack', 'twilio', 'webhook']),
notification_configuration=dict(type='dict'),
messages=dict(type='dict'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -248,6 +248,7 @@ def main():
existing_item = module.get_one(
'notification_templates',
name_or_id=name,
check_exists=(state == 'exists'),
**{
'data': {
'organization': organization_id,

View File

@@ -52,7 +52,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
instance_groups:
description:
@@ -130,7 +130,7 @@ def main():
notification_templates_error=dict(type="list", elements='str'),
notification_templates_approvals=dict(type="list", elements='str'),
galaxy_credentials=dict(type="list", elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -146,7 +146,7 @@ def main():
state = module.params.get('state')
# Attempt to look up organization based on the provided name
organization = module.get_one('organizations', name_or_id=name)
organization = module.get_one('organizations', name_or_id=name, check_exists=(state == 'exists'))
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -122,7 +122,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
wait:
description:
@@ -272,7 +272,7 @@ def main():
notification_templates_started=dict(type="list", elements='str'),
notification_templates_success=dict(type="list", elements='str'),
notification_templates_error=dict(type="list", elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
wait=dict(type='bool', default=True),
update_project=dict(default=False, type='bool'),
interval=dict(default=2.0, type='float'),
@@ -313,7 +313,7 @@ def main():
lookup_data['organization'] = org_id
# Attempt to look up project based on the provided name and org ID
project = module.get_one('projects', name_or_id=name, data=lookup_data)
project = module.get_one('projects', name_or_id=name, check_exists=(state == 'exists'), data=lookup_data)
# Attempt to look up credential to copy based on the provided name
if copy_from:

View File

@@ -24,11 +24,23 @@ options:
user:
description:
- User that receives the permissions specified by the role.
- Deprecated, use 'users'.
type: str
users:
description:
- Users that receive the permissions specified by the role.
type: list
elements: str
team:
description:
- Team that receives the permissions specified by the role.
- Deprecated, use 'teams'.
type: str
teams:
description:
- Teams that receive the permissions specified by the role.
type: list
elements: str
role:
description:
- The role type to grant/revoke.
@@ -161,7 +173,9 @@ def main():
argument_spec = dict(
user=dict(),
users=dict(type='list', elements='str'),
team=dict(),
teams=dict(type='list', elements='str'),
role=dict(
choices=[
"admin",
@@ -219,9 +233,9 @@ def main():
'projects': 'project',
'target_teams': 'target_team',
'workflows': 'workflow',
'users': 'user',
'teams': 'team',
}
# Singular parameters
resource_param_keys = ('user', 'team', 'lookup_organization')
resources = {}
for resource_group, old_name in resource_list_param_keys.items():
@@ -229,9 +243,9 @@ def main():
resources.setdefault(resource_group, []).extend(module.params.get(resource_group))
if module.params.get(old_name) is not None:
resources.setdefault(resource_group, []).append(module.params.get(old_name))
for resource_group in resource_param_keys:
if module.params.get(resource_group) is not None:
resources[resource_group] = module.params.get(resource_group)
if module.params.get('lookup_organization') is not None:
resources['lookup_organization'] = module.params.get('lookup_organization')
# Change workflows and target_teams key to its endpoint name.
if 'workflows' in resources:
resources['workflow_job_templates'] = resources.pop('workflows')
@@ -248,28 +262,13 @@ def main():
# separate actors from resources
actor_data = {}
missing_items = []
for key in ('user', 'team'):
if key in resources:
if key == 'user':
lookup_data_populated = {}
else:
lookup_data_populated = lookup_data
# Attempt to look up project based on the provided name or ID and lookup data
data = module.get_one('{0}s'.format(key), name_or_id=resources[key], data=lookup_data_populated)
if data is None:
module.fail_json(
msg='Unable to find {0} with name: {1}'.format(key, resources[key]), changed=False
)
else:
actor_data[key] = module.get_one('{0}s'.format(key), name_or_id=resources[key], data=lookup_data_populated)
resources.pop(key)
# Lookup Resources
resource_data = {}
for key, value in resources.items():
for resource in value:
# Attempt to look up project based on the provided name or ID and lookup data
if key in resources:
if key == 'organizations':
if key == 'organizations' or key == 'users':
lookup_data_populated = {}
else:
lookup_data_populated = lookup_data
@@ -277,14 +276,18 @@ def main():
if data is None:
missing_items.append(resource)
else:
resource_data.setdefault(key, []).append(data)
if key == 'users' or key == 'teams':
actor_data.setdefault(key, []).append(data)
else:
resource_data.setdefault(key, []).append(data)
if len(missing_items) > 0:
module.fail_json(
msg='There were {0} missing items, missing items: {1}'.format(len(missing_items), missing_items), changed=False
)
# build association agenda
associations = {}
for actor_type, actor in actor_data.items():
for actor_type, actors in actor_data.items():
for key, value in resource_data.items():
for resource in value:
resource_roles = resource['summary_fields']['object_roles']
@@ -294,9 +297,10 @@ def main():
msg='Resource {0} has no role {1}, available roles: {2}'.format(resource['url'], role_field, available_roles), changed=False
)
role_data = resource_roles[role_field]
endpoint = '/roles/{0}/{1}/'.format(role_data['id'], module.param_to_endpoint(actor_type))
endpoint = '/roles/{0}/{1}/'.format(role_data['id'], actor_type)
associations.setdefault(endpoint, [])
associations[endpoint].append(actor['id'])
for actor in actors:
associations[endpoint].append(actor['id'])
# perform associations
for association_endpoint, new_association_list in associations.items():

View File

@@ -146,7 +146,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
extends_documentation_fragment: awx.awx.auth
@@ -220,7 +220,7 @@ def main():
unified_job_template=dict(),
organization=dict(),
enabled=dict(type='bool'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -265,8 +265,13 @@ def main():
search_fields['name'] = unified_job_template
unified_job_template_id = module.get_one('unified_job_templates', **{'data': search_fields})['id']
sched_search_fields['unified_job_template'] = unified_job_template_id
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('schedules', name_or_id=name, **{'data': sched_search_fields})
existing_item = module.get_one('schedules', name_or_id=name, check_exists=(state == 'exists'), **{'data': sched_search_fields})
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(existing_item)
association_fields = {}
@@ -343,18 +348,14 @@ def main():
else:
new_fields['execution_environment'] = ee['id']
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(existing_item)
elif state == 'present':
# If the state was present and we can let the module build or update the existing item, this will return on its own
module.create_or_update_if_needed(
existing_item,
new_fields,
endpoint='schedules',
item_type='schedule',
associations=association_fields,
)
# If the state was present and we can let the module build or update the existing item, this will return on its own
module.create_or_update_if_needed(
existing_item,
new_fields,
endpoint='schedules',
item_type='schedule',
associations=association_fields,
)
if __name__ == '__main__':

View File

@@ -42,7 +42,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
extends_documentation_fragment: awx.awx.auth
@@ -69,7 +69,7 @@ def main():
new_name=dict(),
description=dict(),
organization=dict(required=True),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -86,7 +86,7 @@ def main():
org_id = module.resolve_name_to_id('organizations', organization)
# Attempt to look up team based on the provided name and org ID
team = module.get_one('teams', name_or_id=name, **{'data': {'organization': org_id}})
team = module.get_one('teams', name_or_id=name, check_exists=(state == 'exists'), **{'data': {'organization': org_id}})
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -69,7 +69,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
extends_documentation_fragment: awx.awx.auth
@@ -137,7 +137,7 @@ def main():
password=dict(no_log=True),
update_secrets=dict(type='bool', default=True, no_log=False),
organization=dict(),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -158,7 +158,7 @@ def main():
# Attempt to look up the related items the user specified (these will fail the module if not found)
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('users', name_or_id=username)
existing_item = module.get_one('users', name_or_id=username, check_exists=(state == 'exists'))
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -144,6 +144,7 @@ options:
choices:
- present
- absent
- exists
default: "present"
type: str
notification_templates_started:
@@ -667,8 +668,7 @@ def create_workflow_nodes(module, response, workflow_nodes, workflow_id):
inv_lookup_data = {}
if 'organization' in workflow_node['inventory']:
inv_lookup_data['organization'] = module.resolve_name_to_id('organizations', workflow_node['inventory']['organization']['name'])
workflow_node_fields['inventory'] = module.get_one(
'inventories', name_or_id=workflow_node['inventory']['name'], data=inv_lookup_data)['id']
workflow_node_fields['inventory'] = module.get_one('inventories', name_or_id=workflow_node['inventory']['name'], data=inv_lookup_data)['id']
else:
workflow_node_fields['inventory'] = module.get_one('inventories', name_or_id=workflow_node['inventory'])['id']
@@ -843,7 +843,7 @@ def main():
notification_templates_approvals=dict(type="list", elements='str'),
workflow_nodes=dict(type='list', elements='dict', aliases=['schema']),
destroy_current_nodes=dict(type='bool', default=False, aliases=['destroy_current_schema']),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -871,7 +871,7 @@ def main():
search_fields['organization'] = new_fields['organization'] = organization_id
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('workflow_job_templates', name_or_id=name, **{'data': search_fields})
existing_item = module.get_one('workflow_job_templates', name_or_id=name, check_exists=(state == 'exists'), **{'data': search_fields})
# Attempt to look up credential to copy based on the provided name
if copy_from:

View File

@@ -179,7 +179,7 @@ options:
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
default: "present"
type: str
extends_documentation_fragment: awx.awx.auth
@@ -285,7 +285,7 @@ def main():
job_slice_count=dict(type='int'),
labels=dict(type='list', elements='str'),
timeout=dict(type='int'),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
mutually_exclusive = [("unified_job_template", "approval_node")]
required_if = [
@@ -327,7 +327,7 @@ def main():
search_fields['workflow_job_template'] = new_fields['workflow_job_template'] = workflow_job_template_id
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('workflow_job_template_nodes', **{'data': search_fields})
existing_item = module.get_one('workflow_job_template_nodes', check_exists=(state == 'exists'), **{'data': search_fields})
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this

View File

@@ -10,7 +10,7 @@ from awx.main.models import WorkflowJob
@pytest.mark.django_db
def test_bulk_job_launch(run_module, admin_user, job_template):
jobs = [dict(unified_job_template=job_template.id)]
run_module(
result = run_module(
'bulk_job_launch',
{
'name': "foo-bulk-job",
@@ -21,6 +21,8 @@ def test_bulk_job_launch(run_module, admin_user, job_template):
},
admin_user,
)
assert not result.get('failed', False), result.get('msg', result)
assert result.get('changed'), result
bulk_job = WorkflowJob.objects.get(name="foo-bulk-job")
assert bulk_job.extra_vars == '{"animal": "owl"}'
@@ -30,7 +32,7 @@ def test_bulk_job_launch(run_module, admin_user, job_template):
@pytest.mark.django_db
def test_bulk_host_create(run_module, admin_user, inventory):
hosts = [dict(name="127.0.0.1"), dict(name="foo.dns.org")]
run_module(
result = run_module(
'bulk_host_create',
{
'inventory': inventory.name,
@@ -38,6 +40,8 @@ def test_bulk_host_create(run_module, admin_user, inventory):
},
admin_user,
)
assert not result.get('failed', False), result.get('msg', result)
assert result.get('changed'), result
resp_hosts = inventory.hosts.all().values_list('name', flat=True)
for h in hosts:
assert h['name'] in resp_hosts

View File

@@ -132,3 +132,20 @@ def test_secret_field_write_twice(run_module, organization, admin_user, cred_typ
else:
assert result.get('changed') is False, result
assert Credential.objects.get(id=result['id']).get_input('token') == val1
@pytest.mark.django_db
@pytest.mark.parametrize('state', ('present', 'absent', 'exists'))
def test_credential_state(run_module, organization, admin_user, cred_type, state):
result = run_module(
'credential',
dict(
name='Galaxy Token for Steve',
organization=organization.name,
credential_type=cred_type.name,
inputs={'token': '7rEZK38DJl58A7RxA6EC7lLvUHbBQ1'},
state=state,
),
admin_user,
)
assert not result.get('failed', False), result.get('msg', result)

View File

@@ -46,27 +46,24 @@
that:
- "command is changed"
- name: Timeout waiting for the command to cancel
- name: Cancel the command
ad_hoc_command_cancel:
command_id: "{{ command.id }}"
timeout: -1
register: results
ignore_errors: true
- assert:
that:
- results is failed
- "results['msg'] == 'Monitoring of ad hoc command aborted due to timeout'"
- results is changed
- block:
- name: "Wait for up to a minute until the job enters the can_cancel: False state"
debug:
msg: "The job can_cancel status has transitioned into False, we can proceed with testing"
until: not job_status
retries: 6
delay: 10
vars:
job_status: "{{ lookup('awx.awx.controller_api', 'ad_hoc_commands/'+ command.id | string +'/cancel')['can_cancel'] }}"
- name: "Wait for up to a minute until the job enters the can_cancel: False state"
debug:
msg: "The job can_cancel status has transitioned into False, we can proceed with testing"
until: not job_status
retries: 6
delay: 10
vars:
job_status: "{{ lookup('awx.awx.controller_api', 'ad_hoc_commands/'+ command.id | string +'/cancel')['can_cancel'] }}"
- name: Cancel the command with hard error if it's not running
ad_hoc_command_cancel:

View File

@@ -108,9 +108,8 @@
- assert:
that:
- wait_results is failed
- 'wait_results.status == "canceled"'
- "wait_results.msg == 'The ad hoc command - {{ command.id }}, failed'"
- wait_results is successful
- 'wait_results.status == "successful"'
- name: Delete the Credential
credential:

View File

@@ -2,6 +2,7 @@
- name: Generate a test id
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
@@ -23,6 +24,43 @@
that:
- "result is changed"
- name: Run an application with exists
application:
name: "{{ app1_name }}"
authorization_grant_type: "password"
client_type: "public"
organization: "Default"
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete our application
application:
name: "{{ app1_name }}"
organization: "Default"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Run an application with exists
application:
name: "{{ app1_name }}"
authorization_grant_type: "password"
client_type: "public"
organization: "Default"
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete our application
application:
name: "{{ app1_name }}"

View File

@@ -47,6 +47,42 @@
that:
- "result is changed"
- name: Create an Org-specific credential with an ID with exists
credential:
name: "{{ ssh_cred_name1 }}"
organization: Default
credential_type: Machine
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an Org-specific credential with an ID
credential:
name: "{{ ssh_cred_name1 }}"
organization: Default
credential_type: Machine
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an Org-specific credential with an ID with exists
credential:
name: "{{ ssh_cred_name1 }}"
organization: Default
credential_type: Machine
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete a Org-specific credential
credential:
name: "{{ ssh_cred_name1 }}"
@@ -178,6 +214,60 @@
that:
- result is changed
- name: Delete an SSH credential
credential:
name: "{{ ssh_cred_name2 }}"
organization: Default
state: absent
credential_type: Machine
register: result
- assert:
that:
- "result is changed"
- name: Ensure existence of SSH credential
credential:
name: "{{ ssh_cred_name2 }}"
organization: Default
state: exists
credential_type: Machine
description: An example SSH awx.awx.credential
inputs:
username: joe
password: secret
become_method: sudo
become_username: superuser
become_password: supersecret
ssh_key_data: "{{ ssh_key_data }}"
ssh_key_unlock: "passphrase"
register: result
- assert:
that:
- result is changed
- name: Ensure existence of SSH credential, not updating any inputs
credential:
name: "{{ ssh_cred_name2 }}"
organization: Default
state: exists
credential_type: Machine
description: An example SSH awx.awx.credential
inputs:
username: joe
password: no-update-secret
become_method: sudo
become_username: some-other-superuser
become_password: some-other-secret
ssh_key_data: "{{ ssh_key_data }}"
ssh_key_unlock: "another-pass-phrase"
register: result
- assert:
that:
- result is not changed
- name: Create an invalid SSH credential (passphrase required)
credential:
name: SSH Credential

View File

@@ -54,6 +54,51 @@
that:
- "result is changed"
- name: Add credential Input Source with exists
credential_input_source:
input_field_name: password
target_credential: "{{ target_cred_result.id }}"
source_credential: "{{ src_cred_result.id }}"
metadata:
object_query: "Safe=MY_SAFE;Object=AWX-user"
object_query_format: "Exact"
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete credential Input Source
credential_input_source:
input_field_name: password
target_credential: "{{ target_cred_result.id }}"
source_credential: "{{ src_cred_result.id }}"
metadata:
object_query: "Safe=MY_SAFE;Object=AWX-user"
object_query_format: "Exact"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Add credential Input Source with exists
credential_input_source:
input_field_name: password
target_credential: "{{ target_cred_result.id }}"
source_credential: "{{ src_cred_result.id }}"
metadata:
object_query: "Safe=MY_SAFE;Object=AWX-user"
object_query_format: "Exact"
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Add Second credential Lookup
credential:
description: Credential for Testing Source Change

View File

@@ -1,7 +1,12 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
cred_type_name: "AWX-Collection-tests-credential_type-cred-type-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
cred_type_name: "AWX-Collection-tests-credential_type-cred-type-{{ test_id }}"
- block:
- name: Add Tower credential type
@@ -17,6 +22,48 @@
that:
- "result is changed"
- name: Add Tower credential type with exists
credential_type:
description: Credential type for Test
name: "{{ cred_type_name }}"
kind: cloud
inputs: {"fields": [{"type": "string", "id": "username", "label": "Username"}, {"secret": true, "type": "string", "id": "password", "label": "Password"}], "required": ["username", "password"]}
injectors: {"extra_vars": {"test": "foo"}}
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete the credential type
credential_type:
description: Credential type for Test
name: "{{ cred_type_name }}"
kind: cloud
inputs: {"fields": [{"type": "string", "id": "username", "label": "Username"}, {"secret": true, "type": "string", "id": "password", "label": "Password"}], "required": ["username", "password"]}
injectors: {"extra_vars": {"test": "foo"}}
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Add Tower credential type with exists
credential_type:
description: Credential type for Test
name: "{{ cred_type_name }}"
kind: cloud
inputs: {"fields": [{"type": "string", "id": "username", "label": "Username"}, {"secret": true, "type": "string", "id": "password", "label": "Password"}], "required": ["username", "password"]}
injectors: {"extra_vars": {"test": "foo"}}
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Rename Tower credential type
credential_type:
name: "{{ cred_type_name }}"

View File

@@ -22,6 +22,48 @@
that:
- "result is changed"
- name: Add an EE with exists
execution_environment:
name: "{{ ee_name }}"
description: "EE for Testing"
image: quay.io/ansible/awx-ee
pull: always
organization: Default
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an EE
execution_environment:
name: "{{ ee_name }}"
description: "EE for Testing"
image: quay.io/ansible/awx-ee
pull: always
organization: Default
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Add an EE with exists
execution_environment:
name: "{{ ee_name }}"
description: "EE for Testing"
image: quay.io/ansible/awx-ee
pull: always
organization: Default
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Associate the Test EE with Default Org (this should fail)
execution_environment:
name: "{{ ee_name }}"

View File

@@ -1,22 +1,27 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
group_name1: "AWX-Collection-tests-group-group-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
group_name2: "AWX-Collection-tests-group-group-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
group_name3: "AWX-Collection-tests-group-group-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
inv_name: "AWX-Collection-tests-group-inv-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
host_name1: "AWX-Collection-tests-group-host-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
host_name2: "AWX-Collection-tests-group-host-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
host_name3: "AWX-Collection-tests-group-host-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
group_name1: "AWX-Collection-tests-group-group1-{{ test_id }}"
group_name2: "AWX-Collection-tests-group-group2-{{ test_id }}"
group_name3: "AWX-Collection-tests-group-group3-{{ test_id }}"
inv_name: "AWX-Collection-tests-group-inv-{{ test_id }}"
host_name1: "AWX-Collection-tests-group-host1-{{ test_id }}"
host_name2: "AWX-Collection-tests-group-host2-{{ test_id }}"
host_name3: "AWX-Collection-tests-group-host3-{{ test_id }}"
- name: Create an Inventory
inventory:
name: "{{ inv_name }}"
organization: Default
state: present
register: result
registuer: result
- name: Create a Group
- name: Create Group 1
group:
name: "{{ group_name1 }}"
inventory: "{{ result.id }}"
@@ -29,7 +34,46 @@
that:
- "result is changed"
- name: Create a Group
- name: Create Group 1 with exists
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is not changed"
- name: Delete Group 1
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: absent
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Create Group 1 with exists
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Create Group 2
group:
name: "{{ group_name2 }}"
inventory: "{{ inv_name }}"
@@ -42,7 +86,7 @@
that:
- "result is changed"
- name: Create a Group
- name: Create Group 3
group:
name: "{{ group_name3 }}"
inventory: "{{ inv_name }}"
@@ -64,7 +108,7 @@
- "{{ host_name2 }}"
- "{{ host_name3 }}"
- name: Create a Group with hosts and sub group
- name: Create Group 1 with hosts and sub group of Group 2
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
@@ -78,7 +122,7 @@
foo: bar
register: result
- name: Create a Group with hosts and sub group
- name: Create Group 1 with hosts and sub group
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
@@ -99,7 +143,31 @@
that:
- group1_host_count == "3"
- name: Delete a Group
- name: Delete Group 2
group:
name: "{{ group_name2 }}"
inventory: "{{ inv_name }}"
state: absent
register: result
# In this case, group 2 was last a child of group1 so deleting group1 deleted group2
- assert:
that:
- "result is not changed"
- name: Delete Group 3
group:
name: "{{ group_name3 }}"
inventory: "{{ inv_name }}"
state: absent
register: result
- assert:
that:
- "result is changed"
# If we delete group 1 first it will delete group 2 and 3
- name: Delete Group 1
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
@@ -110,28 +178,6 @@
that:
- "result is changed"
- name: Delete a Group
group:
name: "{{ group_name2 }}"
inventory: "{{ inv_name }}"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Delete a Group
group:
name: "{{ group_name3 }}"
inventory: "{{ inv_name }}"
state: absent
register: result
- assert:
that:
- "result is not changed"
- name: Check module fails with correct msg
group:
name: test-group

View File

@@ -1,8 +1,13 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
host_name: "AWX-Collection-tests-host-host-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
inv_name: "AWX-Collection-tests-host-inv-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
host_name: "AWX-Collection-tests-host-host-{{ test_id }}"
inv_name: "AWX-Collection-tests-host-inv-{{ test_id }}"
- name: Create an Inventory
inventory:
@@ -24,6 +29,45 @@
that:
- "result is changed"
- name: Create a Host with exists
host:
name: "{{ host_name }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is not changed"
- name: Delete a Host
host:
name: "{{ host_name }}"
inventory: "{{ inv_name }}"
state: absent
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Create a Host with exists
host:
name: "{{ host_name }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Delete a Host
host:
name: "{{ result.id }}"

View File

@@ -1,14 +1,25 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate hostnames
set_fact:
hostname1: "AWX-Collection-tests-instance1.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com"
hostname2: "AWX-Collection-tests-instance2.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com"
hostname3: "AWX-Collection-tests-instance3.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com"
hostname1: "AWX-Collection-tests-instance1.{{ test_id }}.example.com"
hostname2: "AWX-Collection-tests-instance2.{{ test_id }}.example.com"
hostname3: "AWX-Collection-tests-instance3.{{ test_id }}.example.com"
register: facts
- name: Show hostnames
debug:
var: facts
- name: Get the k8s setting
set_fact:
IS_K8S: "{{ controller_settings['IS_K8S'] | default(False) }}"
vars:
controller_settings: "{{ lookup('awx.awx.controller_api', 'settings/all') }}"
- debug:
msg: "Skipping instance test since this is instance is not running on a K8s platform"
when: not IS_K8S
- block:
- name: Create an instance
@@ -57,3 +68,5 @@
- "{{ hostname1 }}"
- "{{ hostname2 }}"
- "{{ hostname3 }}"
when: IS_K8S

View File

@@ -2,6 +2,7 @@
- name: Generate test id
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
@@ -37,6 +38,42 @@
that:
- "result is changed"
- name: Create an Instance Group with exists
instance_group:
name: "{{ group_name1 }}"
policy_instance_percentage: 34
policy_instance_minimum: 12
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an Instance Group
instance_group:
name: "{{ group_name1 }}"
policy_instance_percentage: 34
policy_instance_minimum: 12
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an Instance Group with exists
instance_group:
name: "{{ group_name1 }}"
policy_instance_percentage: 34
policy_instance_minimum: 12
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Update an Instance Group
instance_group:
name: "{{ result.id }}"

View File

@@ -2,6 +2,7 @@
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
@@ -50,6 +51,45 @@
that:
- "result is changed"
- name: Create an Inventory with exists
inventory:
name: "{{ inv_name1 }}"
organization: Default
instance_groups:
- "{{ group_name1 }}"
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an Inventory
inventory:
name: "{{ inv_name1 }}"
organization: Default
instance_groups:
- "{{ group_name1 }}"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an Inventory with exists
inventory:
name: "{{ inv_name1 }}"
organization: Default
instance_groups:
- "{{ group_name1 }}"
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Test Inventory module idempotency
inventory:
name: "{{ result.id }}"

View File

@@ -1,9 +1,14 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
openstack_cred: "AWX-Collection-tests-inventory_source-cred-openstack-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
openstack_inv: "AWX-Collection-tests-inventory_source-inv-openstack-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
openstack_inv_source: "AWX-Collection-tests-inventory_source-inv-source-openstack-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
openstack_cred: "AWX-Collection-tests-inventory_source-cred-openstack-{{ test_id }}"
openstack_inv: "AWX-Collection-tests-inventory_source-inv-openstack-{{ test_id }}"
openstack_inv_source: "AWX-Collection-tests-inventory_source-inv-source-openstack-{{ test_id }}"
- name: Add a credential
credential:
@@ -25,7 +30,7 @@
organization: Default
name: "{{ openstack_inv }}"
- name: Create a source inventory
- name: Create an source inventory
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
@@ -42,6 +47,60 @@
that:
- "result is changed"
- name: Create an source inventory with exists
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
inventory: "{{ openstack_inv }}"
credential: "{{ credential_result.id }}"
overwrite: true
update_on_launch: true
source_vars:
private: false
source: openstack
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an source inventory
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
inventory: "{{ openstack_inv }}"
credential: "{{ credential_result.id }}"
overwrite: true
update_on_launch: true
source_vars:
private: false
source: openstack
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an source inventory with exists
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
inventory: "{{ openstack_inv }}"
credential: "{{ credential_result.id }}"
overwrite: true
update_on_launch: true
source_vars:
private: false
source: openstack
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete the inventory source with an invalid cred and source_project specified
inventory_source:
name: "{{ result.id }}"

View File

@@ -2,6 +2,7 @@
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:

View File

@@ -1,9 +1,14 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
jt_name1: "AWX-Collection-tests-job_launch-jt1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
jt_name2: "AWX-Collection-tests-job_launch-jt2-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
proj_name: "AWX-Collection-tests-job_launch-project-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
jt_name1: "AWX-Collection-tests-job_launch-jt1-{{ test_id }}"
jt_name2: "AWX-Collection-tests-job_launch-jt2-{{ test_id }}"
proj_name: "AWX-Collection-tests-job_launch-project-{{ test_id }}"
- name: Launch a Job Template
job_launch:

View File

@@ -2,6 +2,7 @@
- name: Generate a random string for test
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: generate random string for project
set_fact:
@@ -9,6 +10,7 @@
cred1: "AWX-Collection-tests-job_template-cred1-{{ test_id }}"
cred2: "AWX-Collection-tests-job_template-cred2-{{ test_id }}"
cred3: "AWX-Collection-tests-job_template-cred3-{{ test_id }}"
inv1: "AWX-Collection-tests-job_template-inv-{{ test_id }}"
proj1: "AWX-Collection-tests-job_template-proj-{{ test_id }}"
jt1: "AWX-Collection-tests-job_template-jt1-{{ test_id }}"
jt2: "AWX-Collection-tests-job_template-jt2-{{ test_id }}"
@@ -24,6 +26,11 @@
- Ansible Galaxy
register: result
- name: Create an inventory
inventory:
name: "{{ inv1 }}"
organization: "{{ org_name }}"
- name: Create a Demo Project
project:
name: "{{ proj1 }}"
@@ -103,7 +110,7 @@
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
@@ -118,6 +125,63 @@
that:
- "jt1_result is changed"
- name: Create Job Template 1 with exists
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
- "{{ cred2 }}"
instance_groups:
- "{{ group_name1 }}"
job_type: run
state: exists
register: jt1_result
- assert:
that:
- "jt1_result is not changed"
- name: Delete Job Template 1
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
- "{{ cred2 }}"
instance_groups:
- "{{ group_name1 }}"
job_type: run
state: absent
register: jt1_result
- assert:
that:
- "jt1_result is changed"
- name: Create Job Template 1 with exists
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
- "{{ cred2 }}"
instance_groups:
- "{{ group_name1 }}"
job_type: run
state: exists
register: jt1_result
- assert:
that:
- "jt1_result is changed"
- name: Add a credential to this JT
job_template:
name: "{{ jt1 }}"
@@ -217,7 +281,7 @@
name: "{{ jt2 }}"
organization: Default
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credential: "{{ cred3 }}"
job_type: run
@@ -235,7 +299,7 @@
name: "{{ jt2 }}"
organization: Default
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credential: "{{ cred3 }}"
job_type: run
@@ -383,7 +447,7 @@
job_template:
name: "{{ jt2 }}"
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credential: "{{ cred3 }}"
job_type: run
@@ -443,6 +507,12 @@
organization: Default
state: absent
- name: Delete an inventory
inventory:
name: "{{ inv1 }}"
organization: "{{ org_name }}"
state: absent
- name: "Remove the organization"
organization:
name: "{{ org_name }}"

View File

@@ -1,8 +1,13 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate random string for template and project
set_fact:
jt_name: "AWX-Collection-tests-job_wait-long_running-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
proj_name: "AWX-Collection-tests-job_wait-long_running-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
jt_name: "AWX-Collection-tests-job_wait-long_running-{{ test_id }}"
proj_name: "AWX-Collection-tests-job_wait-long_running-{{ test_id }}"
- name: Assure that the demo project exists
project:

View File

@@ -1,13 +1,34 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
label_name: "AWX-Collection-tests-label-label-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
label_name: "AWX-Collection-tests-label-label-{{ test_id }}"
- name: Create a Label
label:
name: "{{ label_name }}"
organization: Default
state: present
register: results
- assert:
that:
- "results is changed"
- name: Create a Label with exists
label:
name: "{{ label_name }}"
organization: Default
state: exists
register: results
- assert:
that:
- "results is not changed"
- name: Check module fails with correct msg
label:

View File

@@ -101,6 +101,9 @@
set_fact:
users: "{{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 2 }, return_ids=True ) }}"
- debug:
msg: "{{ users }}"
- name: Assert that user list has 2 ids only and that they are strings, not ints
assert:
that:

View File

@@ -1,12 +1,17 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
slack_not: "AWX-Collection-tests-notification_template-slack-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
webhook_not: "AWX-Collection-tests-notification_template-wehbook-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
email_not: "AWX-Collection-tests-notification_template-email-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
twillo_not: "AWX-Collection-tests-notification_template-twillo-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
pd_not: "AWX-Collection-tests-notification_template-pd-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
irc_not: "AWX-Collection-tests-notification_template-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
slack_not: "AWX-Collection-tests-notification_template-slack-not-{{ test_id }}"
webhook_not: "AWX-Collection-tests-notification_template-wehbook-not-{{ test_id }}"
email_not: "AWX-Collection-tests-notification_template-email-not-{{ test_id }}"
twillo_not: "AWX-Collection-tests-notification_template-twillo-not-{{ test_id }}"
pd_not: "AWX-Collection-tests-notification_template-pd-not-{{ test_id }}"
irc_not: "AWX-Collection-tests-notification_template-irc-not-{{ test_id }}"
- name: Create Slack notification with custom messages
notification_template:
@@ -31,6 +36,75 @@
that:
- result is changed
- name: Create Slack notification with custom messages with exists
notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack
notification_configuration:
token: a_token
channels:
- general
messages:
started:
message: "{{ '{{' }} job_friendly_name {{' }}' }} {{ '{{' }} job.id {{' }}' }} started"
success:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} completed in {{ '{{' }} job.elapsed {{ '}}' }} seconds"
error:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} FAILED! Please look at {{ '{{' }} job.url {{ '}}' }}"
state: exists
register: result
- assert:
that:
- result is not changed
- name: Delete Slack notification with custom messages
notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack
notification_configuration:
token: a_token
channels:
- general
messages:
started:
message: "{{ '{{' }} job_friendly_name {{' }}' }} {{ '{{' }} job.id {{' }}' }} started"
success:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} completed in {{ '{{' }} job.elapsed {{ '}}' }} seconds"
error:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} FAILED! Please look at {{ '{{' }} job.url {{ '}}' }}"
state: absent
register: result
- assert:
that:
- result is changed
- name: Create Slack notification with custom messages with exists
notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack
notification_configuration:
token: a_token
channels:
- general
messages:
started:
message: "{{ '{{' }} job_friendly_name {{' }}' }} {{ '{{' }} job.id {{' }}' }} started"
success:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} completed in {{ '{{' }} job.elapsed {{ '}}' }} seconds"
error:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} FAILED! Please look at {{ '{{' }} job.url {{ '}}' }}"
state: exists
register: result
- assert:
that:
- result is changed
- name: Delete Slack notification
notification_template:
name: "{{ slack_not }}"

View File

@@ -2,6 +2,7 @@
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate an org name
set_fact:
@@ -24,6 +25,39 @@
- assert:
that: "result is changed"
- name: "Create a new organization with exists"
organization:
name: "{{ org_name }}"
galaxy_credentials:
- Ansible Galaxy
state: exists
register: result
- assert:
that: "result is not changed"
- name: "Delete a new organization"
organization:
name: "{{ org_name }}"
galaxy_credentials:
- Ansible Galaxy
state: absent
register: result
- assert:
that: "result is changed"
- name: "Create a new organization with exists"
organization:
name: "{{ org_name }}"
galaxy_credentials:
- Ansible Galaxy
state: exists
register: result
- assert:
that: "result is changed"
- name: "Make sure making the same org is not a change"
organization:
name: "{{ org_name }}"

View File

@@ -1,13 +1,18 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id is not defined
- name: Generate names
set_fact:
project_name1: "AWX-Collection-tests-project-project1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
project_name2: "AWX-Collection-tests-project-project2-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
project_name3: "AWX-Collection-tests-project-project3-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
jt1: "AWX-Collection-tests-project-jt1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
scm_cred_name: "AWX-Collection-tests-project-scm-cred-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
org_name: "AWX-Collection-tests-project-org-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
cred_name: "AWX-Collection-tests-project-cred-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
project_name1: "AWX-Collection-tests-project-project1-{{ test_id }}"
project_name2: "AWX-Collection-tests-project-project2-{{ test_id }}"
project_name3: "AWX-Collection-tests-project-project3-{{ test_id }}"
jt1: "AWX-Collection-tests-project-jt1-{{ test_id }}"
scm_cred_name: "AWX-Collection-tests-project-scm-cred-{{ test_id }}"
org_name: "AWX-Collection-tests-project-org-{{ test_id }}"
cred_name: "AWX-Collection-tests-project-cred-{{ test_id }}"
- block:
- name: Create an SCM Credential
@@ -34,6 +39,48 @@
that:
- result is changed
- name: Create a git project without credentials and wait with exists
project:
name: "{{ project_name1 }}"
organization: Default
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
state: exists
register: result
- assert:
that:
- result is not changed
- name: Delete a git project without credentials and wait
project:
name: "{{ project_name1 }}"
organization: Default
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
state: absent
register: result
- assert:
that:
- result is changed
- name: Create a git project without credentials and wait with exists
project:
name: "{{ project_name1 }}"
organization: Default
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
state: exists
register: result
- assert:
that:
- result is changed
- name: Recreate the project to validate not changed
project:
name: "{{ project_name1 }}"

View File

@@ -1,56 +0,0 @@
---
- name: Load the UI settings
set_fact:
project_base_dir: "{{ controller_settings.project_base_dir }}"
vars:
controller_settings: "{{ lookup('awx.awx.controller_api', 'config/') }}"
- inventory:
name: localhost
organization: Default
- host:
name: localhost
inventory: localhost
variables:
ansible_connection: local
- name: Create an unused SSH / Machine credential
credential:
name: dummy
credential_type: Machine
inputs:
ssh_key_data: |
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIUl6R1xgzR6siIUArz4XBPtGZ09aetma2eWf1v3uYymoAoGCCqGSM49
AwEHoUQDQgAENJNjgeZDAh/+BY860s0yqrLDprXJflY0GvHIr7lX3ieCtrzOMCVU
QWzw35pc5tvuP34SSi0ZE1E+7cVMDDOF3w==
-----END EC PRIVATE KEY-----
organization: Default
- block:
- name: Add a path to a setting
settings:
name: AWX_ISOLATION_SHOW_PATHS
value: "[{{ project_base_dir }}]"
- name: Create a directory for manual project
ad_hoc_command:
credential: dummy
inventory: localhost
job_type: run
module_args: "mkdir -p {{ project_base_dir }}/{{ project_dir_name }}"
module_name: command
wait: true
always:
- name: Delete path from setting
settings:
name: AWX_ISOLATION_SHOW_PATHS
value: []
- name: Delete dummy credential
credential:
name: dummy
credential_type: Machine
state: absent

View File

@@ -1,38 +0,0 @@
---
- name: Generate random string for project
set_fact:
rand_string: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
- name: Generate manual project name
set_fact:
project_name: "Manual_Project_{{ rand_string }}"
- name: Generate manual project dir name
set_fact:
project_dir_name: "proj_{{ rand_string }}"
- name: Create a project directory for manual project
import_tasks: create_project_dir.yml
- name: Create a manual project
project:
name: "{{ project_name }}"
organization: Default
scm_type: manual
local_path: "{{ project_dir_name }}"
register: result
- assert:
that:
- "result is changed"
- name: Delete a manual project
project:
name: "{{ project_name }}"
organization: Default
state: absent
register: result
- assert:
that:
- "result is changed"

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