mirror of
https://github.com/ansible/awx.git
synced 2026-02-07 04:28:23 -03:30
Compare commits
258 Commits
fix_gather
...
s3_action
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b84fb980 | ||
|
|
98697a8ce7 | ||
|
|
f1edbd8ef5 | ||
|
|
d0a99c37c1 | ||
|
|
1f06d1bb9a | ||
|
|
873f5c0ecc | ||
|
|
b31da105ad | ||
|
|
a285843cf2 | ||
|
|
dd02d56de6 | ||
|
|
b1944ba676 | ||
|
|
24818b510d | ||
|
|
55a7591f89 | ||
|
|
38f858303d | ||
|
|
0fa8135691 | ||
|
|
e63eba247f | ||
|
|
8fb6a3a633 | ||
|
|
7dc4f149a7 | ||
|
|
2c96c48a5c | ||
|
|
58dcd2f5dc | ||
|
|
25896a8772 | ||
|
|
d96727c3bd | ||
|
|
d8737435fa | ||
|
|
bb46268eec | ||
|
|
af2efec2b4 | ||
|
|
a7eb1ef763 | ||
|
|
e9928ff513 | ||
|
|
df1c453c37 | ||
|
|
5a89d7bc29 | ||
|
|
505ec560c8 | ||
|
|
4f2d28db51 | ||
|
|
0b17007764 | ||
|
|
dfad93cf4c | ||
|
|
6bd7c3831f | ||
|
|
7b56f23c0e | ||
|
|
3e1b9b2c88 | ||
|
|
cf0bc16cf7 | ||
|
|
a0b6083d4e | ||
|
|
d452098123 | ||
|
|
c5fb0c351d | ||
|
|
a3f2401740 | ||
|
|
ad461a3aab | ||
|
|
44c53b02ae | ||
|
|
58e237a09a | ||
|
|
052166df39 | ||
|
|
b4ba7595c6 | ||
|
|
c5211df9ca | ||
|
|
5e0870a7ec | ||
|
|
0936b28f9b | ||
|
|
8e58fee49c | ||
|
|
e746589019 | ||
|
|
c4a6b28b87 | ||
|
|
abc4692231 | ||
|
|
ab9bde3698 | ||
|
|
c5e55fe0f5 | ||
|
|
6b2e9a66d5 | ||
|
|
512857c2a9 | ||
|
|
c2c0f2b828 | ||
|
|
534549139c | ||
|
|
d98118a108 | ||
|
|
e4758e8b4b | ||
|
|
46710c4d86 | ||
|
|
b70e884484 | ||
|
|
05b6f4fcb9 | ||
|
|
243e27c7a9 | ||
|
|
7fe525a533 | ||
|
|
c36ce902db | ||
|
|
44e9dee9c7 | ||
|
|
51eb109dbe | ||
|
|
5ca76f3d64 | ||
|
|
e3a9d9fbe8 | ||
|
|
8b13c75f2e | ||
|
|
36ec5efc88 | ||
|
|
4e332ac2c7 | ||
|
|
b730bfa193 | ||
|
|
8fe4223eac | ||
|
|
461678df08 | ||
|
|
e8c4b302ad | ||
|
|
e82de50edb | ||
|
|
11f31ef796 | ||
|
|
09b539bc34 | ||
|
|
9033e829fe | ||
|
|
4757785016 | ||
|
|
902f2634a6 | ||
|
|
793c85ef24 | ||
|
|
290dec8bf8 | ||
|
|
80f9f87181 | ||
|
|
cd12f4dcac | ||
|
|
3ccc5e5f2c | ||
|
|
550ae51aec | ||
|
|
e8b2920aec | ||
|
|
7977e8639c | ||
|
|
03cd450669 | ||
|
|
1d4b555a2c | ||
|
|
69df7d0e27 | ||
|
|
bf0567ca41 | ||
|
|
ec0732ce94 | ||
|
|
d6482d3898 | ||
|
|
20b203ea8e | ||
|
|
1afd23043d | ||
|
|
1330a1b353 | ||
|
|
11a9a2b066 | ||
|
|
5752c7a8e2 | ||
|
|
3d027bafd0 | ||
|
|
ee19ee0c10 | ||
|
|
f1e5cadce7 | ||
|
|
a238c5dd09 | ||
|
|
d26c7fedb8 | ||
|
|
f4347d05a9 | ||
|
|
4eefce622d | ||
|
|
57b8773613 | ||
|
|
d0776dabdf | ||
|
|
2d730abb82 | ||
|
|
8896f75f9b | ||
|
|
bb6bf33b9e | ||
|
|
5cf3a09163 | ||
|
|
54db6c792b | ||
|
|
3e122778e4 | ||
|
|
f98b2e2455 | ||
|
|
12dcc10416 | ||
|
|
6bd39aea4b | ||
|
|
b7a3c6b025 | ||
|
|
ba7ee23298 | ||
|
|
825a48bb32 | ||
|
|
eb6aebff00 | ||
|
|
60114ab929 | ||
|
|
0e28d2590a | ||
|
|
2bc08b421d | ||
|
|
cae8a4e16c | ||
|
|
41d3729501 | ||
|
|
a3303bb74b | ||
|
|
6a10e0ea5c | ||
|
|
6690d71357 | ||
|
|
ae0a8a80eb | ||
|
|
4532c627e3 | ||
|
|
87cb6dc0b9 | ||
|
|
bbcdef18a7 | ||
|
|
d35d7f62ec | ||
|
|
9d9c125e47 | ||
|
|
5dd81a04ce | ||
|
|
e060e44b05 | ||
|
|
db5b6d0019 | ||
|
|
a2c8ecb4e6 | ||
|
|
277bc581e7 | ||
|
|
ef89c59a13 | ||
|
|
5872a88a57 | ||
|
|
7fdd15f115 | ||
|
|
353f0adf36 | ||
|
|
bdfd9dec74 | ||
|
|
bad4e630ba | ||
|
|
e9f2a14ebd | ||
|
|
01fae57de2 | ||
|
|
c7ac45717b | ||
|
|
c7b6b43913 | ||
|
|
1e6a7c0749 | ||
|
|
b5bc85e639 | ||
|
|
f04bf5ccf0 | ||
|
|
28712a4c6e | ||
|
|
698d769a7a | ||
|
|
529ee73fcd | ||
|
|
ba053dfb51 | ||
|
|
b351dfb102 | ||
|
|
b502a9444a | ||
|
|
2d648d1225 | ||
|
|
b8a1e90b06 | ||
|
|
c0b9d3f428 | ||
|
|
376a791052 | ||
|
|
cb2df43580 | ||
|
|
ccb6360a96 | ||
|
|
397fb297bf | ||
|
|
63bb4d66ef | ||
|
|
7017c28706 | ||
|
|
48ee5b05ee | ||
|
|
386f85c59f | ||
|
|
b7b15584af | ||
|
|
148f28f448 | ||
|
|
26b6eac849 | ||
|
|
18ea5cc561 | ||
|
|
99b67f1e37 | ||
|
|
cdd9e7263d | ||
|
|
edba126193 | ||
|
|
22ecb2030c | ||
|
|
2e8114394b | ||
|
|
3268c9b5fe | ||
|
|
f7cda7696c | ||
|
|
a209751f22 | ||
|
|
5944d041e6 | ||
|
|
9c732d2406 | ||
|
|
b215699586 | ||
|
|
38f72ac7ea | ||
|
|
b361aef0fb | ||
|
|
df79fa4ae1 | ||
|
|
9c2de6b535 | ||
|
|
0ce0023561 | ||
|
|
f2ae68f302 | ||
|
|
b3542c226d | ||
|
|
56d3933154 | ||
|
|
a1ec28aeb9 | ||
|
|
148afce455 | ||
|
|
f3b86b5193 | ||
|
|
82c967a66e | ||
|
|
8174a28716 | ||
|
|
9c556db4c0 | ||
|
|
1d12f0c837 | ||
|
|
71a18c0d61 | ||
|
|
c55fb369fa | ||
|
|
2c3b4ff5d7 | ||
|
|
943964e14f | ||
|
|
b97240417a | ||
|
|
6d959daca1 | ||
|
|
8fbe0c2b1f | ||
|
|
23528b7fef | ||
|
|
784ff3193d | ||
|
|
7972486594 | ||
|
|
dbdbc7635a | ||
|
|
4820b084c1 | ||
|
|
d5388b3c56 | ||
|
|
433974aea6 | ||
|
|
d1c85dae4d | ||
|
|
534b0209f4 | ||
|
|
46becf15e9 | ||
|
|
02795c9ed9 | ||
|
|
6574cfe3a9 | ||
|
|
fafed924e3 | ||
|
|
d2f3c02945 | ||
|
|
eb4f3c2864 | ||
|
|
bcd18e161c | ||
|
|
b62d0ff8e6 | ||
|
|
c33947af7f | ||
|
|
30b005aa9d | ||
|
|
a1e3919b1f | ||
|
|
0a8e92cab7 | ||
|
|
30e2c3a8cd | ||
|
|
aef3d8750b | ||
|
|
f799376b3d | ||
|
|
96ec709e90 | ||
|
|
70f7ac72d4 | ||
|
|
4c9c22fea2 | ||
|
|
9914229a5a | ||
|
|
ce0d176508 | ||
|
|
059f52f314 | ||
|
|
446046c4bf | ||
|
|
17e01e0eb0 | ||
|
|
5a4b789488 | ||
|
|
6dfe2e3a9f | ||
|
|
01ea091e8a | ||
|
|
effbd0e416 | ||
|
|
2334211ba0 | ||
|
|
15e28371eb | ||
|
|
64d2e10dc2 | ||
|
|
85bd7c3ca0 | ||
|
|
77e999f7c8 | ||
|
|
01aa760510 | ||
|
|
16a4c66c73 | ||
|
|
9fa5be015c | ||
|
|
8b293e7046 | ||
|
|
467024bc54 | ||
|
|
bdf3f81016 | ||
|
|
4a5cfdc11d |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -172,9 +172,10 @@ jobs:
|
||||
repository: ansible/awx-operator
|
||||
path: awx-operator
|
||||
|
||||
- uses: ./awx/.github/actions/setup-python
|
||||
- name: Setup python, referencing action at awx relative path
|
||||
uses: ./awx/.github/actions/setup-python
|
||||
with:
|
||||
working-directory: awx
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install playbook dependencies
|
||||
run: |
|
||||
|
||||
1
.github/workflows/devel_images.yml
vendored
1
.github/workflows/devel_images.yml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
- devel
|
||||
- release_*
|
||||
- feature_*
|
||||
- stable-*
|
||||
jobs:
|
||||
push-development-images:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/stage.yml
vendored
4
.github/workflows/stage.yml
vendored
@@ -85,9 +85,11 @@ jobs:
|
||||
cp ../awx-logos/awx/ui/client/assets/* awx/ui/public/static/media/
|
||||
|
||||
- name: Setup node and npm for new UI build
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: awx/awx/ui/**/package-lock.json
|
||||
|
||||
- name: Prebuild new UI for awx image (to speed up build process)
|
||||
working-directory: awx
|
||||
|
||||
41
.github/workflows/upload_schema.yml
vendored
41
.github/workflows/upload_schema.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
- devel
|
||||
- release_**
|
||||
- feature_**
|
||||
- stable-**
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,35 +24,25 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
|
||||
- uses: ./.github/actions/setup-python
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- uses: ./.github/actions/setup-ssh-agent
|
||||
- name: Build awx_devel image to use for schema gen
|
||||
uses: ./.github/actions/awx_devel_image
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.PRIVATE_GITHUB_KEY }}
|
||||
|
||||
- name: Pre-pull image to warm build cache
|
||||
run: |
|
||||
docker pull -q ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/} || :
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
DEV_DOCKER_TAG_BASE=ghcr.io/${{ github.repository_owner }} COMPOSE_TAG=${GITHUB_REF##*/} make docker-compose-build
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
private-github-key: ${{ secrets.PRIVATE_GITHUB_KEY }}
|
||||
|
||||
- name: Generate API Schema
|
||||
run: |
|
||||
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} \
|
||||
COMPOSE_TAG=${{ github.base_ref || github.ref_name }} \
|
||||
docker run -u $(id -u) --rm -v ${{ github.workspace }}:/awx_devel/:Z \
|
||||
--workdir=/awx_devel ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/} /start_tests.sh genschema
|
||||
--workdir=/awx_devel `make print-DEVEL_IMAGE_NAME` /start_tests.sh genschema
|
||||
|
||||
- name: Upload API Schema
|
||||
env:
|
||||
AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
|
||||
AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }}
|
||||
AWS_REGION: 'us-east-1'
|
||||
run: |
|
||||
ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
|
||||
ansible localhost -c local -m aws_s3 \
|
||||
-a "src=${{ github.workspace }}/schema.json bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=put permission=public-read"
|
||||
uses: keithweaver/aws-s3-github-action@v1.0.0
|
||||
with:
|
||||
command: cp
|
||||
source: ${{ github.workspace }}/schema.json
|
||||
destination: s3://awx-public-ci-files/${{ github.ref_name }}/schema.json
|
||||
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
|
||||
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
|
||||
aws_region: us-east-1
|
||||
|
||||
2
Makefile
2
Makefile
@@ -77,7 +77,7 @@ RECEPTOR_IMAGE ?= quay.io/ansible/receptor:devel
|
||||
SRC_ONLY_PKGS ?= cffi,pycparser,psycopg,twilio
|
||||
# These should be upgraded in the AWX and Ansible venv before attempting
|
||||
# to install the actual requirements
|
||||
VENV_BOOTSTRAP ?= pip==21.2.4 setuptools==70.3.0 setuptools_scm[toml]==8.1.0 wheel==0.45.1 cython==3.0.11
|
||||
VENV_BOOTSTRAP ?= pip==21.2.4 setuptools==80.9.0 setuptools_scm[toml]==8.0.4 wheel==0.42.0 cython==3.1.3
|
||||
|
||||
NAME ?= awx
|
||||
|
||||
|
||||
@@ -844,7 +844,7 @@ class ResourceAccessList(ParentMixin, ListAPIView):
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
ancestors = set(RoleEvaluation.objects.filter(content_type_id=content_type.id, object_id=obj.id).values_list('role_id', flat=True))
|
||||
qs = User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True)
|
||||
auditor_role = RoleDefinition.objects.filter(name="Controller System Auditor").first()
|
||||
auditor_role = RoleDefinition.objects.filter(name="Platform Auditor").first()
|
||||
if auditor_role:
|
||||
qs |= User.objects.filter(role_assignments__role_definition=auditor_role)
|
||||
return qs.distinct()
|
||||
|
||||
@@ -10,7 +10,7 @@ from rest_framework import permissions
|
||||
|
||||
# AWX
|
||||
from awx.main.access import check_user_access
|
||||
from awx.main.models import Inventory, UnifiedJob
|
||||
from awx.main.models import Inventory, UnifiedJob, Organization
|
||||
from awx.main.utils import get_object_or_400
|
||||
|
||||
logger = logging.getLogger('awx.api.permissions')
|
||||
@@ -228,12 +228,19 @@ class InventoryInventorySourcesUpdatePermission(ModelAccessPermission):
|
||||
class UserPermission(ModelAccessPermission):
|
||||
def check_post_permissions(self, request, view, obj=None):
|
||||
if not request.data:
|
||||
return request.user.admin_of_organizations.exists()
|
||||
return Organization.access_qs(request.user, 'change').exists()
|
||||
elif request.user.is_superuser:
|
||||
return True
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class IsSystemAdmin(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if not (request.user and request.user.is_authenticated):
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
class IsSystemAdminOrAuditor(permissions.BasePermission):
|
||||
"""
|
||||
Allows write access only to system admin users.
|
||||
|
||||
@@ -2839,7 +2839,7 @@ class ResourceAccessListElementSerializer(UserSerializer):
|
||||
{
|
||||
"role": {
|
||||
"id": None,
|
||||
"name": _("Controller System Auditor"),
|
||||
"name": _("Platform Auditor"),
|
||||
"description": _("Can view all aspects of the system"),
|
||||
"user_capabilities": {"unattach": False},
|
||||
},
|
||||
@@ -3027,11 +3027,6 @@ class CredentialSerializer(BaseSerializer):
|
||||
ret.remove(field)
|
||||
return ret
|
||||
|
||||
def validate_organization(self, org):
|
||||
if self.instance and (not self.instance.managed) and self.instance.credential_type.kind == 'galaxy' and org is None:
|
||||
raise serializers.ValidationError(_("Galaxy credentials must be owned by an Organization."))
|
||||
return org
|
||||
|
||||
def validate_credential_type(self, credential_type):
|
||||
if self.instance and credential_type.pk != self.instance.credential_type.pk:
|
||||
for related_objects in (
|
||||
@@ -3107,9 +3102,6 @@ class CredentialSerializerCreate(CredentialSerializer):
|
||||
if attrs.get('team'):
|
||||
attrs['organization'] = attrs['team'].organization
|
||||
|
||||
if 'credential_type' in attrs and attrs['credential_type'].kind == 'galaxy' and list(owner_fields) != ['organization']:
|
||||
raise serializers.ValidationError({"organization": _("Galaxy credentials must be owned by an Organization.")})
|
||||
|
||||
return super(CredentialSerializerCreate, self).validate(attrs)
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -6006,7 +5998,7 @@ class InstanceGroupSerializer(BaseSerializer):
|
||||
if self.instance and not self.instance.is_container_group:
|
||||
raise serializers.ValidationError(_('pod_spec_override is only valid for container groups'))
|
||||
|
||||
pod_spec_override_json = None
|
||||
pod_spec_override_json = {}
|
||||
# defect if the value is yaml or json if yaml convert to json
|
||||
try:
|
||||
# convert yaml to json
|
||||
|
||||
@@ -55,8 +55,7 @@ from wsgiref.util import FileWrapper
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.lib.utils.requests import get_remote_hosts
|
||||
from ansible_base.rbac.models import RoleEvaluation, ObjectRole
|
||||
from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
|
||||
# AWX
|
||||
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
|
||||
@@ -85,7 +84,6 @@ from awx.api.generics import (
|
||||
from awx.api.views.labels import LabelSubListCreateAttachDetachView
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main import models
|
||||
from awx.main.models.rbac import get_role_definition
|
||||
from awx.main.utils import (
|
||||
camelcase_to_underscore,
|
||||
extract_ansible_vars,
|
||||
@@ -671,81 +669,16 @@ class ScheduleUnifiedJobsList(SubListAPIView):
|
||||
name = _('Schedule Jobs List')
|
||||
|
||||
|
||||
def immutablesharedfields(cls):
|
||||
'''
|
||||
Class decorator to prevent modifying shared resources when ALLOW_LOCAL_RESOURCE_MANAGEMENT setting is set to False.
|
||||
|
||||
Works by overriding these view methods:
|
||||
- create
|
||||
- delete
|
||||
- perform_update
|
||||
create and delete are overridden to raise a PermissionDenied exception.
|
||||
perform_update is overridden to check if any shared fields are being modified,
|
||||
and raise a PermissionDenied exception if so.
|
||||
'''
|
||||
# create instead of perform_create because some of our views
|
||||
# override create instead of perform_create
|
||||
if hasattr(cls, 'create'):
|
||||
cls.original_create = cls.create
|
||||
|
||||
@functools.wraps(cls.create)
|
||||
def create_wrapper(*args, **kwargs):
|
||||
if settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
|
||||
return cls.original_create(*args, **kwargs)
|
||||
raise PermissionDenied({'detail': _('Creation of this resource is not allowed. Create this resource via the platform ingress.')})
|
||||
|
||||
cls.create = create_wrapper
|
||||
|
||||
if hasattr(cls, 'delete'):
|
||||
cls.original_delete = cls.delete
|
||||
|
||||
@functools.wraps(cls.delete)
|
||||
def delete_wrapper(*args, **kwargs):
|
||||
if settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
|
||||
return cls.original_delete(*args, **kwargs)
|
||||
raise PermissionDenied({'detail': _('Deletion of this resource is not allowed. Delete this resource via the platform ingress.')})
|
||||
|
||||
cls.delete = delete_wrapper
|
||||
|
||||
if hasattr(cls, 'perform_update'):
|
||||
cls.original_perform_update = cls.perform_update
|
||||
|
||||
@functools.wraps(cls.perform_update)
|
||||
def update_wrapper(*args, **kwargs):
|
||||
if not settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
|
||||
view, serializer = args
|
||||
instance = view.get_object()
|
||||
if instance:
|
||||
if isinstance(instance, models.Organization):
|
||||
shared_fields = OrganizationType._declared_fields.keys()
|
||||
elif isinstance(instance, models.User):
|
||||
shared_fields = UserType._declared_fields.keys()
|
||||
elif isinstance(instance, models.Team):
|
||||
shared_fields = TeamType._declared_fields.keys()
|
||||
attrs = serializer.validated_data
|
||||
for field in shared_fields:
|
||||
if field in attrs and getattr(instance, field) != attrs[field]:
|
||||
raise PermissionDenied({field: _(f"Cannot change shared field '{field}'. Alter this field via the platform ingress.")})
|
||||
return cls.original_perform_update(*args, **kwargs)
|
||||
|
||||
cls.perform_update = update_wrapper
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class TeamList(ListCreateAPIView):
|
||||
model = models.Team
|
||||
serializer_class = serializers.TeamSerializer
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class TeamDetail(RetrieveUpdateDestroyAPIView):
|
||||
model = models.Team
|
||||
serializer_class = serializers.TeamSerializer
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class TeamUsersList(BaseUsersList):
|
||||
model = models.User
|
||||
serializer_class = serializers.UserSerializer
|
||||
@@ -816,17 +749,9 @@ class TeamProjectsList(SubListAPIView):
|
||||
def get_queryset(self):
|
||||
team = self.get_parent_object()
|
||||
self.check_parent_access(team)
|
||||
model_ct = ContentType.objects.get_for_model(self.model)
|
||||
parent_ct = ContentType.objects.get_for_model(self.parent_model)
|
||||
|
||||
rd = get_role_definition(team.member_role)
|
||||
role = ObjectRole.objects.filter(object_id=team.id, content_type=parent_ct, role_definition=rd).first()
|
||||
if role is None:
|
||||
# Team has no permissions, therefore team has no projects
|
||||
return self.model.objects.none()
|
||||
else:
|
||||
project_qs = self.model.accessible_objects(self.request.user, 'read_role')
|
||||
return project_qs.filter(id__in=RoleEvaluation.objects.filter(content_type_id=model_ct.id, role=role).values_list('object_id'))
|
||||
my_qs = self.model.accessible_objects(self.request.user, 'read_role')
|
||||
team_qs = models.Project.accessible_objects(team, 'read_role')
|
||||
return my_qs & team_qs
|
||||
|
||||
|
||||
class TeamActivityStreamList(SubListAPIView):
|
||||
@@ -941,13 +866,23 @@ class ProjectTeamsList(ListAPIView):
|
||||
serializer_class = serializers.TeamSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
p = get_object_or_404(models.Project, pk=self.kwargs['pk'])
|
||||
if not self.request.user.can_access(models.Project, 'read', p):
|
||||
parent = get_object_or_404(models.Project, pk=self.kwargs['pk'])
|
||||
if not self.request.user.can_access(models.Project, 'read', parent):
|
||||
raise PermissionDenied()
|
||||
project_ct = ContentType.objects.get_for_model(models.Project)
|
||||
|
||||
project_ct = ContentType.objects.get_for_model(parent)
|
||||
team_ct = ContentType.objects.get_for_model(self.model)
|
||||
all_roles = models.Role.objects.filter(Q(descendents__content_type=project_ct) & Q(descendents__object_id=p.pk), content_type=team_ct)
|
||||
return self.model.accessible_objects(self.request.user, 'read_role').filter(pk__in=[t.content_object.pk for t in all_roles])
|
||||
|
||||
roles_on_project = models.Role.objects.filter(
|
||||
content_type=project_ct,
|
||||
object_id=parent.pk,
|
||||
)
|
||||
|
||||
team_member_parent_roles = models.Role.objects.filter(children__in=roles_on_project, role_field='member_role', content_type=team_ct).distinct()
|
||||
|
||||
team_ids = team_member_parent_roles.values_list('object_id', flat=True)
|
||||
my_qs = self.model.accessible_objects(self.request.user, 'read_role').filter(pk__in=team_ids)
|
||||
return my_qs
|
||||
|
||||
|
||||
class ProjectSchedulesList(SubListCreateAPIView):
|
||||
@@ -1127,7 +1062,6 @@ class ProjectCopy(CopyAPIView):
|
||||
copy_return_serializer_class = serializers.ProjectSerializer
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class UserList(ListCreateAPIView):
|
||||
model = models.User
|
||||
serializer_class = serializers.UserSerializer
|
||||
@@ -1184,14 +1118,6 @@ class UserRolesList(SubListAttachDetachAPIView):
|
||||
role = get_object_or_400(models.Role, pk=sub_id)
|
||||
|
||||
content_types = ContentType.objects.get_for_models(models.Organization, models.Team, models.Credential) # dict of {model: content_type}
|
||||
# Prevent user to be associated with team/org when ALLOW_LOCAL_RESOURCE_MANAGEMENT is False
|
||||
if not settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
|
||||
for model in [models.Organization, models.Team]:
|
||||
ct = content_types[model]
|
||||
if role.content_type == ct and role.role_field in ['member_role', 'admin_role']:
|
||||
data = dict(msg=_(f"Cannot directly modify user membership to {ct.model}. Direct shared resource management disabled"))
|
||||
return Response(data, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
credential_content_type = content_types[models.Credential]
|
||||
if role.content_type == credential_content_type:
|
||||
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
||||
@@ -1226,7 +1152,6 @@ class UserOrganizationsList(OrganizationCountsMixin, SubListAPIView):
|
||||
model = models.Organization
|
||||
serializer_class = serializers.OrganizationSerializer
|
||||
parent_model = models.User
|
||||
relationship = 'organizations'
|
||||
|
||||
def get_queryset(self):
|
||||
parent = self.get_parent_object()
|
||||
@@ -1240,7 +1165,6 @@ class UserAdminOfOrganizationsList(OrganizationCountsMixin, SubListAPIView):
|
||||
model = models.Organization
|
||||
serializer_class = serializers.OrganizationSerializer
|
||||
parent_model = models.User
|
||||
relationship = 'admin_of_organizations'
|
||||
|
||||
def get_queryset(self):
|
||||
parent = self.get_parent_object()
|
||||
@@ -1264,7 +1188,6 @@ class UserActivityStreamList(SubListAPIView):
|
||||
return qs.filter(Q(actor=parent) | Q(user__in=[parent]))
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class UserDetail(RetrieveUpdateDestroyAPIView):
|
||||
model = models.User
|
||||
serializer_class = serializers.UserSerializer
|
||||
@@ -4239,13 +4162,6 @@ class RoleUsersList(SubListAttachDetachAPIView):
|
||||
role = self.get_parent_object()
|
||||
|
||||
content_types = ContentType.objects.get_for_models(models.Organization, models.Team, models.Credential) # dict of {model: content_type}
|
||||
if not settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
|
||||
for model in [models.Organization, models.Team]:
|
||||
ct = content_types[model]
|
||||
if role.content_type == ct and role.role_field in ['member_role', 'admin_role']:
|
||||
data = dict(msg=_(f"Cannot directly modify user membership to {ct.model}. Direct shared resource management disabled"))
|
||||
return Response(data, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
credential_content_type = content_types[models.Credential]
|
||||
if role.content_type == credential_content_type:
|
||||
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
||||
|
||||
@@ -12,7 +12,7 @@ import re
|
||||
import asn1
|
||||
from awx.api import serializers
|
||||
from awx.api.generics import GenericAPIView, Response
|
||||
from awx.api.permissions import IsSystemAdminOrAuditor
|
||||
from awx.api.permissions import IsSystemAdmin
|
||||
from awx.main import models
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
@@ -48,7 +48,7 @@ class InstanceInstallBundle(GenericAPIView):
|
||||
name = _('Install Bundle')
|
||||
model = models.Instance
|
||||
serializer_class = serializers.InstanceSerializer
|
||||
permission_classes = (IsSystemAdminOrAuditor,)
|
||||
permission_classes = (IsSystemAdmin,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
instance_obj = self.get_object()
|
||||
|
||||
@@ -53,18 +53,15 @@ from awx.api.serializers import (
|
||||
CredentialSerializer,
|
||||
)
|
||||
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, OrganizationCountsMixin, OrganizationInstanceGroupMembershipMixin
|
||||
from awx.api.views import immutablesharedfields
|
||||
|
||||
logger = logging.getLogger('awx.api.views.organization')
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
|
||||
model = Organization
|
||||
serializer_class = OrganizationSerializer
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||
model = Organization
|
||||
serializer_class = OrganizationSerializer
|
||||
@@ -107,7 +104,6 @@ class OrganizationInventoriesList(SubListAPIView):
|
||||
relationship = 'inventories'
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class OrganizationUsersList(BaseUsersList):
|
||||
model = User
|
||||
serializer_class = UserSerializer
|
||||
@@ -116,7 +112,6 @@ class OrganizationUsersList(BaseUsersList):
|
||||
ordering = ('username',)
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class OrganizationAdminsList(BaseUsersList):
|
||||
model = User
|
||||
serializer_class = UserSerializer
|
||||
@@ -155,7 +150,6 @@ class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
|
||||
parent_key = 'organization'
|
||||
|
||||
|
||||
@immutablesharedfields
|
||||
class OrganizationTeamsList(SubListCreateAttachDetachAPIView):
|
||||
model = Team
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
@@ -8,6 +8,8 @@ import operator
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
@@ -26,6 +28,7 @@ from awx.api.generics import APIView
|
||||
from awx.conf.registry import settings_registry
|
||||
from awx.main.analytics import all_collectors
|
||||
from awx.main.ha import is_ha_environment
|
||||
from awx.main.tasks.system import clear_setting_cache
|
||||
from awx.main.utils import get_awx_version, get_custom_venv_choices
|
||||
from awx.main.utils.licensing import validate_entitlement_manifest
|
||||
from awx.api.versioning import URLPathVersioning, reverse, drf_reverse
|
||||
@@ -221,8 +224,12 @@ class ApiV2AttachView(APIView):
|
||||
subscription_id = data.get('subscription_id', None)
|
||||
if not subscription_id:
|
||||
return Response({"error": _("No subscription ID provided.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Ensure we always use the latest subscription credentials
|
||||
cache.delete_many(['SUBSCRIPTIONS_CLIENT_ID', 'SUBSCRIPTIONS_CLIENT_SECRET'])
|
||||
user = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None)
|
||||
pw = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None)
|
||||
if not (user and pw):
|
||||
return Response({"error": _("Missing subscription credentials")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if subscription_id and user and pw:
|
||||
data = request.data.copy()
|
||||
try:
|
||||
@@ -245,6 +252,7 @@ class ApiV2AttachView(APIView):
|
||||
if sub['subscription_id'] == subscription_id:
|
||||
sub['valid_key'] = True
|
||||
settings.LICENSE = sub
|
||||
connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE']))
|
||||
return Response(sub)
|
||||
|
||||
return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -264,7 +272,6 @@ class ApiV2ConfigView(APIView):
|
||||
'''Return various sitewide configuration settings'''
|
||||
|
||||
license_data = get_licenser().validate()
|
||||
|
||||
if not license_data.get('valid_key', False):
|
||||
license_data = {}
|
||||
|
||||
@@ -328,6 +335,7 @@ class ApiV2ConfigView(APIView):
|
||||
|
||||
try:
|
||||
license_data_validated = get_licenser().license_from_manifest(license_data)
|
||||
connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE']))
|
||||
except Exception:
|
||||
logger.warning(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username))
|
||||
return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -346,6 +354,7 @@ class ApiV2ConfigView(APIView):
|
||||
def delete(self, request):
|
||||
try:
|
||||
settings.LICENSE = {}
|
||||
connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE']))
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception:
|
||||
# FIX: Log
|
||||
|
||||
@@ -639,7 +639,9 @@ class UserAccess(BaseAccess):
|
||||
prefetch_related = ('resource',)
|
||||
|
||||
def filtered_queryset(self):
|
||||
if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()):
|
||||
if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (
|
||||
Organization.access_qs(self.user, 'change').exists() or Organization.access_qs(self.user, 'audit').exists()
|
||||
):
|
||||
qs = User.objects.all()
|
||||
else:
|
||||
qs = (
|
||||
@@ -1224,7 +1226,9 @@ class TeamAccess(BaseAccess):
|
||||
)
|
||||
|
||||
def filtered_queryset(self):
|
||||
if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()):
|
||||
if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (
|
||||
Organization.access_qs(self.user, 'change').exists() or Organization.access_qs(self.user, 'audit').exists()
|
||||
):
|
||||
return self.model.objects.all()
|
||||
return self.model.objects.filter(
|
||||
Q(organization__in=Organization.accessible_pk_qs(self.user, 'member_role')) | Q(pk__in=self.model.accessible_pk_qs(self.user, 'read_role'))
|
||||
@@ -2564,7 +2568,7 @@ class NotificationTemplateAccess(BaseAccess):
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
return self.model.access_qs(self.user, 'view')
|
||||
return self.model.objects.filter(
|
||||
Q(organization__in=Organization.access_qs(self.user, 'add_notificationtemplate')) | Q(organization__in=self.user.auditor_of_organizations)
|
||||
Q(organization__in=Organization.access_qs(self.user, 'add_notificationtemplate')) | Q(organization__in=Organization.access_qs(self.user, 'audit'))
|
||||
).distinct()
|
||||
|
||||
@check_superuser
|
||||
@@ -2599,7 +2603,7 @@ class NotificationAccess(BaseAccess):
|
||||
def filtered_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
Q(notification_template__organization__in=Organization.access_qs(self.user, 'add_notificationtemplate'))
|
||||
| Q(notification_template__organization__in=self.user.auditor_of_organizations)
|
||||
| Q(notification_template__organization__in=Organization.access_qs(self.user, 'audit'))
|
||||
).distinct()
|
||||
|
||||
def can_delete(self, obj):
|
||||
|
||||
@@ -105,6 +105,7 @@ register(
|
||||
),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -1093,3 +1094,13 @@ register(
|
||||
category=('PolicyAsCode'),
|
||||
category_slug='policyascode',
|
||||
)
|
||||
|
||||
|
||||
def policy_as_code_validate(serializer, attrs):
|
||||
opa_host = attrs.get('OPA_HOST', '')
|
||||
if opa_host and (opa_host.startswith('http://') or opa_host.startswith('https://')):
|
||||
raise serializers.ValidationError({'OPA_HOST': _("OPA_HOST should not include 'http://' or 'https://' prefixes. Please enter only the hostname.")})
|
||||
return attrs
|
||||
|
||||
|
||||
register_validate('policyascode', policy_as_code_validate)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from crum import impersonate
|
||||
from ansible_base.resource_registry.signals.handlers import no_reverse_sync
|
||||
from awx.main.models import User, Organization, Project, Inventory, CredentialType, Credential, Host, JobTemplate
|
||||
from awx.main.signals import disable_computed_fields
|
||||
|
||||
@@ -16,8 +17,9 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
# Wrap the operation in an atomic block, so we do not on accident
|
||||
# create the organization but not create the project, etc.
|
||||
with transaction.atomic():
|
||||
self._handle()
|
||||
with no_reverse_sync():
|
||||
with transaction.atomic():
|
||||
self._handle()
|
||||
|
||||
def _handle(self):
|
||||
changed = False
|
||||
|
||||
@@ -26,6 +26,11 @@ def change_inventory_source_org_unique(apps, schema_editor):
|
||||
logger.info(f'Set database constraint rule for {r} inventory source objects')
|
||||
|
||||
|
||||
def rename_wfjt(apps, schema_editor):
|
||||
cls = apps.get_model('main', 'WorkflowJobTemplate')
|
||||
_rename_duplicates(cls)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -40,6 +45,7 @@ class Migration(migrations.Migration):
|
||||
name='org_unique',
|
||||
field=models.BooleanField(blank=True, default=True, editable=False, help_text='Used internally to selectively enforce database constraint on name'),
|
||||
),
|
||||
migrations.RunPython(rename_wfjt, migrations.RunPython.noop),
|
||||
migrations.RunPython(change_inventory_source_org_unique, migrations.RunPython.noop),
|
||||
migrations.AddConstraint(
|
||||
model_name='unifiedjobtemplate',
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
from django.db import migrations
|
||||
|
||||
# AWX
|
||||
from awx.main.models import CredentialType
|
||||
from awx.main.utils.common import set_current_apps
|
||||
|
||||
|
||||
def setup_tower_managed_defaults(apps, schema_editor):
|
||||
set_current_apps(apps)
|
||||
CredentialType.setup_tower_managed_defaults(apps)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0200_template_name_constraint'),
|
||||
]
|
||||
|
||||
operations = []
|
||||
operations = [
|
||||
migrations.RunPython(setup_tower_managed_defaults),
|
||||
]
|
||||
|
||||
102
awx/main/migrations/0202_convert_controller_role_definitions.py
Normal file
102
awx/main/migrations/0202_convert_controller_role_definitions.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# Generated by Django migration for converting Controller role definitions
|
||||
|
||||
from ansible_base.rbac.migrations._utils import give_permissions
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def convert_controller_role_definitions(apps, schema_editor):
|
||||
"""
|
||||
Convert Controller role definitions to regular role definitions:
|
||||
- Controller Organization Admin -> Organization Admin
|
||||
- Controller Organization Member -> Organization Member
|
||||
- Controller Team Admin -> Team Admin
|
||||
- Controller Team Member -> Team Member
|
||||
- Controller System Auditor -> Platform Auditor
|
||||
|
||||
Then delete the old Controller role definitions.
|
||||
"""
|
||||
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment')
|
||||
RoleTeamAssignment = apps.get_model('dab_rbac', 'RoleTeamAssignment')
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
|
||||
# Mapping of old Controller role names to new role names
|
||||
role_mappings = {
|
||||
'Controller Organization Admin': 'Organization Admin',
|
||||
'Controller Organization Member': 'Organization Member',
|
||||
'Controller Team Admin': 'Team Admin',
|
||||
'Controller Team Member': 'Team Member',
|
||||
}
|
||||
|
||||
for old_name, new_name in role_mappings.items():
|
||||
# Find the old Controller role definition
|
||||
old_role = RoleDefinition.objects.filter(name=old_name).first()
|
||||
if not old_role:
|
||||
continue # Skip if the old role doesn't exist
|
||||
|
||||
# Find the new role definition
|
||||
new_role = RoleDefinition.objects.get(name=new_name)
|
||||
|
||||
# Collect all the assignments that need to be migrated
|
||||
# Group by object (content_type + object_id) to batch the give_permissions calls
|
||||
assignments_by_object = {}
|
||||
|
||||
# Get user assignments
|
||||
user_assignments = RoleUserAssignment.objects.filter(role_definition=old_role).select_related('object_role')
|
||||
for assignment in user_assignments:
|
||||
key = (assignment.object_role.content_type_id, assignment.object_role.object_id)
|
||||
if key not in assignments_by_object:
|
||||
assignments_by_object[key] = {'users': [], 'teams': []}
|
||||
assignments_by_object[key]['users'].append(assignment.user)
|
||||
|
||||
# Get team assignments
|
||||
team_assignments = RoleTeamAssignment.objects.filter(role_definition=old_role).select_related('object_role')
|
||||
for assignment in team_assignments:
|
||||
key = (assignment.object_role.content_type_id, assignment.object_role.object_id)
|
||||
if key not in assignments_by_object:
|
||||
assignments_by_object[key] = {'users': [], 'teams': []}
|
||||
assignments_by_object[key]['teams'].append(assignment.team.id)
|
||||
|
||||
# Use give_permissions to create new assignments with the new role definition
|
||||
for (content_type_id, object_id), data in assignments_by_object.items():
|
||||
if data['users'] or data['teams']:
|
||||
give_permissions(
|
||||
apps,
|
||||
new_role,
|
||||
users=data['users'],
|
||||
teams=data['teams'],
|
||||
object_id=object_id,
|
||||
content_type_id=content_type_id,
|
||||
)
|
||||
|
||||
# Delete the old role definition (this will cascade to delete old assignments and ObjectRoles)
|
||||
old_role.delete()
|
||||
|
||||
# Create or get Platform Auditor
|
||||
auditor_rd, created = RoleDefinition.objects.get_or_create(
|
||||
name='Platform Auditor',
|
||||
defaults={'description': 'Migrated singleton role giving read permission to everything', 'managed': True},
|
||||
)
|
||||
if created:
|
||||
auditor_rd.permissions.add(*list(Permission.objects.filter(codename__startswith='view')))
|
||||
|
||||
old_rd = RoleDefinition.objects.filter(name='Controller System Auditor').first()
|
||||
if old_rd:
|
||||
for assignment in RoleUserAssignment.objects.filter(role_definition=old_rd):
|
||||
RoleUserAssignment.objects.create(
|
||||
user=assignment.user,
|
||||
role_definition=auditor_rd,
|
||||
)
|
||||
|
||||
# Delete the Controller System Auditor role
|
||||
RoleDefinition.objects.filter(name='Controller System Auditor').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0201_create_managed_creds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(convert_controller_role_definitions),
|
||||
]
|
||||
22
awx/main/migrations/0203_remove_team_of_teams.py
Normal file
22
awx/main/migrations/0203_remove_team_of_teams.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from awx.main.migrations._dab_rbac import consolidate_indirect_user_roles
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0202_convert_controller_role_definitions'),
|
||||
]
|
||||
# The DAB RBAC app makes substantial model changes which by change-ordering comes after this
|
||||
# not including run_before might sometimes work but this enforces a more strict and stable order
|
||||
# for both applying migrations forwards and backwards
|
||||
run_before = [("dab_rbac", "0004_remote_permissions_additions")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(consolidate_indirect_user_roles, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,34 +1,55 @@
|
||||
# Generated by Django 4.2.10 on 2024-09-16 10:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from awx.main.migrations._create_system_jobs import delete_clear_tokens_sjt
|
||||
|
||||
|
||||
# --- START of function merged from 0203_rename_github_app_kind.py ---
|
||||
def update_github_app_kind(apps, schema_editor):
|
||||
"""
|
||||
Updates the 'kind' field for CredentialType records
|
||||
from 'github_app' to 'github_app_lookup'.
|
||||
This addresses a change in the entry point key for the GitHub App plugin.
|
||||
"""
|
||||
CredentialType = apps.get_model('main', 'CredentialType')
|
||||
db_alias = schema_editor.connection.alias
|
||||
CredentialType.objects.using(db_alias).filter(kind='github_app').update(kind='github_app_lookup')
|
||||
|
||||
|
||||
# --- END of function merged from 0203_rename_github_app_kind.py ---
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0201_create_managed_creds'),
|
||||
('main', '0203_remove_team_of_teams'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Profile',
|
||||
),
|
||||
# Remove SSO app content
|
||||
# delete all sso application migrations
|
||||
migrations.RunSQL("DELETE FROM django_migrations WHERE app = 'sso';"),
|
||||
# Added reverse_sql=migrations.RunSQL.noop to make this reversible for tests
|
||||
migrations.RunSQL("DELETE FROM django_migrations WHERE app = 'sso';", reverse_sql=migrations.RunSQL.noop),
|
||||
# delete all sso application content group permissions
|
||||
# Added reverse_sql=migrations.RunSQL.noop to make this reversible for tests
|
||||
migrations.RunSQL(
|
||||
"DELETE FROM auth_group_permissions "
|
||||
"WHERE permission_id IN "
|
||||
"(SELECT id FROM auth_permission WHERE content_type_id in (SELECT id FROM django_content_type WHERE app_label = 'sso'));"
|
||||
"(SELECT id FROM auth_permission WHERE content_type_id in (SELECT id FROM django_content_type WHERE app_label = 'sso'));",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
# delete all sso application content permissions
|
||||
migrations.RunSQL("DELETE FROM auth_permission " "WHERE content_type_id IN (SELECT id FROM django_content_type WHERE app_label = 'sso');"),
|
||||
# Added reverse_sql=migrations.RunSQL.noop to make this reversible for tests
|
||||
migrations.RunSQL(
|
||||
"DELETE FROM auth_permission " "WHERE content_type_id IN (SELECT id FROM django_content_type WHERE app_label = 'sso');",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
# delete sso application content type
|
||||
migrations.RunSQL("DELETE FROM django_content_type WHERE app_label = 'sso';"),
|
||||
# Added reverse_sql=migrations.RunSQL.noop to make this reversible for tests
|
||||
migrations.RunSQL("DELETE FROM django_content_type WHERE app_label = 'sso';", reverse_sql=migrations.RunSQL.noop),
|
||||
# drop sso application created table
|
||||
migrations.RunSQL("DROP TABLE IF EXISTS sso_userenterpriseauth;"),
|
||||
# Added reverse_sql=migrations.RunSQL.noop to make this reversible for tests
|
||||
migrations.RunSQL("DROP TABLE IF EXISTS sso_userenterpriseauth;", reverse_sql=migrations.RunSQL.noop),
|
||||
# Alter inventory source source field
|
||||
migrations.AlterField(
|
||||
model_name='inventorysource',
|
||||
@@ -97,4 +118,7 @@ class Migration(migrations.Migration):
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
# --- START of operations merged from 0203_rename_github_app_kind.py ---
|
||||
migrations.RunPython(update_github_app_kind, migrations.RunPython.noop),
|
||||
# --- END of operations merged from 0203_rename_github_app_kind.py ---
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.apps import apps as global_apps
|
||||
from django.db.models import ForeignKey
|
||||
@@ -17,7 +18,14 @@ logger = logging.getLogger('awx.main.migrations._dab_rbac')
|
||||
|
||||
|
||||
def create_permissions_as_operation(apps, schema_editor):
|
||||
logger.info('Running data migration create_permissions_as_operation')
|
||||
# NOTE: the DAB ContentType changes adjusted how they fire
|
||||
# before they would fire on every app config, like contenttypes
|
||||
create_dab_permissions(global_apps.get_app_config("main"), apps=apps)
|
||||
# This changed to only fire once and do a global creation
|
||||
# so we need to call it for specifically the dab_rbac app
|
||||
# multiple calls will not hurt anything
|
||||
create_dab_permissions(global_apps.get_app_config("dab_rbac"), apps=apps)
|
||||
|
||||
|
||||
"""
|
||||
@@ -112,7 +120,12 @@ def get_descendents(f, children_map):
|
||||
|
||||
def get_permissions_for_role(role_field, children_map, apps):
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
try:
|
||||
# After migration for remote permissions
|
||||
ContentType = apps.get_model('dab_rbac', 'DABContentType')
|
||||
except LookupError:
|
||||
# If using DAB from before remote permissions are implemented
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
perm_list = []
|
||||
for child_field in get_descendents(role_field, children_map):
|
||||
@@ -155,11 +168,15 @@ def migrate_to_new_rbac(apps, schema_editor):
|
||||
This method moves the assigned permissions from the old rbac.py models
|
||||
to the new RoleDefinition and ObjectRole models
|
||||
"""
|
||||
logger.info('Running data migration migrate_to_new_rbac')
|
||||
Role = apps.get_model('main', 'Role')
|
||||
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment')
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
|
||||
if Permission.objects.count() == 0:
|
||||
raise RuntimeError('Running migrate_to_new_rbac requires DABPermission objects created first')
|
||||
|
||||
# remove add premissions that are not valid for migrations from old versions
|
||||
for perm_str in ('add_organization', 'add_jobtemplate'):
|
||||
perm = Permission.objects.filter(codename=perm_str).first()
|
||||
@@ -239,11 +256,14 @@ def migrate_to_new_rbac(apps, schema_editor):
|
||||
|
||||
# Create new replacement system auditor role
|
||||
new_system_auditor, created = RoleDefinition.objects.get_or_create(
|
||||
name='Controller System Auditor',
|
||||
name='Platform Auditor',
|
||||
defaults={'description': 'Migrated singleton role giving read permission to everything', 'managed': True},
|
||||
)
|
||||
new_system_auditor.permissions.add(*list(Permission.objects.filter(codename__startswith='view')))
|
||||
|
||||
if created:
|
||||
logger.info(f'Created RoleDefinition {new_system_auditor.name} pk={new_system_auditor.pk} with {new_system_auditor.permissions.count()} permissions')
|
||||
|
||||
# migrate is_system_auditor flag, because it is no longer handled by a system role
|
||||
old_system_auditor = Role.objects.filter(singleton_name='system_auditor').first()
|
||||
if old_system_auditor:
|
||||
@@ -272,8 +292,9 @@ def get_or_create_managed(name, description, ct, permissions, RoleDefinition):
|
||||
|
||||
def setup_managed_role_definitions(apps, schema_editor):
|
||||
"""
|
||||
Idepotent method to create or sync the managed role definitions
|
||||
Idempotent method to create or sync the managed role definitions
|
||||
"""
|
||||
logger.info('Running data migration setup_managed_role_definitions')
|
||||
to_create = {
|
||||
'object_admin': '{cls.__name__} Admin',
|
||||
'org_admin': 'Organization Admin',
|
||||
@@ -281,7 +302,13 @@ def setup_managed_role_definitions(apps, schema_editor):
|
||||
'special': '{cls.__name__} {action}',
|
||||
}
|
||||
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
try:
|
||||
# After migration for remote permissions
|
||||
ContentType = apps.get_model('dab_rbac', 'DABContentType')
|
||||
except LookupError:
|
||||
# If using DAB from before remote permissions are implemented
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
Organization = apps.get_model(settings.ANSIBLE_BASE_ORGANIZATION_MODEL)
|
||||
@@ -309,16 +336,6 @@ def setup_managed_role_definitions(apps, schema_editor):
|
||||
to_create['object_admin'].format(cls=cls), f'Has all permissions to a single {cls._meta.verbose_name}', ct, indiv_perms, RoleDefinition
|
||||
)
|
||||
)
|
||||
if cls_name == 'team':
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
'Controller Team Admin',
|
||||
f'Has all permissions to a single {cls._meta.verbose_name}',
|
||||
ct,
|
||||
indiv_perms,
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
|
||||
if 'org_children' in to_create and (cls_name not in ('organization', 'instancegroup', 'team')):
|
||||
org_child_perms = object_perms.copy()
|
||||
@@ -359,18 +376,6 @@ def setup_managed_role_definitions(apps, schema_editor):
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
if action == 'member' and cls_name in ('organization', 'team'):
|
||||
suffix = to_create['special'].format(cls=cls, action=action.title())
|
||||
rd_name = f'Controller {suffix}'
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
rd_name,
|
||||
f'Has {action} permissions to a single {cls._meta.verbose_name}',
|
||||
ct,
|
||||
perm_list,
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
|
||||
if 'org_admin' in to_create:
|
||||
managed_role_definitions.append(
|
||||
@@ -382,15 +387,6 @@ def setup_managed_role_definitions(apps, schema_editor):
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
'Controller Organization Admin',
|
||||
'Has all permissions to a single organization and all objects inside of it',
|
||||
org_ct,
|
||||
org_perms,
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
|
||||
# Special "organization action" roles
|
||||
audit_permissions = [perm for perm in org_perms if perm.codename.startswith('view_')]
|
||||
@@ -431,3 +427,115 @@ def setup_managed_role_definitions(apps, schema_editor):
|
||||
for role_definition in unexpected_role_definitions:
|
||||
logger.info(f'Deleting old managed role definition {role_definition.name}, pk={role_definition.pk}')
|
||||
role_definition.delete()
|
||||
|
||||
|
||||
def get_team_to_team_relationships(apps, team_member_role):
|
||||
"""
|
||||
Find all team-to-team relationships where one team is a member of another.
|
||||
Returns a dict mapping parent_team_id -> [child_team_id, ...]
|
||||
"""
|
||||
team_to_team_relationships = defaultdict(list)
|
||||
|
||||
# Find all team assignments with the Team Member role
|
||||
RoleTeamAssignment = apps.get_model('dab_rbac', 'RoleTeamAssignment')
|
||||
team_assignments = RoleTeamAssignment.objects.filter(role_definition=team_member_role).select_related('team')
|
||||
|
||||
for assignment in team_assignments:
|
||||
parent_team_id = int(assignment.object_id)
|
||||
child_team_id = assignment.team.id
|
||||
team_to_team_relationships[parent_team_id].append(child_team_id)
|
||||
|
||||
return team_to_team_relationships
|
||||
|
||||
|
||||
def get_all_user_members_of_team(apps, team_member_role, team_id, team_to_team_map, visited=None):
|
||||
"""
|
||||
Recursively find all users who are members of a team, including through nested teams.
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if team_id in visited:
|
||||
return set() # Avoid infinite recursion
|
||||
|
||||
visited.add(team_id)
|
||||
all_users = set()
|
||||
|
||||
# Get direct user assignments to this team
|
||||
RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment')
|
||||
user_assignments = RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_id).select_related('user')
|
||||
|
||||
for assignment in user_assignments:
|
||||
all_users.add(assignment.user)
|
||||
|
||||
# Get team-to-team assignments and recursively find their users
|
||||
child_team_ids = team_to_team_map.get(team_id, [])
|
||||
for child_team_id in child_team_ids:
|
||||
nested_users = get_all_user_members_of_team(apps, team_member_role, child_team_id, team_to_team_map, visited.copy())
|
||||
all_users.update(nested_users)
|
||||
|
||||
return all_users
|
||||
|
||||
|
||||
def remove_team_to_team_assignment(apps, team_member_role, parent_team_id, child_team_id):
|
||||
"""
|
||||
Remove team-to-team memberships.
|
||||
"""
|
||||
Team = apps.get_model('main', 'Team')
|
||||
RoleTeamAssignment = apps.get_model('dab_rbac', 'RoleTeamAssignment')
|
||||
|
||||
parent_team = Team.objects.get(id=parent_team_id)
|
||||
child_team = Team.objects.get(id=child_team_id)
|
||||
|
||||
# Remove all team-to-team RoleTeamAssignments
|
||||
RoleTeamAssignment.objects.filter(role_definition=team_member_role, object_id=parent_team_id, team=child_team).delete()
|
||||
|
||||
# Check mirroring Team model for children under member_role
|
||||
parent_team.member_role.children.filter(object_id=child_team_id).delete()
|
||||
|
||||
|
||||
def consolidate_indirect_user_roles(apps, schema_editor):
|
||||
"""
|
||||
A user should have a member role for every team they were indirectly
|
||||
a member of. ex. Team A is a member of Team B. All users in Team A
|
||||
previously were only members of Team A. They should now be members of
|
||||
Team A and Team B.
|
||||
"""
|
||||
|
||||
# get models for membership on teams
|
||||
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
Team = apps.get_model('main', 'Team')
|
||||
|
||||
team_member_role = RoleDefinition.objects.get(name='Team Member')
|
||||
|
||||
team_to_team_map = get_team_to_team_relationships(apps, team_member_role)
|
||||
|
||||
if not team_to_team_map:
|
||||
return # No team-to-team relationships to consolidate
|
||||
|
||||
# Get content type for Team - needed for give_permissions
|
||||
try:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
team_content_type = ContentType.objects.get_for_model(Team)
|
||||
except ImportError:
|
||||
# Fallback if ContentType is not available
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
team_content_type = ContentType.objects.get_for_model(Team)
|
||||
|
||||
# Get all users who should be direct members of a team
|
||||
for parent_team_id, child_team_ids in team_to_team_map.items():
|
||||
all_users = get_all_user_members_of_team(apps, team_member_role, parent_team_id, team_to_team_map)
|
||||
|
||||
# Create direct RoleUserAssignments for all users
|
||||
if all_users:
|
||||
give_permissions(apps=apps, rd=team_member_role, users=list(all_users), object_id=parent_team_id, content_type_id=team_content_type.id)
|
||||
|
||||
# Mirror assignments to Team model
|
||||
parent_team = Team.objects.get(id=parent_team_id)
|
||||
for user in all_users:
|
||||
parent_team.member_role.members.add(user.id)
|
||||
|
||||
# Remove all team-to-team assignments for parent team
|
||||
for child_team_id in child_team_ids:
|
||||
remove_team_to_team_assignment(apps, team_member_role, parent_team_id, child_team_id)
|
||||
|
||||
@@ -172,35 +172,17 @@ def cleanup_created_modified_by(sender, **kwargs):
|
||||
pre_delete.connect(cleanup_created_modified_by, sender=User)
|
||||
|
||||
|
||||
@property
|
||||
def user_get_organizations(user):
|
||||
return Organization.access_qs(user, 'member')
|
||||
|
||||
|
||||
@property
|
||||
def user_get_admin_of_organizations(user):
|
||||
return Organization.access_qs(user, 'change')
|
||||
|
||||
|
||||
@property
|
||||
def user_get_auditor_of_organizations(user):
|
||||
return Organization.access_qs(user, 'audit')
|
||||
|
||||
|
||||
@property
|
||||
def created(user):
|
||||
return user.date_joined
|
||||
|
||||
|
||||
User.add_to_class('organizations', user_get_organizations)
|
||||
User.add_to_class('admin_of_organizations', user_get_admin_of_organizations)
|
||||
User.add_to_class('auditor_of_organizations', user_get_auditor_of_organizations)
|
||||
User.add_to_class('created', created)
|
||||
|
||||
|
||||
def get_system_auditor_role():
|
||||
rd, created = RoleDefinition.objects.get_or_create(
|
||||
name='Controller System Auditor', defaults={'description': 'Migrated singleton role giving read permission to everything'}
|
||||
name='Platform Auditor', defaults={'description': 'Migrated singleton role giving read permission to everything'}
|
||||
)
|
||||
if created:
|
||||
rd.permissions.add(*list(permission_registry.permission_qs.filter(codename__startswith='view')))
|
||||
|
||||
@@ -1024,7 +1024,10 @@ class InventorySourceOptions(BaseModel):
|
||||
# If a credential was provided, it's important that it matches
|
||||
# the actual inventory source being used (Amazon requires Amazon
|
||||
# credentials; Rackspace requires Rackspace credentials; etc...)
|
||||
if source.replace('ec2', 'aws') != cred.kind:
|
||||
# TODO: AAP-53978 check that this matches new awx-plugin content for ESXI
|
||||
if source == 'vmware_esxi' and source.replace('vmware_esxi', 'vmware') != cred.kind:
|
||||
return _('VMWARE inventory sources (such as %s) require credentials for the matching cloud service.') % source
|
||||
if source == 'ec2' and source.replace('ec2', 'aws') != cred.kind:
|
||||
return _('Cloud-based inventory sources (such as %s) require credentials for the matching cloud service.') % source
|
||||
# Allow an EC2 source to omit the credential. If Tower is running on
|
||||
# an EC2 instance with an IAM Role assigned, boto will use credentials
|
||||
|
||||
@@ -86,7 +86,7 @@ class ResourceMixin(models.Model):
|
||||
raise RuntimeError(f'Role filters only valid for users and ancestor role, received {accessor}')
|
||||
|
||||
if content_types is None:
|
||||
ct_kwarg = dict(content_type_id=ContentType.objects.get_for_model(cls).id)
|
||||
ct_kwarg = dict(content_type=ContentType.objects.get_for_model(cls))
|
||||
else:
|
||||
ct_kwarg = dict(content_type_id__in=content_types)
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ from django.conf import settings
|
||||
|
||||
# Ansible_base app
|
||||
from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment, RoleTeamAssignment
|
||||
from ansible_base.rbac.sync import maybe_reverse_sync_assignment, maybe_reverse_sync_unassignment, maybe_reverse_sync_role_definition
|
||||
from ansible_base.rbac import permission_registry
|
||||
from ansible_base.resource_registry.signals.handlers import no_reverse_sync
|
||||
from ansible_base.lib.utils.models import get_type_for_model
|
||||
|
||||
# AWX
|
||||
@@ -559,34 +562,27 @@ def get_role_definition(role):
|
||||
f = obj._meta.get_field(role.role_field)
|
||||
action_name = f.name.rsplit("_", 1)[0]
|
||||
model_print = type(obj).__name__
|
||||
rd_name = f'{model_print} {action_name.title()} Compat'
|
||||
perm_list = get_role_codenames(role)
|
||||
defaults = {
|
||||
'content_type_id': role.content_type_id,
|
||||
'content_type': permission_registry.content_type_model.objects.get_by_natural_key(role.content_type.app_label, role.content_type.model),
|
||||
'description': f'Has {action_name.title()} permission to {model_print} for backwards API compatibility',
|
||||
}
|
||||
# use Controller-specific role definitions for Team/Organization and member/admin
|
||||
# instead of platform role definitions
|
||||
# these should exist in the system already, so just do a lookup by role definition name
|
||||
if model_print in ['Team', 'Organization'] and action_name in ['member', 'admin']:
|
||||
rd_name = f'Controller {model_print} {action_name.title()}'
|
||||
rd = RoleDefinition.objects.filter(name=rd_name).first()
|
||||
if rd:
|
||||
return rd
|
||||
else:
|
||||
return RoleDefinition.objects.create_from_permissions(permissions=perm_list, name=rd_name, managed=True, **defaults)
|
||||
|
||||
else:
|
||||
rd_name = f'{model_print} {action_name.title()} Compat'
|
||||
|
||||
with impersonate(None):
|
||||
try:
|
||||
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
|
||||
with no_reverse_sync():
|
||||
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
|
||||
except ValidationError:
|
||||
# This is a tricky case - practically speaking, users should not be allowed to create team roles
|
||||
# or roles that include the team member permission.
|
||||
# If we need to create this for compatibility purposes then we will create it as a managed non-editable role
|
||||
defaults['managed'] = True
|
||||
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
|
||||
with no_reverse_sync():
|
||||
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
|
||||
|
||||
if created and rbac_sync_enabled.enabled:
|
||||
maybe_reverse_sync_role_definition(rd, action='create')
|
||||
return rd
|
||||
|
||||
|
||||
@@ -600,12 +596,6 @@ def get_role_from_object_role(object_role):
|
||||
model_name, role_name, _ = rd.name.split()
|
||||
role_name = role_name.lower()
|
||||
role_name += '_role'
|
||||
elif rd.name.startswith('Controller') and rd.name.endswith(' Admin'):
|
||||
# Controller Organization Admin and Controller Team Admin
|
||||
role_name = 'admin_role'
|
||||
elif rd.name.startswith('Controller') and rd.name.endswith(' Member'):
|
||||
# Controller Organization Member and Controller Team Member
|
||||
role_name = 'member_role'
|
||||
elif rd.name.endswith(' Admin') and rd.name.count(' ') == 2:
|
||||
# cases like "Organization Project Admin"
|
||||
model_name, target_model_name, role_name = rd.name.split()
|
||||
@@ -632,12 +622,14 @@ def get_role_from_object_role(object_role):
|
||||
return getattr(object_role.content_object, role_name)
|
||||
|
||||
|
||||
def give_or_remove_permission(role, actor, giving=True):
|
||||
def give_or_remove_permission(role, actor, giving=True, rd=None):
|
||||
obj = role.content_object
|
||||
if obj is None:
|
||||
return
|
||||
rd = get_role_definition(role)
|
||||
rd.give_or_remove_permission(actor, obj, giving=giving)
|
||||
if not rd:
|
||||
rd = get_role_definition(role)
|
||||
assignment = rd.give_or_remove_permission(actor, obj, giving=giving)
|
||||
return assignment
|
||||
|
||||
|
||||
class SyncEnabled(threading.local):
|
||||
@@ -689,7 +681,15 @@ def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
|
||||
role = Role.objects.get(pk=user_or_role_id)
|
||||
else:
|
||||
user = get_user_model().objects.get(pk=user_or_role_id)
|
||||
give_or_remove_permission(role, user, giving=is_giving)
|
||||
rd = get_role_definition(role)
|
||||
assignment = give_or_remove_permission(role, user, giving=is_giving, rd=rd)
|
||||
|
||||
# sync to resource server
|
||||
if rbac_sync_enabled.enabled:
|
||||
if is_giving:
|
||||
maybe_reverse_sync_assignment(assignment)
|
||||
else:
|
||||
maybe_reverse_sync_unassignment(rd, user, role.content_object)
|
||||
|
||||
|
||||
def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
||||
@@ -732,12 +732,19 @@ def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
|
||||
from awx.main.models.organization import Team
|
||||
|
||||
team = Team.objects.get(pk=parent_role.object_id)
|
||||
give_or_remove_permission(child_role, team, giving=is_giving)
|
||||
rd = get_role_definition(child_role)
|
||||
assignment = give_or_remove_permission(child_role, team, giving=is_giving, rd=rd)
|
||||
|
||||
# sync to resource server
|
||||
if rbac_sync_enabled.enabled:
|
||||
if is_giving:
|
||||
maybe_reverse_sync_assignment(assignment)
|
||||
else:
|
||||
maybe_reverse_sync_unassignment(rd, team, child_role.content_object)
|
||||
|
||||
|
||||
ROLE_DEFINITION_TO_ROLE_FIELD = {
|
||||
'Organization Member': 'member_role',
|
||||
'Controller Organization Member': 'member_role',
|
||||
'WorkflowJobTemplate Admin': 'admin_role',
|
||||
'Organization WorkflowJobTemplate Admin': 'workflow_admin_role',
|
||||
'WorkflowJobTemplate Execute': 'execute_role',
|
||||
@@ -762,11 +769,8 @@ ROLE_DEFINITION_TO_ROLE_FIELD = {
|
||||
'Organization Credential Admin': 'credential_admin_role',
|
||||
'Credential Use': 'use_role',
|
||||
'Team Admin': 'admin_role',
|
||||
'Controller Team Admin': 'admin_role',
|
||||
'Team Member': 'member_role',
|
||||
'Controller Team Member': 'member_role',
|
||||
'Organization Admin': 'admin_role',
|
||||
'Controller Organization Admin': 'admin_role',
|
||||
'Organization Audit': 'auditor_role',
|
||||
'Organization Execute': 'execute_role',
|
||||
'Organization Approval': 'approval_role',
|
||||
|
||||
@@ -34,6 +34,7 @@ from polymorphic.models import PolymorphicModel
|
||||
|
||||
from ansible_base.lib.utils.models import prevent_search, get_type_for_model
|
||||
from ansible_base.rbac import permission_registry
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
|
||||
# AWX
|
||||
from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel, NotificationFieldsModel
|
||||
@@ -218,20 +219,21 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
|
||||
# do not use this if in a subclass
|
||||
if cls != UnifiedJobTemplate:
|
||||
return super(UnifiedJobTemplate, cls).accessible_pk_qs(accessor, role_field)
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
|
||||
action = to_permissions[role_field]
|
||||
|
||||
# Special condition for super auditor
|
||||
role_subclasses = cls._submodels_with_roles()
|
||||
role_cts = ContentType.objects.get_for_models(*role_subclasses).values()
|
||||
all_codenames = {f'{action}_{cls._meta.model_name}' for cls in role_subclasses}
|
||||
if not (all_codenames - accessor.singleton_permissions()):
|
||||
role_cts = ContentType.objects.get_for_models(*role_subclasses).values()
|
||||
qs = cls.objects.filter(polymorphic_ctype__in=role_cts)
|
||||
return qs.values_list('id', flat=True)
|
||||
|
||||
dab_role_cts = permission_registry.content_type_model.objects.get_for_models(*role_subclasses).values()
|
||||
|
||||
return (
|
||||
RoleEvaluation.objects.filter(role__in=accessor.has_roles.all(), codename__in=all_codenames, content_type_id__in=[ct.id for ct in role_cts])
|
||||
RoleEvaluation.objects.filter(role__in=accessor.has_roles.all(), codename__in=all_codenames, content_type_id__in=[ct.id for ct in dab_role_cts])
|
||||
.values_list('object_id')
|
||||
.distinct()
|
||||
)
|
||||
@@ -1198,6 +1200,13 @@ class UnifiedJob(
|
||||
fd = StringIO(fd.getvalue().replace('\\r\\n', '\n'))
|
||||
return fd
|
||||
|
||||
def _fix_double_escapes(self, content):
|
||||
"""
|
||||
Collapse double-escaped sequences into single-escaped form.
|
||||
"""
|
||||
# Replace \\ followed by one of ' " \ n r t
|
||||
return re.sub(r'\\([\'"\\nrt])', r'\1', content)
|
||||
|
||||
def _escape_ascii(self, content):
|
||||
# Remove ANSI escape sequences used to embed event data.
|
||||
content = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', content)
|
||||
@@ -1205,12 +1214,14 @@ class UnifiedJob(
|
||||
content = re.sub(r'\x1b[^m]*m', '', content)
|
||||
return content
|
||||
|
||||
def _result_stdout_raw(self, redact_sensitive=False, escape_ascii=False):
|
||||
def _result_stdout_raw(self, redact_sensitive=False, escape_ascii=False, fix_escapes=False):
|
||||
content = self.result_stdout_raw_handle().read()
|
||||
if redact_sensitive:
|
||||
content = UriCleaner.remove_sensitive(content)
|
||||
if escape_ascii:
|
||||
content = self._escape_ascii(content)
|
||||
if fix_escapes:
|
||||
content = self._fix_double_escapes(content)
|
||||
return content
|
||||
|
||||
@property
|
||||
@@ -1219,9 +1230,10 @@ class UnifiedJob(
|
||||
|
||||
@property
|
||||
def result_stdout(self):
|
||||
return self._result_stdout_raw(escape_ascii=True)
|
||||
# Human-facing output should fix escapes
|
||||
return self._result_stdout_raw(escape_ascii=True, fix_escapes=True)
|
||||
|
||||
def _result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True, escape_ascii=False):
|
||||
def _result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True, escape_ascii=False, fix_escapes=False):
|
||||
return_buffer = StringIO()
|
||||
if end_line is not None:
|
||||
end_line = int(end_line)
|
||||
@@ -1244,14 +1256,18 @@ class UnifiedJob(
|
||||
return_buffer = UriCleaner.remove_sensitive(return_buffer)
|
||||
if escape_ascii:
|
||||
return_buffer = self._escape_ascii(return_buffer)
|
||||
if fix_escapes:
|
||||
return_buffer = self._fix_double_escapes(return_buffer)
|
||||
|
||||
return return_buffer, start_actual, end_actual, absolute_end
|
||||
|
||||
def result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=False):
|
||||
# Raw should NOT fix escapes
|
||||
return self._result_stdout_raw_limited(start_line, end_line, redact_sensitive)
|
||||
|
||||
def result_stdout_limited(self, start_line=0, end_line=None, redact_sensitive=False):
|
||||
return self._result_stdout_raw_limited(start_line, end_line, redact_sensitive, escape_ascii=True)
|
||||
# Human-facing should fix escapes
|
||||
return self._result_stdout_raw_limited(start_line, end_line, redact_sensitive, escape_ascii=True, fix_escapes=True)
|
||||
|
||||
@property
|
||||
def workflow_job_id(self):
|
||||
|
||||
@@ -53,8 +53,8 @@ class GrafanaBackend(AWXBaseEmailBackend, CustomNotificationBase):
|
||||
):
|
||||
super(GrafanaBackend, self).__init__(fail_silently=fail_silently)
|
||||
self.grafana_key = grafana_key
|
||||
self.dashboardId = int(dashboardId) if dashboardId is not None and panelId != "" else None
|
||||
self.panelId = int(panelId) if panelId is not None and panelId != "" else None
|
||||
self.dashboardId = int(dashboardId) if dashboardId != '' else None
|
||||
self.panelId = int(panelId) if panelId != '' else None
|
||||
self.annotation_tags = annotation_tags if annotation_tags is not None else []
|
||||
self.grafana_no_verify_ssl = grafana_no_verify_ssl
|
||||
self.isRegion = isRegion
|
||||
|
||||
@@ -5,8 +5,6 @@ import time
|
||||
import ssl
|
||||
import logging
|
||||
|
||||
import irc.client
|
||||
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -16,6 +14,19 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBa
|
||||
logger = logging.getLogger('awx.main.notifications.irc_backend')
|
||||
|
||||
|
||||
def _irc():
|
||||
"""
|
||||
Prime the real jaraco namespace before importing irc.* so that
|
||||
setuptools' vendored 'setuptools._vendor.jaraco' doesn't shadow
|
||||
external 'jaraco.*' packages (e.g., jaraco.stream).
|
||||
"""
|
||||
import jaraco.stream # ensure the namespace package is established # noqa: F401
|
||||
import irc.client as irc_client
|
||||
import irc.connection as irc_connection
|
||||
|
||||
return irc_client, irc_connection
|
||||
|
||||
|
||||
class IrcBackend(AWXBaseEmailBackend, CustomNotificationBase):
|
||||
init_parameters = {
|
||||
"server": {"label": "IRC Server Address", "type": "string"},
|
||||
@@ -40,12 +51,15 @@ class IrcBackend(AWXBaseEmailBackend, CustomNotificationBase):
|
||||
def open(self):
|
||||
if self.connection is not None:
|
||||
return False
|
||||
|
||||
irc_client, irc_connection = _irc()
|
||||
|
||||
if self.use_ssl:
|
||||
connection_factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
|
||||
connection_factory = irc_connection.Factory(wrapper=ssl.wrap_socket)
|
||||
else:
|
||||
connection_factory = irc.connection.Factory()
|
||||
connection_factory = irc_connection.Factory()
|
||||
try:
|
||||
self.reactor = irc.client.Reactor()
|
||||
self.reactor = irc_client.Reactor()
|
||||
self.connection = self.reactor.server().connect(
|
||||
self.server,
|
||||
self.port,
|
||||
@@ -53,7 +67,7 @@ class IrcBackend(AWXBaseEmailBackend, CustomNotificationBase):
|
||||
password=self.password,
|
||||
connect_factory=connection_factory,
|
||||
)
|
||||
except irc.client.ServerConnectionError as e:
|
||||
except irc_client.ServerConnectionError as e:
|
||||
logger.error(smart_str(_("Exception connecting to irc server: {}").format(e)))
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
@@ -65,8 +79,9 @@ class IrcBackend(AWXBaseEmailBackend, CustomNotificationBase):
|
||||
self.connection = None
|
||||
|
||||
def on_connect(self, connection, event):
|
||||
irc_client, _ = _irc()
|
||||
for c in self.channels:
|
||||
if irc.client.is_channel(c):
|
||||
if irc_client.is_channel(c):
|
||||
connection.join(c)
|
||||
else:
|
||||
for m in self.channels[c]:
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.db import transaction
|
||||
# Django flags
|
||||
from flags.state import flag_enabled
|
||||
|
||||
from awx.main.dispatch.publish import task as task_awx
|
||||
from awx.main.dispatch.publish import task
|
||||
from awx.main.dispatch import get_task_queuename
|
||||
from awx.main.models.indirect_managed_node_audit import IndirectManagedNodeAudit
|
||||
from awx.main.models.event_query import EventQuery
|
||||
@@ -159,7 +159,7 @@ def cleanup_old_indirect_host_entries() -> None:
|
||||
IndirectManagedNodeAudit.objects.filter(created__lt=limit).delete()
|
||||
|
||||
|
||||
@task_awx(queue=get_task_queuename)
|
||||
@task(queue=get_task_queuename)
|
||||
def save_indirect_host_entries(job_id: int, wait_for_events: bool = True) -> None:
|
||||
try:
|
||||
job = Job.objects.get(id=job_id)
|
||||
@@ -201,7 +201,7 @@ def save_indirect_host_entries(job_id: int, wait_for_events: bool = True) -> Non
|
||||
logger.exception(f'Error processing indirect host data for job_id={job_id}')
|
||||
|
||||
|
||||
@task_awx(queue=get_task_queuename)
|
||||
@task(queue=get_task_queuename)
|
||||
def cleanup_and_save_indirect_host_entries_fallback() -> None:
|
||||
if not flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
return
|
||||
|
||||
@@ -21,6 +21,8 @@ from django.db import transaction
|
||||
|
||||
# Shared code for the AWX platform
|
||||
from awx_plugins.interfaces._temporary_private_container_api import CONTAINER_ROOT, get_incontainer_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
# Runner
|
||||
import ansible_runner
|
||||
@@ -87,8 +89,6 @@ from awx.main.utils.common import (
|
||||
from awx.conf.license import get_license
|
||||
from awx.main.utils.handlers import SpecialInventoryHandler
|
||||
from awx.main.utils.update_model import update_model
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Django flags
|
||||
from flags.state import flag_enabled
|
||||
|
||||
@@ -1224,6 +1224,30 @@ def test_custom_credential_type_create(get, post, organization, admin):
|
||||
assert decrypt_field(cred, 'api_token') == 'secret'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_galaxy_create_ok(post, organization, admin):
|
||||
params = {
|
||||
'credential_type': 1,
|
||||
'name': 'Galaxy credential',
|
||||
'inputs': {
|
||||
'url': 'https://galaxy.ansible.com',
|
||||
'token': 'some_galaxy_token',
|
||||
},
|
||||
}
|
||||
galaxy = CredentialType.defaults['galaxy_api_token']()
|
||||
galaxy.save()
|
||||
params['user'] = admin.id
|
||||
params['credential_type'] = galaxy.pk
|
||||
response = post(reverse('api:credential_list'), params, admin)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert Credential.objects.count() == 1
|
||||
cred = Credential.objects.all()[:1].get()
|
||||
assert cred.credential_type == galaxy
|
||||
assert cred.inputs['url'] == 'https://galaxy.ansible.com'
|
||||
assert decrypt_field(cred, 'token') == 'some_galaxy_token'
|
||||
|
||||
|
||||
#
|
||||
# misc xfail conditions
|
||||
#
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import Organization
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestImmutableSharedFields:
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_settings(self, settings):
|
||||
settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT = False
|
||||
|
||||
def test_create_raises_permission_denied(self, admin_user, post):
|
||||
orgA = Organization.objects.create(name='orgA')
|
||||
resp = post(
|
||||
url=reverse('api:team_list'),
|
||||
data={'name': 'teamA', 'organization': orgA.id},
|
||||
user=admin_user,
|
||||
expect=403,
|
||||
)
|
||||
assert "Creation of this resource is not allowed" in resp.data['detail']
|
||||
|
||||
def test_perform_delete_raises_permission_denied(self, admin_user, delete):
|
||||
orgA = Organization.objects.create(name='orgA')
|
||||
team = orgA.teams.create(name='teamA')
|
||||
resp = delete(
|
||||
url=reverse('api:team_detail', kwargs={'pk': team.id}),
|
||||
user=admin_user,
|
||||
expect=403,
|
||||
)
|
||||
assert "Deletion of this resource is not allowed" in resp.data['detail']
|
||||
|
||||
def test_perform_update(self, admin_user, patch):
|
||||
orgA = Organization.objects.create(name='orgA')
|
||||
# allow patching non-shared fields
|
||||
patch(
|
||||
url=reverse('api:organization_detail', kwargs={'pk': orgA.id}),
|
||||
data={"max_hosts": 76},
|
||||
user=admin_user,
|
||||
expect=200,
|
||||
)
|
||||
# prevent patching shared fields
|
||||
resp = patch(url=reverse('api:organization_detail', kwargs={'pk': orgA.id}), data={"name": "orgB"}, user=admin_user, expect=403)
|
||||
assert "Cannot change shared field" in resp.data['name']
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'role',
|
||||
['admin_role', 'member_role'],
|
||||
)
|
||||
@pytest.mark.parametrize('resource', ['organization', 'team'])
|
||||
def test_prevent_assigning_member_to_organization_or_team(self, admin_user, post, resource, role):
|
||||
orgA = Organization.objects.create(name='orgA')
|
||||
if resource == 'organization':
|
||||
role = getattr(orgA, role)
|
||||
elif resource == 'team':
|
||||
teamA = orgA.teams.create(name='teamA')
|
||||
role = getattr(teamA, role)
|
||||
resp = post(
|
||||
url=reverse('api:user_roles_list', kwargs={'pk': admin_user.id}),
|
||||
data={'id': role.id},
|
||||
user=admin_user,
|
||||
expect=403,
|
||||
)
|
||||
assert f"Cannot directly modify user membership to {resource}." in resp.data['msg']
|
||||
@@ -1,3 +1,5 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
@@ -5,6 +7,9 @@ from awx.main.models.activity_stream import ActivityStream
|
||||
from awx.main.models.ha import Instance
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.http import HttpResponse
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, node_type='execution', memory=36000000000, cpu_capacity=6, mem_capacity=42)
|
||||
@@ -87,3 +92,11 @@ def test_custom_hostname_regex(post, admin_user):
|
||||
"peers": [],
|
||||
}
|
||||
post(url=url, user=admin_user, data=data, expect=value[1])
|
||||
|
||||
|
||||
def test_instance_install_bundle(get, admin_user, system_auditor):
|
||||
instance = Instance.objects.create(**INSTANCE_KWARGS)
|
||||
url = reverse('api:instance_install_bundle', kwargs={'pk': instance.pk})
|
||||
with mock.patch('awx.api.views.instance_install_bundle.InstanceInstallBundle.get', return_value=HttpResponse({'test': 'data'}, status=status.HTTP_200_OK)):
|
||||
get(url=url, user=admin_user, expect=200)
|
||||
get(url=url, user=system_auditor, expect=403)
|
||||
|
||||
@@ -521,6 +521,20 @@ class TestInventorySourceCredential:
|
||||
patch(url=inv_src.get_absolute_url(), data={'credential': aws_cred.pk}, expect=200, user=admin_user)
|
||||
assert list(inv_src.credentials.values_list('id', flat=True)) == [aws_cred.pk]
|
||||
|
||||
@pytest.mark.skip(reason="Delay until AAP-53978 completed")
|
||||
def test_vmware_cred_create_esxi_source(self, inventory, admin_user, organization, post, get):
|
||||
"""Test that a vmware esxi source can be added with a vmware credential"""
|
||||
from awx.main.models.credential import Credential, CredentialType
|
||||
|
||||
vmware = CredentialType.defaults['vmware']()
|
||||
vmware.save()
|
||||
vmware_cred = Credential.objects.create(credential_type=vmware, name="bar", organization=organization)
|
||||
inv_src = InventorySource.objects.create(inventory=inventory, name='foobar', source='vmware_esxi')
|
||||
r = post(url=reverse('api:inventory_source_credentials_list', kwargs={'pk': inv_src.pk}), data={'id': vmware_cred.pk}, expect=204, user=admin_user)
|
||||
g = get(inv_src.get_absolute_url(), admin_user)
|
||||
assert r.status_code == 204
|
||||
assert g.data['credential'] == vmware_cred.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestControlledBySCM:
|
||||
|
||||
191
awx/main/tests/functional/api/test_license_cache_clearing.py
Normal file
191
awx/main/tests/functional/api/test_license_cache_clearing.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
# Generated by Cursor (claude-4-sonnet)
|
||||
@pytest.mark.django_db
|
||||
class TestLicenseCacheClearing:
|
||||
"""Test cache clearing for LICENSE setting changes"""
|
||||
|
||||
def test_license_from_manifest_clears_cache(self, admin_user, post):
|
||||
"""Test that posting a manifest to /api/v2/config/ clears the LICENSE cache"""
|
||||
|
||||
# Mock the licenser and clear_setting_cache
|
||||
with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.validate_entitlement_manifest') as mock_validate, patch(
|
||||
'awx.api.views.root.clear_setting_cache'
|
||||
) as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit:
|
||||
|
||||
# Set up mock license data
|
||||
mock_license_data = {'valid_key': True, 'license_type': 'enterprise', 'instance_count': 100, 'subscription_name': 'Test Enterprise License'}
|
||||
|
||||
# Mock the validation and license processing
|
||||
mock_validate.return_value = [{'some': 'manifest_data'}]
|
||||
mock_licenser = MagicMock()
|
||||
mock_licenser.license_from_manifest.return_value = mock_license_data
|
||||
mock_get_licenser.return_value = mock_licenser
|
||||
|
||||
# Prepare the request data (base64 encoded manifest)
|
||||
manifest_data = {'manifest': 'ZmFrZS1tYW5pZmVzdC1kYXRh'} # base64 for "fake-manifest-data"
|
||||
|
||||
# Make the POST request
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = post(url, manifest_data, admin_user, expect=200)
|
||||
|
||||
# Verify the response
|
||||
assert response.data == mock_license_data
|
||||
|
||||
# Verify license_from_manifest was called
|
||||
mock_licenser.license_from_manifest.assert_called_once()
|
||||
|
||||
# Verify on_commit was called (may be multiple times due to other settings)
|
||||
assert mock_on_commit.call_count >= 1
|
||||
|
||||
# Execute all on_commit callbacks to trigger cache clearing
|
||||
for call_args in mock_on_commit.call_args_list:
|
||||
callback = call_args[0][0]
|
||||
callback()
|
||||
|
||||
# Verify that clear_setting_cache.delay was called with ['LICENSE']
|
||||
mock_clear_cache.delay.assert_any_call(['LICENSE'])
|
||||
|
||||
def test_config_delete_clears_cache(self, admin_user, delete):
|
||||
"""Test that DELETE /api/v2/config/ clears the LICENSE cache"""
|
||||
|
||||
with patch('awx.api.views.root.clear_setting_cache') as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit:
|
||||
|
||||
# Make the DELETE request
|
||||
url = reverse('api:api_v2_config_view')
|
||||
delete(url, admin_user, expect=204)
|
||||
|
||||
# Verify on_commit was called at least once
|
||||
assert mock_on_commit.call_count >= 1
|
||||
|
||||
# Execute all on_commit callbacks to trigger cache clearing
|
||||
for call_args in mock_on_commit.call_args_list:
|
||||
callback = call_args[0][0]
|
||||
callback()
|
||||
|
||||
mock_clear_cache.delay.assert_called_once_with(['LICENSE'])
|
||||
|
||||
def test_attach_view_clears_cache(self, admin_user, post):
|
||||
"""Test that posting to /api/v2/config/attach/ clears the LICENSE cache"""
|
||||
|
||||
with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.clear_setting_cache') as mock_clear_cache, patch(
|
||||
'django.db.connection.on_commit'
|
||||
) as mock_on_commit, patch('awx.api.views.root.settings') as mock_settings:
|
||||
|
||||
# Set up subscription credentials in settings
|
||||
mock_settings.SUBSCRIPTIONS_CLIENT_ID = 'test-client-id'
|
||||
mock_settings.SUBSCRIPTIONS_CLIENT_SECRET = 'test-client-secret'
|
||||
|
||||
# Set up mock licenser with validated subscriptions
|
||||
mock_licenser = MagicMock()
|
||||
subscription_data = {'subscription_id': 'test-subscription-123', 'valid_key': False, 'license_type': 'enterprise', 'instance_count': 50}
|
||||
mock_licenser.validate_rh.return_value = [subscription_data]
|
||||
mock_get_licenser.return_value = mock_licenser
|
||||
|
||||
# Prepare request data
|
||||
request_data = {'subscription_id': 'test-subscription-123'}
|
||||
|
||||
# Make the POST request
|
||||
url = reverse('api:api_v2_attach_view')
|
||||
response = post(url, request_data, admin_user, expect=200)
|
||||
|
||||
# Verify the response includes valid_key=True
|
||||
assert response.data['valid_key'] is True
|
||||
assert response.data['subscription_id'] == 'test-subscription-123'
|
||||
|
||||
# Verify settings.LICENSE was set
|
||||
expected_license = subscription_data.copy()
|
||||
expected_license['valid_key'] = True
|
||||
assert mock_settings.LICENSE == expected_license
|
||||
|
||||
# Verify cache clearing was scheduled
|
||||
mock_on_commit.assert_called_once()
|
||||
call_args = mock_on_commit.call_args[0][0] # Get the lambda function
|
||||
|
||||
# Execute the lambda to verify it calls clear_setting_cache
|
||||
call_args()
|
||||
mock_clear_cache.delay.assert_called_once_with(['LICENSE'])
|
||||
|
||||
def test_attach_view_subscription_not_found_no_cache_clear(self, admin_user, post):
|
||||
"""Test that attach view doesn't clear cache when subscription is not found"""
|
||||
|
||||
with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.clear_setting_cache') as mock_clear_cache, patch(
|
||||
'django.db.connection.on_commit'
|
||||
) as mock_on_commit:
|
||||
|
||||
# Set up mock licenser with different subscription
|
||||
mock_licenser = MagicMock()
|
||||
subscription_data = {'subscription_id': 'different-subscription-456', 'valid_key': False, 'license_type': 'enterprise'} # Different ID
|
||||
mock_licenser.validate_rh.return_value = [subscription_data]
|
||||
mock_get_licenser.return_value = mock_licenser
|
||||
|
||||
# Request data with non-matching subscription ID
|
||||
request_data = {
|
||||
'subscription_id': 'test-subscription-123', # This won't match
|
||||
}
|
||||
|
||||
# Make the POST request
|
||||
url = reverse('api:api_v2_attach_view')
|
||||
response = post(url, request_data, admin_user, expect=400)
|
||||
|
||||
# Verify error response
|
||||
assert 'error' in response.data
|
||||
|
||||
# Verify cache clearing was NOT called (no matching subscription)
|
||||
mock_on_commit.assert_not_called()
|
||||
mock_clear_cache.delay.assert_not_called()
|
||||
|
||||
def test_manifest_validation_error_no_cache_clear(self, admin_user, post):
|
||||
"""Test that config view doesn't clear cache when manifest validation fails"""
|
||||
|
||||
with patch('awx.api.views.root.validate_entitlement_manifest') as mock_validate, patch(
|
||||
'awx.api.views.root.clear_setting_cache'
|
||||
) as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit:
|
||||
|
||||
# Mock validation to raise ValueError
|
||||
mock_validate.side_effect = ValueError("Invalid manifest")
|
||||
|
||||
# Prepare request data
|
||||
manifest_data = {'manifest': 'aW52YWxpZC1tYW5pZmVzdA=='} # base64 for "invalid-manifest"
|
||||
|
||||
# Make the POST request
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = post(url, manifest_data, admin_user, expect=400)
|
||||
|
||||
# Verify error response
|
||||
assert response.data['error'] == 'Invalid manifest'
|
||||
|
||||
# Verify cache clearing was NOT called (validation failed)
|
||||
mock_on_commit.assert_not_called()
|
||||
mock_clear_cache.delay.assert_not_called()
|
||||
|
||||
def test_license_processing_error_no_cache_clear(self, admin_user, post):
|
||||
"""Test that config view doesn't clear cache when license processing fails"""
|
||||
|
||||
with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.validate_entitlement_manifest') as mock_validate, patch(
|
||||
'awx.api.views.root.clear_setting_cache'
|
||||
) as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit:
|
||||
|
||||
# Mock validation to succeed but license processing to fail
|
||||
mock_validate.return_value = [{'some': 'manifest_data'}]
|
||||
mock_licenser = MagicMock()
|
||||
mock_licenser.license_from_manifest.side_effect = Exception("License processing failed")
|
||||
mock_get_licenser.return_value = mock_licenser
|
||||
|
||||
# Prepare request data
|
||||
manifest_data = {'manifest': 'ZmFrZS1tYW5pZmVzdA=='} # base64 for "fake-manifest"
|
||||
|
||||
# Make the POST request
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = post(url, manifest_data, admin_user, expect=400)
|
||||
|
||||
# Verify error response
|
||||
assert response.data['error'] == 'Invalid License'
|
||||
|
||||
# Verify cache clearing was NOT called (license processing failed)
|
||||
mock_on_commit.assert_not_called()
|
||||
mock_clear_cache.delay.assert_not_called()
|
||||
@@ -5,10 +5,6 @@ import pytest
|
||||
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test.utils import override_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from ansible_base.lib.utils.response import get_relative_url
|
||||
from ansible_base.lib.testing.fixtures import settings_override_mutable # NOQA: F401 imported to be a pytest fixture
|
||||
|
||||
from awx.main.models import User
|
||||
from awx.api.versioning import reverse
|
||||
@@ -21,33 +17,6 @@ from awx.api.versioning import reverse
|
||||
EXAMPLE_USER_DATA = {"username": "affable", "first_name": "a", "last_name": "a", "email": "a@a.com", "is_superuser": False, "password": "r$TyKiOCb#ED"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_local_user(post, admin_user, settings, settings_override_mutable): # NOQA: F811 this is how you use a pytest fixture
|
||||
"Copy of the test by same name in django-ansible-base for integration and compatibility testing"
|
||||
url = get_relative_url('validate-local-account')
|
||||
admin_user.set_password('password')
|
||||
admin_user.save()
|
||||
data = {
|
||||
"username": admin_user.username,
|
||||
"password": "password",
|
||||
}
|
||||
with override_settings(RESOURCE_SERVER={"URL": "https://foo.invalid", "SECRET_KEY": "foobar"}):
|
||||
response = post(url=url, data=data, user=AnonymousUser(), expect=200)
|
||||
|
||||
assert 'ansible_id' in response.data
|
||||
assert response.data['auth_code'] is not None, response.data
|
||||
|
||||
# No resource server, return coherent response but can not provide auth code
|
||||
response = post(url=url, data=data, user=AnonymousUser(), expect=200)
|
||||
assert 'ansible_id' in response.data
|
||||
assert response.data['auth_code'] is None
|
||||
|
||||
# wrong password
|
||||
data['password'] = 'foobar'
|
||||
response = post(url=url, data=data, user=AnonymousUser(), expect=401)
|
||||
# response.data may be none here, this is just testing that we get no server error
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_create(post, admin):
|
||||
response = post(reverse('api:user_list'), EXAMPLE_USER_DATA, admin, middleware=SessionMiddleware(mock.Mock()))
|
||||
@@ -289,3 +258,19 @@ def test_user_verify_attribute_created(admin, get):
|
||||
for op, count in (('gt', 1), ('lt', 0)):
|
||||
resp = get(reverse('api:user_list') + f'?created__{op}={past}', admin)
|
||||
assert resp.data['count'] == count
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_org_not_shown_in_admin_user_sublists(admin_user, get, organization):
|
||||
for view_name in ('user_admin_of_organizations_list', 'user_organizations_list'):
|
||||
url = reverse(f'api:{view_name}', kwargs={'pk': admin_user.pk})
|
||||
r = get(url, user=admin_user, expect=200)
|
||||
assert organization.pk not in [org['id'] for org in r.data['results']]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_user_not_shown_in_org_users(admin_user, get, organization):
|
||||
for view_name in ('organization_users_list', 'organization_admins_list'):
|
||||
url = reverse(f'api:{view_name}', kwargs={'pk': organization.pk})
|
||||
r = get(url, user=admin_user, expect=200)
|
||||
assert admin_user.pk not in [u['id'] for u in r.data['results']]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
# Python
|
||||
import pytest
|
||||
from unittest import mock
|
||||
@@ -8,7 +10,7 @@ import importlib
|
||||
# Django
|
||||
from django.urls import resolve
|
||||
from django.http import Http404
|
||||
from django.apps import apps
|
||||
from django.apps import apps as global_apps
|
||||
from django.core.handlers.exception import response_for_exception
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@@ -47,6 +49,8 @@ from awx.main.models.ad_hoc_commands import AdHocCommand
|
||||
from awx.main.models.execution_environments import ExecutionEnvironment
|
||||
from awx.main.utils import is_testing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__SWAGGER_REQUESTS__ = {}
|
||||
|
||||
|
||||
@@ -54,8 +58,17 @@ __SWAGGER_REQUESTS__ = {}
|
||||
dab_rr_initial = importlib.import_module('ansible_base.resource_registry.migrations.0001_initial')
|
||||
|
||||
|
||||
def create_service_id(app_config, apps=global_apps, **kwargs):
|
||||
try:
|
||||
apps.get_model("dab_resource_registry", "ServiceID")
|
||||
except LookupError:
|
||||
logger.info('Looks like reverse migration, not creating resource registry ServiceID')
|
||||
return
|
||||
dab_rr_initial.create_service_id(apps, None)
|
||||
|
||||
|
||||
if is_testing():
|
||||
post_migrate.connect(lambda **kwargs: dab_rr_initial.create_service_id(apps, None))
|
||||
post_migrate.connect(create_service_id)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -126,7 +139,7 @@ def execution_environment():
|
||||
@pytest.fixture
|
||||
def setup_managed_roles():
|
||||
"Run the migration script to pre-create managed role definitions"
|
||||
setup_managed_role_definitions(apps, None)
|
||||
setup_managed_role_definitions(global_apps, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
147
awx/main/tests/functional/dab_rbac/test_consolidate_teams.py
Normal file
147
awx/main/tests/functional/dab_rbac/test_consolidate_teams.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import pytest
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.apps import apps
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment, RoleTeamAssignment
|
||||
from ansible_base.rbac.migrations._utils import give_permissions
|
||||
|
||||
from awx.main.models import User, Team
|
||||
from awx.main.migrations._dab_rbac import consolidate_indirect_user_roles
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ANSIBLE_BASE_ALLOW_TEAM_PARENTS=True)
|
||||
def test_consolidate_indirect_user_roles_with_nested_teams(setup_managed_roles, organization):
|
||||
"""
|
||||
Test the consolidate_indirect_user_roles function with a nested team hierarchy.
|
||||
Setup:
|
||||
- Users: A, B, C, D
|
||||
- Teams: E, F, G
|
||||
- Direct assignments: A→(E,F,G), B→E, C→F, D→G
|
||||
- Team hierarchy: F→E (F is member of E), G→F (G is member of F)
|
||||
Expected result after consolidation:
|
||||
- Team E should have users: A, B, C, D (A directly, B directly, C through F, D through G→F)
|
||||
- Team F should have users: A, C, D (A directly, C directly, D through G)
|
||||
- Team G should have users: A, D (A directly, D directly)
|
||||
"""
|
||||
user_a = User.objects.create_user(username='user_a')
|
||||
user_b = User.objects.create_user(username='user_b')
|
||||
user_c = User.objects.create_user(username='user_c')
|
||||
user_d = User.objects.create_user(username='user_d')
|
||||
|
||||
team_e = Team.objects.create(name='Team E', organization=organization)
|
||||
team_f = Team.objects.create(name='Team F', organization=organization)
|
||||
team_g = Team.objects.create(name='Team G', organization=organization)
|
||||
|
||||
# Get role definition and content type for give_permissions
|
||||
team_member_role = RoleDefinition.objects.get(name='Team Member')
|
||||
team_content_type = ContentType.objects.get_for_model(Team)
|
||||
|
||||
# Assign users to teams
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user_a], object_id=team_e.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user_a], object_id=team_f.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user_a], object_id=team_g.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user_b], object_id=team_e.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user_c], object_id=team_f.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user_d], object_id=team_g.id, content_type_id=team_content_type.id)
|
||||
|
||||
# Mirror user assignments in the old RBAC system because signals don't run in tests
|
||||
team_e.member_role.members.add(user_a.id, user_b.id)
|
||||
team_f.member_role.members.add(user_a.id, user_c.id)
|
||||
team_g.member_role.members.add(user_a.id, user_d.id)
|
||||
|
||||
# Setup team-to-team relationships
|
||||
give_permissions(apps=apps, rd=team_member_role, teams=[team_f], object_id=team_e.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, teams=[team_g], object_id=team_f.id, content_type_id=team_content_type.id)
|
||||
|
||||
# Verify initial direct assignments
|
||||
team_e_users_before = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_e.id).values_list('user_id', flat=True))
|
||||
assert team_e_users_before == {user_a.id, user_b.id}
|
||||
team_f_users_before = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_f.id).values_list('user_id', flat=True))
|
||||
assert team_f_users_before == {user_a.id, user_c.id}
|
||||
team_g_users_before = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_g.id).values_list('user_id', flat=True))
|
||||
assert team_g_users_before == {user_a.id, user_d.id}
|
||||
|
||||
# Verify team-to-team relationships exist
|
||||
assert RoleTeamAssignment.objects.filter(role_definition=team_member_role, team=team_f, object_id=team_e.id).exists()
|
||||
assert RoleTeamAssignment.objects.filter(role_definition=team_member_role, team=team_g, object_id=team_f.id).exists()
|
||||
|
||||
# Run the consolidation function
|
||||
consolidate_indirect_user_roles(apps, None)
|
||||
|
||||
# Verify consolidation
|
||||
team_e_users_after = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_e.id).values_list('user_id', flat=True))
|
||||
assert team_e_users_after == {user_a.id, user_b.id, user_c.id, user_d.id}, f"Team E should have users A, B, C, D but has {team_e_users_after}"
|
||||
team_f_users_after = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_f.id).values_list('user_id', flat=True))
|
||||
assert team_f_users_after == {user_a.id, user_c.id, user_d.id}, f"Team F should have users A, C, D but has {team_f_users_after}"
|
||||
team_g_users_after = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_g.id).values_list('user_id', flat=True))
|
||||
assert team_g_users_after == {user_a.id, user_d.id}, f"Team G should have users A, D but has {team_g_users_after}"
|
||||
|
||||
# Verify team member changes are mirrored to the old RBAC system
|
||||
assert team_e_users_after == set(team_e.member_role.members.all().values_list('id', flat=True))
|
||||
assert team_f_users_after == set(team_f.member_role.members.all().values_list('id', flat=True))
|
||||
assert team_g_users_after == set(team_g.member_role.members.all().values_list('id', flat=True))
|
||||
|
||||
# Verify team-to-team relationships are removed after consolidation
|
||||
assert not RoleTeamAssignment.objects.filter(
|
||||
role_definition=team_member_role, team=team_f, object_id=team_e.id
|
||||
).exists(), "Team-to-team relationship F→E should be removed"
|
||||
assert not RoleTeamAssignment.objects.filter(
|
||||
role_definition=team_member_role, team=team_g, object_id=team_f.id
|
||||
).exists(), "Team-to-team relationship G→F should be removed"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ANSIBLE_BASE_ALLOW_TEAM_PARENTS=True)
|
||||
def test_consolidate_indirect_user_roles_no_team_relationships(setup_managed_roles, organization):
|
||||
"""
|
||||
Test that the function handles the case where there are no team-to-team relationships.
|
||||
It should return early without making any changes.
|
||||
"""
|
||||
# Create a user and team with direct assignment
|
||||
user = User.objects.create_user(username='test_user')
|
||||
team = Team.objects.create(name='Test Team', organization=organization)
|
||||
|
||||
team_member_role = RoleDefinition.objects.get(name='Team Member')
|
||||
team_content_type = ContentType.objects.get_for_model(Team)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user], object_id=team.id, content_type_id=team_content_type.id)
|
||||
|
||||
# Compare count of assignments before and after consolidation
|
||||
assignments_before = RoleUserAssignment.objects.filter(role_definition=team_member_role).count()
|
||||
consolidate_indirect_user_roles(apps, None)
|
||||
assignments_after = RoleUserAssignment.objects.filter(role_definition=team_member_role).count()
|
||||
|
||||
assert assignments_before == assignments_after, "Number of assignments should not change when there are no team-to-team relationships"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ANSIBLE_BASE_ALLOW_TEAM_PARENTS=True)
|
||||
def test_consolidate_indirect_user_roles_circular_reference(setup_managed_roles, organization):
|
||||
"""
|
||||
Test that the function handles circular team references without infinite recursion.
|
||||
"""
|
||||
team_a = Team.objects.create(name='Team A', organization=organization)
|
||||
team_b = Team.objects.create(name='Team B', organization=organization)
|
||||
|
||||
# Create a user assigned to team A
|
||||
user = User.objects.create_user(username='test_user')
|
||||
|
||||
team_member_role = RoleDefinition.objects.get(name='Team Member')
|
||||
team_content_type = ContentType.objects.get_for_model(Team)
|
||||
give_permissions(apps=apps, rd=team_member_role, users=[user], object_id=team_a.id, content_type_id=team_content_type.id)
|
||||
|
||||
# Create circular team relationships: A → B → A
|
||||
give_permissions(apps=apps, rd=team_member_role, teams=[team_b], object_id=team_a.id, content_type_id=team_content_type.id)
|
||||
give_permissions(apps=apps, rd=team_member_role, teams=[team_a], object_id=team_b.id, content_type_id=team_content_type.id)
|
||||
|
||||
# Run the consolidation function - should not raise an exception
|
||||
consolidate_indirect_user_roles(apps, None)
|
||||
|
||||
# Both teams should have the user assigned
|
||||
team_a_users = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_a.id).values_list('user_id', flat=True))
|
||||
team_b_users = set(RoleUserAssignment.objects.filter(role_definition=team_member_role, object_id=team_b.id).values_list('user_id', flat=True))
|
||||
|
||||
assert user.id in team_a_users, "User should be assigned to team A"
|
||||
assert user.id in team_b_users, "User should be assigned to team B"
|
||||
@@ -1,6 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse as django_reverse
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
@@ -8,13 +7,14 @@ from awx.main.models import JobTemplate, Inventory, Organization
|
||||
from awx.main.access import JobTemplateAccess, WorkflowJobTemplateAccess
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
from ansible_base.rbac import permission_registry
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_managed_roles_created(setup_managed_roles):
|
||||
"Managed RoleDefinitions are created in post_migration signal, we expect to see them here"
|
||||
for cls in (JobTemplate, Inventory):
|
||||
ct = ContentType.objects.get_for_model(cls)
|
||||
ct = permission_registry.content_type_model.objects.get_for_model(cls)
|
||||
rds = list(RoleDefinition.objects.filter(content_type=ct))
|
||||
assert len(rds) > 1
|
||||
assert f'{cls.__name__} Admin' in [rd.name for rd in rds]
|
||||
@@ -26,17 +26,20 @@ def test_managed_roles_created(setup_managed_roles):
|
||||
def test_custom_read_role(admin_user, post, setup_managed_roles):
|
||||
rd_url = django_reverse('roledefinition-list')
|
||||
resp = post(
|
||||
url=rd_url, data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['view_inventory']}, user=admin_user, expect=201
|
||||
url=rd_url,
|
||||
data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['awx.view_inventory']},
|
||||
user=admin_user,
|
||||
expect=201,
|
||||
)
|
||||
rd_id = resp.data['id']
|
||||
rd = RoleDefinition.objects.get(id=rd_id)
|
||||
assert rd.content_type == ContentType.objects.get_for_model(Inventory)
|
||||
assert rd.content_type == permission_registry.content_type_model.objects.get_for_model(Inventory)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_system_roles_prohibited(admin_user, post):
|
||||
rd_url = django_reverse('roledefinition-list')
|
||||
resp = post(url=rd_url, data={"name": "read role made for test", "content_type": None, "permissions": ['view_inventory']}, user=admin_user, expect=400)
|
||||
resp = post(url=rd_url, data={"name": "read role made for test", "content_type": None, "permissions": ['awx.view_inventory']}, user=admin_user, expect=400)
|
||||
assert 'System-wide roles are not enabled' in str(resp.data)
|
||||
|
||||
|
||||
@@ -71,7 +74,7 @@ def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch):
|
||||
rd, _ = RoleDefinition.objects.get_or_create(
|
||||
name='inventory-delete',
|
||||
permissions=['delete_inventory', 'view_inventory', 'change_inventory'],
|
||||
content_type=ContentType.objects.get_for_model(Inventory),
|
||||
content_type=permission_registry.content_type_model.objects.get_for_model(Inventory),
|
||||
)
|
||||
rd.give_permission(rando, inventory)
|
||||
inv_id = inventory.pk
|
||||
@@ -85,7 +88,9 @@ def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch):
|
||||
@pytest.mark.django_db
|
||||
def test_assign_custom_add_role(admin_user, rando, organization, post, setup_managed_roles):
|
||||
rd, _ = RoleDefinition.objects.get_or_create(
|
||||
name='inventory-add', permissions=['add_inventory', 'view_organization'], content_type=ContentType.objects.get_for_model(Organization)
|
||||
name='inventory-add',
|
||||
permissions=['add_inventory', 'view_organization'],
|
||||
content_type=permission_registry.content_type_model.objects.get_for_model(Organization),
|
||||
)
|
||||
rd.give_permission(rando, organization)
|
||||
url = reverse('api:inventory_list')
|
||||
@@ -146,14 +151,6 @@ def test_assign_credential_to_user_of_another_org(setup_managed_roles, credentia
|
||||
post(url=url, data={"user": org_admin.id, "role_definition": rd.id, "object_id": credential.id}, user=admin_user, expect=201)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_member_role_not_assignable(team, rando, post, admin_user, setup_managed_roles):
|
||||
member_rd = RoleDefinition.objects.get(name='Organization Member')
|
||||
url = django_reverse('roleuserassignment-list')
|
||||
r = post(url, data={'object_id': team.id, 'role_definition': member_rd.id, 'user': rando.id}, user=admin_user, expect=400)
|
||||
assert 'Not managed locally' in str(r.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_adding_user_to_org_member_role(setup_managed_roles, organization, admin, bob, post, get):
|
||||
'''
|
||||
@@ -173,10 +170,17 @@ def test_adding_user_to_org_member_role(setup_managed_roles, organization, admin
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('actor', ['user', 'team'])
|
||||
@pytest.mark.parametrize('role_name', ['Organization Admin', 'Organization Member', 'Team Admin', 'Team Member'])
|
||||
def test_prevent_adding_actor_to_platform_roles(setup_managed_roles, role_name, actor, organization, team, admin, bob, post):
|
||||
def test_adding_actor_to_platform_roles(setup_managed_roles, role_name, actor, organization, team, admin, bob, post):
|
||||
'''
|
||||
Prevent user or team from being added to platform-level roles
|
||||
Allow user to be added to platform-level roles
|
||||
Exceptions:
|
||||
- Team cannot be added to Organization Member or Admin role
|
||||
- Team cannot be added to Team Admin or Team Member role
|
||||
'''
|
||||
if actor == 'team':
|
||||
expect = 400
|
||||
else:
|
||||
expect = 201
|
||||
rd = RoleDefinition.objects.get(name=role_name)
|
||||
endpoint = 'roleuserassignment-list' if actor == 'user' else 'roleteamassignment-list'
|
||||
url = django_reverse(endpoint)
|
||||
@@ -184,37 +188,9 @@ def test_prevent_adding_actor_to_platform_roles(setup_managed_roles, role_name,
|
||||
data = {'object_id': object_id, 'role_definition': rd.id}
|
||||
actor_id = bob.id if actor == 'user' else team.id
|
||||
data[actor] = actor_id
|
||||
r = post(url, data=data, user=admin, expect=400)
|
||||
assert 'Not managed locally' in str(r.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('role_name', ['Controller Team Admin', 'Controller Team Member'])
|
||||
def test_adding_user_to_controller_team_roles(setup_managed_roles, role_name, team, admin, bob, post, get):
|
||||
'''
|
||||
Allow user to be added to Controller Team Admin or Controller Team Member
|
||||
'''
|
||||
url_detail = reverse('api:team_detail', kwargs={'pk': team.id})
|
||||
get(url_detail, user=bob, expect=403)
|
||||
|
||||
rd = RoleDefinition.objects.get(name=role_name)
|
||||
url = django_reverse('roleuserassignment-list')
|
||||
post(url, data={'object_id': team.id, 'role_definition': rd.id, 'user': bob.id}, user=admin, expect=201)
|
||||
|
||||
get(url_detail, user=bob, expect=200)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('role_name', ['Controller Organization Admin', 'Controller Organization Member'])
|
||||
def test_adding_user_to_controller_organization_roles(setup_managed_roles, role_name, organization, admin, bob, post, get):
|
||||
'''
|
||||
Allow user to be added to Controller Organization Admin or Controller Organization Member
|
||||
'''
|
||||
url_detail = reverse('api:organization_detail', kwargs={'pk': organization.id})
|
||||
get(url_detail, user=bob, expect=403)
|
||||
|
||||
rd = RoleDefinition.objects.get(name=role_name)
|
||||
url = django_reverse('roleuserassignment-list')
|
||||
post(url, data={'object_id': organization.id, 'role_definition': rd.id, 'user': bob.id}, user=admin, expect=201)
|
||||
|
||||
get(url, user=bob, expect=200)
|
||||
r = post(url, data=data, user=admin, expect=expect)
|
||||
if expect == 400:
|
||||
if 'Organization' in role_name:
|
||||
assert 'Assigning organization member permission to teams is not allowed' in str(r.data)
|
||||
if 'Team' in role_name:
|
||||
assert 'Assigning team permissions to other teams is not allowed' in str(r.data)
|
||||
|
||||
@@ -15,6 +15,14 @@ def test_roles_to_not_create(setup_managed_roles):
|
||||
raise Exception(f'Found RoleDefinitions that should not exist: {bad_names}')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_org_admin_role(setup_managed_roles):
|
||||
rd = RoleDefinition.objects.get(name='Organization Admin')
|
||||
codenames = list(rd.permissions.values_list('codename', flat=True))
|
||||
assert 'view_inventory' in codenames
|
||||
assert 'change_inventory' in codenames
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_update_role(setup_managed_roles):
|
||||
"""Role to allow updating a project on the object-level should exist"""
|
||||
@@ -31,32 +39,18 @@ def test_org_child_add_permission(setup_managed_roles):
|
||||
assert not DABPermission.objects.filter(codename='add_jobtemplate').exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_controller_specific_roles_have_correct_permissions(setup_managed_roles):
|
||||
'''
|
||||
Controller specific roles should have the same permissions as the platform roles
|
||||
e.g. Controller Team Admin should have same permission set as Team Admin
|
||||
'''
|
||||
for rd_name in ['Controller Team Admin', 'Controller Team Member', 'Controller Organization Member', 'Controller Organization Admin']:
|
||||
rd = RoleDefinition.objects.get(name=rd_name)
|
||||
rd_platform = RoleDefinition.objects.get(name=rd_name.split('Controller ')[1])
|
||||
assert set(rd.permissions.all()) == set(rd_platform.permissions.all())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('resource_name', ['Team', 'Organization'])
|
||||
@pytest.mark.parametrize('action', ['Member', 'Admin'])
|
||||
def test_legacy_RBAC_uses_controller_specific_roles(setup_managed_roles, resource_name, action, team, bob, organization):
|
||||
def test_legacy_RBAC_uses_platform_roles(setup_managed_roles, resource_name, action, team, bob, organization):
|
||||
'''
|
||||
Assignment to legacy RBAC roles should use controller specific role definitions
|
||||
e.g. Controller Team Admin, Controller Team Member, Controller Organization Member, Controller Organization Admin
|
||||
Assignment to legacy RBAC roles should use platform role definitions
|
||||
e.g. Team Admin, Team Member, Organization Member, Organization Admin
|
||||
'''
|
||||
resource = team if resource_name == 'Team' else organization
|
||||
if action == 'Member':
|
||||
resource.member_role.members.add(bob)
|
||||
else:
|
||||
resource.admin_role.members.add(bob)
|
||||
rd = RoleDefinition.objects.get(name=f'Controller {resource_name} {action}')
|
||||
rd_platform = RoleDefinition.objects.get(name=f'{resource_name} {action}')
|
||||
rd = RoleDefinition.objects.get(name=f'{resource_name} {action}')
|
||||
assert RoleUserAssignment.objects.filter(role_definition=rd, user=bob, object_id=resource.id).exists()
|
||||
assert not RoleUserAssignment.objects.filter(role_definition=rd_platform, user=bob, object_id=resource.id).exists()
|
||||
|
||||
@@ -3,8 +3,6 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from crum import impersonate
|
||||
|
||||
from awx.main.fields import ImplicitRoleField
|
||||
@@ -60,7 +58,7 @@ def test_role_migration_matches(request, model, setup_managed_roles):
|
||||
new_codenames = set(rd.permissions.values_list('codename', flat=True))
|
||||
# all the old roles should map to a non-Compat role definition
|
||||
if 'Compat' not in rd.name:
|
||||
model_rds = RoleDefinition.objects.filter(content_type=ContentType.objects.get_for_model(obj))
|
||||
model_rds = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get_for_model(obj))
|
||||
rd_data = {}
|
||||
for rd in model_rds:
|
||||
rd_data[rd.name] = list(rd.permissions.values_list('codename', flat=True))
|
||||
@@ -76,7 +74,7 @@ def test_role_migration_matches(request, model, setup_managed_roles):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_role_naming(setup_managed_roles):
|
||||
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='dmin')
|
||||
qs = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get(model='jobtemplate'), name__endswith='dmin')
|
||||
assert qs.count() == 1 # sanity
|
||||
rd = qs.first()
|
||||
assert rd.name == 'JobTemplate Admin'
|
||||
@@ -86,7 +84,7 @@ def test_role_naming(setup_managed_roles):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_action_role_naming(setup_managed_roles):
|
||||
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='ecute')
|
||||
qs = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get(model='jobtemplate'), name__endswith='ecute')
|
||||
assert qs.count() == 1 # sanity
|
||||
rd = qs.first()
|
||||
assert rd.name == 'JobTemplate Execute'
|
||||
@@ -98,7 +96,7 @@ def test_action_role_naming(setup_managed_roles):
|
||||
def test_compat_role_naming(setup_managed_roles, job_template, rando, alice):
|
||||
with impersonate(alice):
|
||||
job_template.read_role.members.add(rando)
|
||||
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='ompat')
|
||||
qs = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get(model='jobtemplate'), name__endswith='ompat')
|
||||
assert qs.count() == 1 # sanity
|
||||
rd = qs.first()
|
||||
assert rd.name == 'JobTemplate Read Compat'
|
||||
@@ -175,20 +173,6 @@ def test_creator_permission(rando, admin_user, inventory, setup_managed_roles):
|
||||
assert rando in inventory.admin_role.members.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_team_read_role(rando, team, admin_user, post, setup_managed_roles):
|
||||
orgs = [Organization.objects.create(name=f'foo-{i}') for i in range(2)]
|
||||
teams = [Team.objects.create(name=f'foo-{i}', organization=orgs[i]) for i in range(2)]
|
||||
teams[1].member_role.members.add(rando)
|
||||
|
||||
# give second team read permission to first team through the API for regression testing
|
||||
url = reverse('api:role_teams_list', kwargs={'pk': teams[0].read_role.pk, 'version': 'v2'})
|
||||
post(url, {'id': teams[1].id}, user=admin_user)
|
||||
|
||||
# user should be able to view the first team
|
||||
assert rando in teams[0].read_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_implicit_parents_no_assignments(organization):
|
||||
"""Through the normal course of creating models, we should not be changing DAB RBAC permissions"""
|
||||
@@ -202,25 +186,25 @@ def test_user_auditor_rel(organization, rando, setup_managed_roles):
|
||||
assert rando not in organization.auditor_role
|
||||
audit_rd = RoleDefinition.objects.get(name='Organization Audit')
|
||||
audit_rd.give_permission(rando, organization)
|
||||
assert list(rando.auditor_of_organizations) == [organization]
|
||||
assert list(Organization.access_qs(rando, 'audit')) == [organization]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('resource_name', ['Organization', 'Team'])
|
||||
@pytest.mark.parametrize('role_name', ['Member', 'Admin'])
|
||||
def test_mapping_from_controller_role_definitions_to_roles(organization, team, rando, role_name, resource_name, setup_managed_roles):
|
||||
def test_mapping_from_role_definitions_to_roles(organization, team, rando, role_name, resource_name, setup_managed_roles):
|
||||
"""
|
||||
ensure mappings for controller roles are correct
|
||||
ensure mappings for platform roles are correct
|
||||
e.g.
|
||||
Controller Organization Member > organization.member_role
|
||||
Controller Organization Admin > organization.admin_role
|
||||
Controller Team Member > team.member_role
|
||||
Controller Team Admin > team.admin_role
|
||||
Organization Member > organization.member_role
|
||||
Organization Admin > organization.admin_role
|
||||
Team Member > team.member_role
|
||||
Team Admin > team.admin_role
|
||||
"""
|
||||
resource = organization if resource_name == 'Organization' else team
|
||||
old_role_name = f"{role_name.lower()}_role"
|
||||
getattr(resource, old_role_name).members.add(rando)
|
||||
assignment = RoleUserAssignment.objects.get(user=rando)
|
||||
assert assignment.role_definition.name == f'Controller {resource_name} {role_name}'
|
||||
assert assignment.role_definition.name == f'{resource_name} {role_name}'
|
||||
old_role = get_role_from_object_role(assignment.object_role)
|
||||
assert old_role.id == getattr(resource, old_role_name).id
|
||||
|
||||
@@ -35,21 +35,21 @@ class TestNewToOld:
|
||||
|
||||
def test_new_to_old_rbac_team_member_addition(self, admin, post, team, bob, setup_managed_roles):
|
||||
'''
|
||||
Assign user to Controller Team Member role definition, should be added to team.member_role.members
|
||||
Assign user to Team Member role definition, should be added to team.member_role.members
|
||||
'''
|
||||
rd = RoleDefinition.objects.get(name='Controller Team Member')
|
||||
rd = RoleDefinition.objects.get(name='Team Member')
|
||||
|
||||
url = get_relative_url('roleuserassignment-list')
|
||||
post(url, user=admin, data={'role_definition': rd.id, 'user': bob.id, 'object_id': team.id}, expect=201)
|
||||
assert bob in team.member_role.members.all()
|
||||
|
||||
def test_new_to_old_rbac_team_member_removal(self, admin, delete, team, bob):
|
||||
def test_new_to_old_rbac_team_member_removal(self, admin, delete, team, bob, setup_managed_roles):
|
||||
'''
|
||||
Remove user from Controller Team Member role definition, should be deleted from team.member_role.members
|
||||
Remove user from Team Member role definition, should be deleted from team.member_role.members
|
||||
'''
|
||||
team.member_role.members.add(bob)
|
||||
|
||||
rd = RoleDefinition.objects.get(name='Controller Team Member')
|
||||
rd = RoleDefinition.objects.get(name='Team Member')
|
||||
user_assignment = RoleUserAssignment.objects.get(user=bob, role_definition=rd, object_id=team.id)
|
||||
|
||||
url = get_relative_url('roleuserassignment-detail', kwargs={'pk': user_assignment.id})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from awx.main.access import ExecutionEnvironmentAccess
|
||||
from awx.main.models import ExecutionEnvironment, Organization, Team
|
||||
from awx.main.models.rbac import get_role_codenames
|
||||
@@ -10,6 +8,7 @@ from awx.api.versioning import reverse
|
||||
from django.urls import reverse as django_reverse
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
from ansible_base.rbac import permission_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -17,7 +16,7 @@ def ee_rd():
|
||||
return RoleDefinition.objects.create_from_permissions(
|
||||
name='EE object admin',
|
||||
permissions=['change_executionenvironment', 'delete_executionenvironment'],
|
||||
content_type=ContentType.objects.get_for_model(ExecutionEnvironment),
|
||||
content_type=permission_registry.content_type_model.objects.get_for_model(ExecutionEnvironment),
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +25,7 @@ def org_ee_rd():
|
||||
return RoleDefinition.objects.create_from_permissions(
|
||||
name='EE org admin',
|
||||
permissions=['add_executionenvironment', 'change_executionenvironment', 'delete_executionenvironment', 'view_organization'],
|
||||
content_type=ContentType.objects.get_for_model(Organization),
|
||||
content_type=permission_registry.content_type_model.objects.get_for_model(Organization),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -50,13 +50,11 @@ def test_org_factory_roles(organization_factory):
|
||||
teams=['team1', 'team2'],
|
||||
users=['team1:foo', 'bar'],
|
||||
projects=['baz', 'bang'],
|
||||
roles=['team2.member_role:foo', 'team1.admin_role:bar', 'team1.member_role:team2.admin_role', 'baz.admin_role:foo'],
|
||||
roles=['team2.member_role:foo', 'team1.admin_role:bar', 'baz.admin_role:foo'],
|
||||
)
|
||||
|
||||
assert objects.users.bar in objects.teams.team2.admin_role
|
||||
assert objects.users.bar in objects.teams.team1.admin_role
|
||||
assert objects.users.foo in objects.projects.baz.admin_role
|
||||
assert objects.users.foo in objects.teams.team1.member_role
|
||||
assert objects.teams.team2.admin_role in objects.teams.team1.member_role.children.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -49,7 +49,6 @@ def credential_kind(source):
|
||||
"""Given the inventory source kind, return expected credential kind"""
|
||||
if source == 'openshift_virtualization':
|
||||
return 'kubernetes_bearer_token'
|
||||
|
||||
return source.replace('ec2', 'aws')
|
||||
|
||||
|
||||
@@ -223,6 +222,10 @@ def test_inventory_update_injected_content(product_name, this_kind, inventory, f
|
||||
private_data_dir = envvars.pop('AWX_PRIVATE_DATA_DIR')
|
||||
assert envvars.pop('ANSIBLE_INVENTORY_ENABLED') == 'auto'
|
||||
set_files = bool(os.getenv("MAKE_INVENTORY_REFERENCE_FILES", 'false').lower()[0] not in ['f', '0'])
|
||||
|
||||
# Ensure the directory exists before trying to list/read it
|
||||
os.makedirs(private_data_dir, exist_ok=True)
|
||||
|
||||
env, content = read_content(private_data_dir, envvars, inventory_update)
|
||||
|
||||
# Assert inventory plugin inventory file is in private_data_dir
|
||||
|
||||
@@ -8,7 +8,6 @@ Most tests that live in here can probably be deleted at some point. They are mai
|
||||
for a developer. When AWX versions that users upgrade from falls out of support that
|
||||
is when migration tests can be deleted. This is also a good time to squash. Squashing
|
||||
will likely mess with the tests that live here.
|
||||
|
||||
The smoke test should be kept in here. The smoke test ensures that our migrations
|
||||
continue to work when sqlite is the backing database (vs. the default DB of postgres).
|
||||
"""
|
||||
@@ -19,27 +18,22 @@ class TestMigrationSmoke:
|
||||
def test_happy_path(self, migrator):
|
||||
"""
|
||||
This smoke test runs all the migrations.
|
||||
|
||||
Example of how to use django-test-migration to invoke particular migration(s)
|
||||
while weaving in object creation and assertions.
|
||||
|
||||
Note that this is more than just an example. It is a smoke test because it runs ALL
|
||||
the migrations. Our "normal" unit tests subvert the migrations running because it is slow.
|
||||
"""
|
||||
migration_nodes = all_migrations('default')
|
||||
migration_tuples = nodes_to_tuples(migration_nodes)
|
||||
final_migration = migration_tuples[-1]
|
||||
|
||||
migrator.apply_initial_migration(('main', None))
|
||||
# I just picked a newish migration at the time of writing this.
|
||||
# If someone from the future finds themselves here because the are squashing migrations
|
||||
# it is fine to change the 0180_... below to some other newish migration
|
||||
intermediate_state = migrator.apply_tested_migration(('main', '0180_add_hostmetric_fields'))
|
||||
|
||||
Instance = intermediate_state.apps.get_model('main', 'Instance')
|
||||
# Create any old object in the database
|
||||
Instance.objects.create(hostname='foobar', node_type='control')
|
||||
|
||||
final_state = migrator.apply_tested_migration(final_migration)
|
||||
Instance = final_state.apps.get_model('main', 'Instance')
|
||||
assert Instance.objects.filter(hostname='foobar').count() == 1
|
||||
@@ -52,20 +46,16 @@ class TestMigrationSmoke:
|
||||
foo = Instance.objects.create(hostname='foo', node_type='execution', listener_port=1234)
|
||||
bar = Instance.objects.create(hostname='bar', node_type='execution', listener_port=None)
|
||||
bar.peers.add(foo)
|
||||
|
||||
new_state = migrator.apply_tested_migration(
|
||||
('main', '0189_inbound_hop_nodes'),
|
||||
)
|
||||
Instance = new_state.apps.get_model('main', 'Instance')
|
||||
ReceptorAddress = new_state.apps.get_model('main', 'ReceptorAddress')
|
||||
|
||||
# We can now test how our migration worked, new field is there:
|
||||
assert ReceptorAddress.objects.filter(address='foo', port=1234).count() == 1
|
||||
assert not ReceptorAddress.objects.filter(address='bar').exists()
|
||||
|
||||
bar = Instance.objects.get(hostname='bar')
|
||||
fooaddr = ReceptorAddress.objects.get(address='foo')
|
||||
|
||||
bar_peers = bar.peers.all()
|
||||
assert len(bar_peers) == 1
|
||||
assert fooaddr in bar_peers
|
||||
@@ -75,7 +65,6 @@ class TestMigrationSmoke:
|
||||
Organization = old_state.apps.get_model('main', 'Organization')
|
||||
Team = old_state.apps.get_model('main', 'Team')
|
||||
User = old_state.apps.get_model('auth', 'User')
|
||||
|
||||
org = Organization.objects.create(name='arbitrary-org', created=now(), modified=now())
|
||||
user = User.objects.create(username='random-user')
|
||||
org.read_role.members.add(user)
|
||||
@@ -87,11 +76,10 @@ class TestMigrationSmoke:
|
||||
new_state = migrator.apply_tested_migration(
|
||||
('main', '0192_custom_roles'),
|
||||
)
|
||||
|
||||
RoleUserAssignment = new_state.apps.get_model('dab_rbac', 'RoleUserAssignment')
|
||||
assert RoleUserAssignment.objects.filter(user=user.id, object_id=org.id).exists()
|
||||
assert RoleUserAssignment.objects.filter(user=user.id, role_definition__name='Controller Organization Member', object_id=org.id).exists()
|
||||
assert RoleUserAssignment.objects.filter(user=user.id, role_definition__name='Controller Team Member', object_id=team.id).exists()
|
||||
assert RoleUserAssignment.objects.filter(user=user.id, role_definition__name='Organization Member', object_id=org.id).exists()
|
||||
assert RoleUserAssignment.objects.filter(user=user.id, role_definition__name='Team Member', object_id=team.id).exists()
|
||||
|
||||
# Regression testing for bug that comes from current vs past models mismatch
|
||||
RoleDefinition = new_state.apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
@@ -99,7 +87,6 @@ class TestMigrationSmoke:
|
||||
# Test special cases in managed role creation
|
||||
assert not RoleDefinition.objects.filter(name='Organization Team Admin').exists()
|
||||
assert not RoleDefinition.objects.filter(name='Organization InstanceGroup Admin').exists()
|
||||
|
||||
# Test that a removed EE model permission has been deleted
|
||||
new_state = migrator.apply_tested_migration(
|
||||
('main', '0195_EE_permissions'),
|
||||
@@ -110,21 +97,35 @@ class TestMigrationSmoke:
|
||||
# Test create a Project with a duplicate name
|
||||
Organization = new_state.apps.get_model('main', 'Organization')
|
||||
Project = new_state.apps.get_model('main', 'Project')
|
||||
WorkflowJobTemplate = new_state.apps.get_model('main', 'WorkflowJobTemplate')
|
||||
org = Organization.objects.create(name='duplicate-obj-organization', created=now(), modified=now())
|
||||
proj_ids = []
|
||||
for i in range(3):
|
||||
proj = Project.objects.create(name='duplicate-project-name', organization=org, created=now(), modified=now())
|
||||
proj_ids.append(proj.id)
|
||||
|
||||
# Test create WorkflowJobTemplate with duplicate names
|
||||
wfjt_ids = []
|
||||
for i in range(3):
|
||||
wfjt = WorkflowJobTemplate.objects.create(name='duplicate-workflow-name', organization=org, created=now(), modified=now())
|
||||
wfjt_ids.append(wfjt.id)
|
||||
|
||||
# The uniqueness rules will not apply to InventorySource
|
||||
Inventory = new_state.apps.get_model('main', 'Inventory')
|
||||
InventorySource = new_state.apps.get_model('main', 'InventorySource')
|
||||
inv = Inventory.objects.create(name='migration-test-inv', organization=org, created=now(), modified=now())
|
||||
InventorySource.objects.create(name='migration-test-src', source='file', inventory=inv, organization=org, created=now(), modified=now())
|
||||
|
||||
# Apply migration 0200 which should rename duplicates
|
||||
new_state = migrator.apply_tested_migration(
|
||||
('main', '0200_template_name_constraint'),
|
||||
)
|
||||
|
||||
# Get the models from the new state for verification
|
||||
Project = new_state.apps.get_model('main', 'Project')
|
||||
WorkflowJobTemplate = new_state.apps.get_model('main', 'WorkflowJobTemplate')
|
||||
InventorySource = new_state.apps.get_model('main', 'InventorySource')
|
||||
|
||||
for i, proj_id in enumerate(proj_ids):
|
||||
proj = Project.objects.get(id=proj_id)
|
||||
if i == 0:
|
||||
@@ -133,10 +134,36 @@ class TestMigrationSmoke:
|
||||
assert proj.name != 'duplicate-project-name'
|
||||
assert proj.name.startswith('duplicate-project-name')
|
||||
|
||||
# Verify WorkflowJobTemplate duplicates are renamed
|
||||
for i, wfjt_id in enumerate(wfjt_ids):
|
||||
wfjt = WorkflowJobTemplate.objects.get(id=wfjt_id)
|
||||
if i == 0:
|
||||
assert wfjt.name == 'duplicate-workflow-name'
|
||||
else:
|
||||
assert wfjt.name != 'duplicate-workflow-name'
|
||||
assert wfjt.name.startswith('duplicate-workflow-name')
|
||||
|
||||
# The inventory source had this field set to avoid the constrains
|
||||
InventorySource = new_state.apps.get_model('main', 'InventorySource')
|
||||
inv_src = InventorySource.objects.get(name='migration-test-src')
|
||||
assert inv_src.org_unique is False
|
||||
Project = new_state.apps.get_model('main', 'Project')
|
||||
for proj in Project.objects.all():
|
||||
assert proj.org_unique is True
|
||||
|
||||
# Piggyback test for the new credential types
|
||||
validate_exists = ['GitHub App Installation Access Token Lookup', 'Terraform backend configuration']
|
||||
CredentialType = new_state.apps.get_model('main', 'CredentialType')
|
||||
# simulate an upgrade by deleting existing types with these names
|
||||
for expected_name in validate_exists:
|
||||
ct = CredentialType.objects.filter(name=expected_name).first()
|
||||
if ct:
|
||||
ct.delete()
|
||||
|
||||
new_state = migrator.apply_tested_migration(
|
||||
('main', '0201_create_managed_creds'),
|
||||
)
|
||||
|
||||
CredentialType = new_state.apps.get_model('main', 'CredentialType')
|
||||
for expected_name in validate_exists:
|
||||
assert CredentialType.objects.filter(
|
||||
name=expected_name
|
||||
).exists(), f'Could not find {expected_name} credential type name, all names: {list(CredentialType.objects.values_list("name", flat=True))}'
|
||||
|
||||
@@ -334,6 +334,69 @@ def test_team_project_list(get, team_project_list):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_teams_list_multiple_roles_distinct(get, organization_factory):
|
||||
# test projects with multiple roles on the same team
|
||||
objects = organization_factory(
|
||||
'org1',
|
||||
superusers=['admin'],
|
||||
teams=['teamA'],
|
||||
projects=['proj1'],
|
||||
roles=[
|
||||
'teamA.member_role:proj1.admin_role',
|
||||
'teamA.member_role:proj1.use_role',
|
||||
'teamA.member_role:proj1.update_role',
|
||||
'teamA.member_role:proj1.read_role',
|
||||
],
|
||||
)
|
||||
admin = objects.superusers.admin
|
||||
proj1 = objects.projects.proj1
|
||||
|
||||
res = get(reverse('api:project_teams_list', kwargs={'pk': proj1.pk}), admin).data
|
||||
names = [t['name'] for t in res['results']]
|
||||
assert names == ['teamA']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_teams_list_multiple_teams(get, organization_factory):
|
||||
# test projects with multiple teams
|
||||
objs = organization_factory(
|
||||
'org1',
|
||||
superusers=['admin'],
|
||||
teams=['teamA', 'teamB', 'teamC', 'teamD'],
|
||||
projects=['proj1'],
|
||||
roles=[
|
||||
'teamA.member_role:proj1.admin_role',
|
||||
'teamB.member_role:proj1.update_role',
|
||||
'teamC.member_role:proj1.use_role',
|
||||
'teamD.member_role:proj1.read_role',
|
||||
],
|
||||
)
|
||||
admin = objs.superusers.admin
|
||||
proj1 = objs.projects.proj1
|
||||
|
||||
res = get(reverse('api:project_teams_list', kwargs={'pk': proj1.pk}), admin).data
|
||||
names = sorted([t['name'] for t in res['results']])
|
||||
assert names == ['teamA', 'teamB', 'teamC', 'teamD']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_teams_list_no_direct_assignments(get, organization_factory):
|
||||
# test projects with no direct team assignments
|
||||
objects = organization_factory(
|
||||
'org1',
|
||||
superusers=['admin'],
|
||||
teams=['teamA'],
|
||||
projects=['proj1'],
|
||||
roles=[],
|
||||
)
|
||||
admin = objects.superusers.admin
|
||||
proj1 = objects.projects.proj1
|
||||
|
||||
res = get(reverse('api:project_teams_list', kwargs={'pk': proj1.pk}), admin).data
|
||||
assert res['count'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("u,expected_status_code", [('rando', 403), ('org_member', 403), ('org_admin', 201), ('admin', 201)])
|
||||
@pytest.mark.django_db
|
||||
def test_create_project(post, organization, org_admin, org_member, admin, rando, u, expected_status_code):
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import pytest
|
||||
|
||||
from awx.main.tests.live.tests.conftest import wait_for_events, wait_for_job
|
||||
from awx.main.tests.live.tests.conftest import wait_for_events
|
||||
|
||||
from awx.main.models import Job, Inventory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def facts_project(live_tmp_folder, project_factory):
|
||||
return project_factory(scm_url=f'file://{live_tmp_folder}/facts')
|
||||
|
||||
|
||||
def assert_facts_populated(name):
|
||||
job = Job.objects.filter(name__icontains=name).order_by('-created').first()
|
||||
assert job is not None
|
||||
wait_for_events(job)
|
||||
wait_for_job(job)
|
||||
|
||||
inventory = job.inventory
|
||||
assert inventory.hosts.count() > 0 # sanity
|
||||
@@ -23,24 +17,24 @@ def assert_facts_populated(name):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def general_facts_test(facts_project, run_job_from_playbook):
|
||||
def general_facts_test(live_tmp_folder, run_job_from_playbook):
|
||||
def _rf(slug, jt_params):
|
||||
jt_params['use_fact_cache'] = True
|
||||
standard_kwargs = dict(jt_params=jt_params)
|
||||
standard_kwargs = dict(scm_url=f'file://{live_tmp_folder}/facts', jt_params=jt_params)
|
||||
|
||||
# GATHER FACTS
|
||||
name = f'test_gather_ansible_facts_{slug}'
|
||||
run_job_from_playbook(name, 'gather.yml', proj=facts_project, **standard_kwargs)
|
||||
run_job_from_playbook(name, 'gather.yml', **standard_kwargs)
|
||||
assert_facts_populated(name)
|
||||
|
||||
# KEEP FACTS
|
||||
name = f'test_clear_ansible_facts_{slug}'
|
||||
run_job_from_playbook(name, 'no_op.yml', proj=facts_project, **standard_kwargs)
|
||||
run_job_from_playbook(name, 'no_op.yml', **standard_kwargs)
|
||||
assert_facts_populated(name)
|
||||
|
||||
# CLEAR FACTS
|
||||
name = f'test_clear_ansible_facts_{slug}'
|
||||
run_job_from_playbook(name, 'clear.yml', proj=facts_project, **standard_kwargs)
|
||||
run_job_from_playbook(name, 'clear.yml', **standard_kwargs)
|
||||
job = Job.objects.filter(name__icontains=name).order_by('-created').first()
|
||||
|
||||
assert job is not None
|
||||
|
||||
@@ -125,9 +125,6 @@ def test_finish_job_fact_cache_clear(hosts, mocker, ref_time, tmpdir):
|
||||
for host in (hosts[0], hosts[2], hosts[3]):
|
||||
assert host.ansible_facts == {"a": 1, "b": 2}
|
||||
assert host.ansible_facts_modified == ref_time
|
||||
|
||||
# Verify facts were cleared for host with deleted cache file
|
||||
assert hosts[1].ansible_facts == {}
|
||||
assert hosts[1].ansible_facts_modified > ref_time
|
||||
|
||||
# Current implementation skips the call entirely if hosts_to_update == []
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_send_messages():
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey")
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='')
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
@@ -43,7 +43,7 @@ def test_send_messages_with_no_verify_ssl():
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", grafana_no_verify_ssl=True)
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', grafana_no_verify_ssl=True)
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
@@ -74,7 +74,7 @@ def test_send_messages_with_dashboardid(dashboardId):
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=dashboardId)
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=dashboardId, panelId='')
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
@@ -97,7 +97,7 @@ def test_send_messages_with_dashboardid(dashboardId):
|
||||
assert sent_messages == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("panelId", [42, 0])
|
||||
@pytest.mark.parametrize("panelId", ['42', '0'])
|
||||
def test_send_messages_with_panelid(panelId):
|
||||
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
|
||||
requests_mock.post.return_value.status_code = 200
|
||||
@@ -105,7 +105,7 @@ def test_send_messages_with_panelid(panelId):
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=None, panelId=panelId)
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId=panelId)
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
@@ -122,7 +122,7 @@ def test_send_messages_with_panelid(panelId):
|
||||
requests_mock.post.assert_called_once_with(
|
||||
'https://example.com/api/annotations',
|
||||
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer testapikey'},
|
||||
json={'text': 'test subject', 'isRegion': True, 'timeEnd': 120000, 'panelId': panelId, 'time': 60000},
|
||||
json={'text': 'test subject', 'isRegion': True, 'timeEnd': 120000, 'panelId': int(panelId), 'time': 60000},
|
||||
verify=True,
|
||||
)
|
||||
assert sent_messages == 1
|
||||
@@ -135,7 +135,7 @@ def test_send_messages_with_bothids():
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=42, panelId=42)
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='42', panelId='42')
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
@@ -158,6 +158,36 @@ def test_send_messages_with_bothids():
|
||||
assert sent_messages == 1
|
||||
|
||||
|
||||
def test_send_messages_with_emptyids():
|
||||
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
|
||||
requests_mock.post.return_value.status_code = 200
|
||||
m = {}
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='')
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
[],
|
||||
[
|
||||
'https://example.com',
|
||||
],
|
||||
)
|
||||
sent_messages = backend.send_messages(
|
||||
[
|
||||
message,
|
||||
]
|
||||
)
|
||||
requests_mock.post.assert_called_once_with(
|
||||
'https://example.com/api/annotations',
|
||||
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer testapikey'},
|
||||
json={'text': 'test subject', 'isRegion': True, 'timeEnd': 120000, 'time': 60000},
|
||||
verify=True,
|
||||
)
|
||||
assert sent_messages == 1
|
||||
|
||||
|
||||
def test_send_messages_with_tags():
|
||||
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
|
||||
requests_mock.post.return_value.status_code = 200
|
||||
@@ -165,7 +195,7 @@ def test_send_messages_with_tags():
|
||||
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=None, panelId=None, annotation_tags=["ansible"])
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', annotation_tags=["ansible"])
|
||||
message = EmailMessage(
|
||||
m['subject'],
|
||||
{"started": m['started'], "finished": m['finished']},
|
||||
|
||||
@@ -249,7 +249,7 @@ class Licenser(object):
|
||||
'GET',
|
||||
host,
|
||||
verify=True,
|
||||
timeout=(5, 20),
|
||||
timeout=(31, 31),
|
||||
)
|
||||
except requests.RequestException:
|
||||
logger.warning("Failed to connect to console.redhat.com using Service Account credentials. Falling back to basic auth.")
|
||||
@@ -258,7 +258,7 @@ class Licenser(object):
|
||||
host,
|
||||
auth=(client_id, client_secret),
|
||||
verify=True,
|
||||
timeout=(5, 20),
|
||||
timeout=(31, 31),
|
||||
)
|
||||
subs.raise_for_status()
|
||||
subs_formatted = []
|
||||
|
||||
@@ -38,7 +38,7 @@ class ActionModule(ActionBase):
|
||||
|
||||
def _obtain_auth_token(self, oidc_endpoint, client_id, client_secret):
|
||||
if oidc_endpoint.endswith('/'):
|
||||
oidc_endpoint = oidc_endpoint.rstrip('/')
|
||||
oidc_endpoint = oidc_endpoint[:-1]
|
||||
main_url = oidc_endpoint + '/.well-known/openid-configuration'
|
||||
response = requests.get(url=main_url, headers={'Accept': 'application/json'})
|
||||
data = {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from ansible_base.resource_registry.registry import ParentResource, ResourceConfig, ServiceAPIConfig, SharedResource
|
||||
from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
from ansible_base.resource_registry.shared_types import RoleDefinitionType
|
||||
|
||||
from awx.main import models
|
||||
|
||||
@@ -19,4 +21,8 @@ RESOURCE_LIST = (
|
||||
shared_resource=SharedResource(serializer=TeamType, is_provider=False),
|
||||
parent_resources=[ParentResource(model=models.Organization, field_name="organization")],
|
||||
),
|
||||
ResourceConfig(
|
||||
RoleDefinition,
|
||||
shared_resource=SharedResource(serializer=RoleDefinitionType, is_provider=False),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -83,7 +83,7 @@ USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, 'ui', 'build'),
|
||||
os.path.join(BASE_DIR, 'ui', 'build', 'static'),
|
||||
os.path.join(BASE_DIR, 'static'),
|
||||
]
|
||||
|
||||
@@ -538,12 +538,9 @@ AWX_ANSIBLE_CALLBACK_PLUGINS = ""
|
||||
# Automatically remove nodes that have missed their heartbeats after some time
|
||||
AWX_AUTO_DEPROVISION_INSTANCES = False
|
||||
|
||||
# If False, do not allow creation of resources that are shared with the platform ingress
|
||||
# e.g. organizations, teams, and users
|
||||
ALLOW_LOCAL_RESOURCE_MANAGEMENT = True
|
||||
|
||||
# If True, allow users to be assigned to roles that were created via JWT
|
||||
ALLOW_LOCAL_ASSIGNING_JWT_ROLES = False
|
||||
ALLOW_LOCAL_ASSIGNING_JWT_ROLES = True
|
||||
|
||||
# Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed'
|
||||
# Note: This setting may be overridden by database settings.
|
||||
@@ -602,6 +599,12 @@ VMWARE_EXCLUDE_EMPTY_GROUPS = True
|
||||
|
||||
VMWARE_VALIDATE_CERTS = False
|
||||
|
||||
# -----------------
|
||||
# -- VMware ESXi --
|
||||
# -----------------
|
||||
# TODO: Verify matches with AAP-53978 solution in awx-plugins
|
||||
VMWARE_ESXI_EXCLUDE_EMPTY_GROUPS = True
|
||||
|
||||
# ---------------------------
|
||||
# -- Google Compute Engine --
|
||||
# ---------------------------
|
||||
@@ -714,7 +717,7 @@ DISABLE_LOCAL_AUTH = False
|
||||
TOWER_URL_BASE = "https://platformhost"
|
||||
|
||||
INSIGHTS_URL_BASE = "https://example.org"
|
||||
INSIGHTS_OIDC_ENDPOINT = "https://sso.example.org"
|
||||
INSIGHTS_OIDC_ENDPOINT = "https://sso.example.org/"
|
||||
INSIGHTS_AGENT_MIME = 'application/example'
|
||||
# See https://github.com/ansible/awx-facts-playbooks
|
||||
INSIGHTS_SYSTEM_ID_FILE = '/etc/redhat-access-insights/machine-id'
|
||||
@@ -1072,6 +1075,7 @@ ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS = True
|
||||
# Currently features are enabled to keep compatibility with old system, except custom roles
|
||||
ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN = False
|
||||
# ANSIBLE_BASE_ALLOW_CUSTOM_ROLES = True
|
||||
ANSIBLE_BASE_ALLOW_TEAM_PARENTS = False
|
||||
ANSIBLE_BASE_ALLOW_CUSTOM_TEAM_ROLES = False
|
||||
ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES = True
|
||||
ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES = False # System auditor has always been restricted to users
|
||||
@@ -1092,6 +1096,9 @@ INDIRECT_HOST_QUERY_FALLBACK_GIVEUP_DAYS = 3
|
||||
# Older records will be cleaned up
|
||||
INDIRECT_HOST_AUDIT_RECORD_MAX_AGE_DAYS = 7
|
||||
|
||||
# setting for Policy as Code feature
|
||||
FEATURE_POLICY_AS_CODE_ENABLED = False
|
||||
|
||||
OPA_HOST = '' # The hostname used to connect to the OPA server. If empty, policy enforcement will be disabled.
|
||||
OPA_PORT = 8181 # The port used to connect to the OPA server. Defaults to 8181.
|
||||
OPA_SSL = False # Enable or disable the use of SSL to connect to the OPA server. Defaults to false.
|
||||
|
||||
@@ -87,7 +87,14 @@ ui/src/webpack: $(UI_DIR)/src/node_modules/webpack
|
||||
## True target for ui/src/webpack.
|
||||
$(UI_DIR)/src/node_modules/webpack:
|
||||
@echo "=== Installing webpack ==="
|
||||
@cd $(UI_DIR)/src && n 18 && npm install webpack
|
||||
@cd $(UI_DIR)/src && \
|
||||
maj=$$(node -p "process.versions.node.split('.')[0]"); \
|
||||
if [ "$$maj" != "18" ]; then \
|
||||
echo "Error: Need Node 18.x; found $$(node -v)" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
npm install webpack
|
||||
|
||||
|
||||
.PHONY: clean/ui
|
||||
## Clean ui
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.conf import settings
|
||||
from django.urls import re_path, include, path
|
||||
|
||||
from ansible_base.lib.dynamic_config.dynamic_urls import api_urls, api_version_urls, root_urls
|
||||
from ansible_base.rbac.service_api.urls import rbac_service_urls
|
||||
|
||||
from ansible_base.resource_registry.urls import urlpatterns as resource_api_urls
|
||||
|
||||
@@ -23,6 +24,7 @@ def get_urlpatterns(prefix=None):
|
||||
|
||||
urlpatterns += [
|
||||
path(f'api{prefix}v2/', include(resource_api_urls)),
|
||||
path(f'api{prefix}v2/', include(rbac_service_urls)),
|
||||
path(f'api{prefix}v2/', include(api_version_urls)),
|
||||
path(f'api{prefix}', include(api_urls)),
|
||||
path('', include(root_urls)),
|
||||
|
||||
@@ -32,7 +32,7 @@ Installing the `tar.gz` involves no special instructions.
|
||||
## Running
|
||||
|
||||
Non-deprecated modules in this collection have no Python requirements, but
|
||||
may require the official [AWX CLI](https://pypi.org/project/awxkit/)
|
||||
may require the AWX CLI
|
||||
in the future. The `DOCUMENTATION` for each module will report this.
|
||||
|
||||
You can specify authentication by host, username, and password.
|
||||
|
||||
@@ -60,7 +60,7 @@ options:
|
||||
- Path to the controller config file.
|
||||
- If provided, the other locations for config files will not be considered.
|
||||
type: path
|
||||
aliases: [tower_config_file]
|
||||
aliases: [ tower_config_file ]
|
||||
|
||||
notes:
|
||||
- If no I(config_file) is provided we will attempt to use the tower-cli library
|
||||
|
||||
@@ -134,9 +134,10 @@ class InventoryModule(BaseInventoryPlugin):
|
||||
'Invalid type for configuration option inventory_id, ' 'not integer, and cannot convert to string: {err}'.format(err=to_native(e))
|
||||
), e)
|
||||
inventory_id = inventory_id.replace('/', '')
|
||||
inventory_url = '/api/v2/inventories/{inv_id}/script/'.format(inv_id=inventory_id)
|
||||
|
||||
inventory = module.get_endpoint(inventory_url, data={'hostvars': '1', 'towervars': '1', 'all': '1'})['json']
|
||||
inventory = module.get_endpoint(
|
||||
'inventories/{inv_id}/script/'.format(inv_id=inventory_id), data={'hostvars': '1', 'towervars': '1', 'all': '1'}
|
||||
)['json']
|
||||
|
||||
# To start with, create all the groups.
|
||||
for group_name in inventory:
|
||||
@@ -169,7 +170,7 @@ class InventoryModule(BaseInventoryPlugin):
|
||||
# Fetch extra variables if told to do so
|
||||
if self.get_option('include_metadata'):
|
||||
|
||||
config_data = module.get_endpoint('/api/v2/config/')['json']
|
||||
config_data = module.get_endpoint('config/')['json']
|
||||
|
||||
server_data = {}
|
||||
server_data['license_type'] = config_data.get('license_info', {}).get('license_type', 'unknown')
|
||||
|
||||
@@ -4,6 +4,7 @@ __metaclass__ = type
|
||||
|
||||
from .controller_api import ControllerModule
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from os import getenv
|
||||
|
||||
try:
|
||||
from awxkit.api.client import Connection
|
||||
@@ -42,7 +43,13 @@ class ControllerAWXKitModule(ControllerModule):
|
||||
if not self.apiV2Ref:
|
||||
if not self.authenticated:
|
||||
self.authenticate()
|
||||
v2_index = get_registered_page('/api/v2/')(self.connection).get()
|
||||
prefix = getenv('CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX', '/api/')
|
||||
if not prefix.startswith('/'):
|
||||
prefix = f"/{prefix}"
|
||||
if not prefix.endswith('/'):
|
||||
prefix = f"{prefix}/"
|
||||
v2_path = f"{prefix}v2/"
|
||||
v2_index = get_registered_page(v2_path)(self.connection).get()
|
||||
self.api_ref = ApiV2(connection=self.connection, **{'json': v2_index})
|
||||
return self.api_ref
|
||||
|
||||
|
||||
@@ -538,7 +538,18 @@ class ControllerAPIModule(ControllerModule):
|
||||
self.fail_json(msg='Invalid authentication credentials for {0} (HTTP 401).'.format(url.path))
|
||||
# Sanity check: Did we get a forbidden response, which means that the user isn't allowed to do this? Report that.
|
||||
elif he.code == 403:
|
||||
self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).".format(url.path, method))
|
||||
# Hack: Tell the customer to use the platform supported collection when interacting with Org, Team, User Controller endpoints
|
||||
err_msg = he.fp.read().decode('utf-8')
|
||||
try:
|
||||
# Defensive coding. Handle json responses and non-json responses
|
||||
err_msg = loads(err_msg)
|
||||
err_msg = err_msg['detail']
|
||||
# JSONDecodeError only available on Python 3.5+
|
||||
except ValueError:
|
||||
pass
|
||||
prepend_msg = " Use the collection ansible.platform to modify resources Organization, User, or Team." if (
|
||||
"this resource via the platform ingress") in err_msg else ""
|
||||
self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).{2}".format(url.path, method, prepend_msg))
|
||||
# Sanity check: Did we get a 404 response?
|
||||
# Requests with primary keys will return a 404 if there is no response, and we want to consistently trap these.
|
||||
elif he.code == 404:
|
||||
|
||||
@@ -67,6 +67,7 @@ EXAMPLES = '''
|
||||
'''
|
||||
|
||||
import base64
|
||||
|
||||
from ..module_utils.controller_api import ControllerAPIModule
|
||||
|
||||
|
||||
@@ -120,11 +121,17 @@ def main():
|
||||
|
||||
# Do the actual install, if we need to
|
||||
if perform_install:
|
||||
json_output['changed'] = True
|
||||
if module.params.get('manifest', None):
|
||||
module.post_endpoint('config', data={'manifest': manifest.decode()})
|
||||
response = module.post_endpoint('config', data={'manifest': manifest.decode()})
|
||||
else:
|
||||
module.post_endpoint('config/attach', data={'subscription_id': module.params.get('subscription_id')})
|
||||
response = module.post_endpoint('config/attach', data={'subscription_id': module.params.get('subscription_id')})
|
||||
|
||||
# Check API response for errors (AAP-44277 fix)
|
||||
if response and response.get('status_code') and response.get('status_code') != 200:
|
||||
error_msg = response.get('json', {}).get('error', 'License operation failed')
|
||||
module.fail_json(msg=error_msg)
|
||||
|
||||
json_output['changed'] = True
|
||||
|
||||
module.exit_json(**json_output)
|
||||
|
||||
|
||||
@@ -344,7 +344,10 @@ def main():
|
||||
|
||||
unified_job_template = module.params.get('unified_job_template')
|
||||
if unified_job_template:
|
||||
new_fields['unified_job_template'] = module.get_one('unified_job_templates', name_or_id=unified_job_template, **{'data': search_fields})['id']
|
||||
ujt = module.get_one('unified_job_templates', name_or_id=unified_job_template, **{'data': search_fields})
|
||||
if ujt is None or 'id' not in ujt:
|
||||
module.fail_json(msg=f'Could not get unified_job_template name_or_id={unified_job_template} search_fields={search_fields}, got {ujt}')
|
||||
new_fields['unified_job_template'] = ujt['id']
|
||||
inventory = module.params.get('inventory')
|
||||
if inventory:
|
||||
new_fields['inventory'] = module.resolve_name_to_id('inventories', inventory)
|
||||
|
||||
@@ -18,6 +18,8 @@ import pytest
|
||||
from ansible.module_utils.six import raise_from
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition, DABPermission
|
||||
from ansible_base.rbac import permission_registry
|
||||
|
||||
from awx.main.tests.conftest import load_all_credentials # noqa: F401; pylint: disable=unused-import
|
||||
from awx.main.tests.functional.conftest import _request
|
||||
from awx.main.tests.functional.conftest import credentialtype_scm, credentialtype_ssh # noqa: F401; pylint: disable=unused-import
|
||||
@@ -37,7 +39,6 @@ from awx.main.models import (
|
||||
)
|
||||
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
HAS_TOWER_CLI = False
|
||||
@@ -115,35 +116,91 @@ def collection_import():
|
||||
return rf
|
||||
|
||||
|
||||
def _process_request_data(kwargs_copy, kwargs):
|
||||
"""Helper to process 'data' in request kwargs."""
|
||||
if 'data' in kwargs:
|
||||
if isinstance(kwargs['data'], dict):
|
||||
kwargs_copy['data'] = kwargs['data']
|
||||
elif kwargs['data'] is None:
|
||||
pass
|
||||
elif isinstance(kwargs['data'], str):
|
||||
kwargs_copy['data'] = json.loads(kwargs['data'])
|
||||
else:
|
||||
raise RuntimeError('Expected data to be dict or str, got {0}, data: {1}'.format(type(kwargs['data']), kwargs['data']))
|
||||
|
||||
|
||||
def _process_request_params(kwargs_copy, kwargs, method):
|
||||
"""Helper to process 'params' in request kwargs."""
|
||||
if 'params' in kwargs and method == 'GET':
|
||||
if not kwargs_copy.get('data'):
|
||||
kwargs_copy['data'] = {}
|
||||
if isinstance(kwargs['params'], dict):
|
||||
kwargs_copy['data'].update(kwargs['params'])
|
||||
elif isinstance(kwargs['params'], list):
|
||||
for k, v in kwargs['params']:
|
||||
kwargs_copy['data'][k] = v
|
||||
|
||||
|
||||
def _get_resource_class(resource_module):
|
||||
"""Helper to determine the Ansible module resource class."""
|
||||
if getattr(resource_module, 'ControllerAWXKitModule', None):
|
||||
return resource_module.ControllerAWXKitModule
|
||||
elif getattr(resource_module, 'ControllerAPIModule', None):
|
||||
return resource_module.ControllerAPIModule
|
||||
else:
|
||||
raise RuntimeError("The module has neither a ControllerAWXKitModule or a ControllerAPIModule")
|
||||
|
||||
|
||||
def _get_tower_cli_mgr(new_request):
|
||||
"""Helper to get the appropriate tower_cli mock context manager."""
|
||||
if HAS_TOWER_CLI:
|
||||
return mock.patch('tower_cli.api.Session.request', new=new_request)
|
||||
elif HAS_AWX_KIT:
|
||||
return mock.patch('awxkit.api.client.requests.Session.request', new=new_request)
|
||||
else:
|
||||
return suppress()
|
||||
|
||||
|
||||
def _run_and_capture_module_output(resource_module, stdout_buffer):
|
||||
"""Helper to run the module and capture its stdout."""
|
||||
try:
|
||||
with redirect_stdout(stdout_buffer):
|
||||
resource_module.main()
|
||||
except SystemExit:
|
||||
pass # A system exit indicates successful execution
|
||||
except Exception:
|
||||
# dump the stdout back to console for debugging
|
||||
print(stdout_buffer.getvalue())
|
||||
raise
|
||||
|
||||
|
||||
def _parse_and_handle_module_result(module_stdout):
|
||||
"""Helper to parse module output and handle exceptions."""
|
||||
try:
|
||||
result = json.loads(module_stdout)
|
||||
except Exception as e:
|
||||
raise_from(Exception('Module did not write valid JSON, error: {0}, stdout:\n{1}'.format(str(e), module_stdout)), e)
|
||||
|
||||
if 'exception' in result:
|
||||
if "ModuleNotFoundError: No module named 'tower_cli'" in result['exception']:
|
||||
pytest.skip('The tower-cli library is needed to run this test, module no longer supported.')
|
||||
raise Exception('Module encountered error:\n{0}'.format(result['exception']))
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_module(request, collection_import):
|
||||
def run_module(request, collection_import, mocker):
|
||||
def rf(module_name, module_params, request_user):
|
||||
|
||||
def new_request(self, method, url, **kwargs):
|
||||
kwargs_copy = kwargs.copy()
|
||||
if 'data' in kwargs:
|
||||
if isinstance(kwargs['data'], dict):
|
||||
kwargs_copy['data'] = kwargs['data']
|
||||
elif kwargs['data'] is None:
|
||||
pass
|
||||
elif isinstance(kwargs['data'], str):
|
||||
kwargs_copy['data'] = json.loads(kwargs['data'])
|
||||
else:
|
||||
raise RuntimeError('Expected data to be dict or str, got {0}, data: {1}'.format(type(kwargs['data']), kwargs['data']))
|
||||
if 'params' in kwargs and method == 'GET':
|
||||
# query params for GET are handled a bit differently by
|
||||
# tower-cli and python requests as opposed to REST framework APIRequestFactory
|
||||
if not kwargs_copy.get('data'):
|
||||
kwargs_copy['data'] = {}
|
||||
if isinstance(kwargs['params'], dict):
|
||||
kwargs_copy['data'].update(kwargs['params'])
|
||||
elif isinstance(kwargs['params'], list):
|
||||
for k, v in kwargs['params']:
|
||||
kwargs_copy['data'][k] = v
|
||||
_process_request_data(kwargs_copy, kwargs)
|
||||
_process_request_params(kwargs_copy, kwargs, method)
|
||||
|
||||
# make request
|
||||
with transaction.atomic():
|
||||
rf = _request(method.lower())
|
||||
django_response = rf(url, user=request_user, expect=None, **kwargs_copy)
|
||||
rf_django = _request(method.lower()) # Renamed rf to avoid conflict with outer rf
|
||||
django_response = rf_django(url, user=request_user, expect=None, **kwargs_copy)
|
||||
|
||||
# requests library response object is different from the Django response, but they are the same concept
|
||||
# this converts the Django response object into a requests response object for consumption
|
||||
@@ -167,58 +224,25 @@ def run_module(request, collection_import):
|
||||
return m
|
||||
|
||||
stdout_buffer = io.StringIO()
|
||||
# Requies specific PYTHONPATH, see docs
|
||||
# Note that a proper Ansiballz explosion of the modules will have an import path like:
|
||||
# ansible_collections.awx.awx.plugins.modules.{}
|
||||
# We should consider supporting that in the future
|
||||
resource_module = collection_import('plugins.modules.{0}'.format(module_name))
|
||||
|
||||
if not isinstance(module_params, dict):
|
||||
raise RuntimeError('Module params must be dict, got {0}'.format(type(module_params)))
|
||||
|
||||
# Ansible params can be passed as an invocation argument or over stdin
|
||||
# this short circuits within the AnsibleModule interface
|
||||
def mock_load_params(self):
|
||||
self.params = module_params
|
||||
|
||||
if getattr(resource_module, 'ControllerAWXKitModule', None):
|
||||
resource_class = resource_module.ControllerAWXKitModule
|
||||
elif getattr(resource_module, 'ControllerAPIModule', None):
|
||||
resource_class = resource_module.ControllerAPIModule
|
||||
else:
|
||||
raise RuntimeError("The module has neither a ControllerAWXKitModule or a ControllerAPIModule")
|
||||
resource_class = _get_resource_class(resource_module)
|
||||
|
||||
with mock.patch.object(resource_class, '_load_params', new=mock_load_params):
|
||||
# Call the test utility (like a mock server) instead of issuing HTTP requests
|
||||
mocker.patch('ansible.module_utils.basic._ANSIBLE_PROFILE', 'legacy')
|
||||
|
||||
with mock.patch('ansible.module_utils.urls.Request.open', new=new_open):
|
||||
if HAS_TOWER_CLI:
|
||||
tower_cli_mgr = mock.patch('tower_cli.api.Session.request', new=new_request)
|
||||
elif HAS_AWX_KIT:
|
||||
tower_cli_mgr = mock.patch('awxkit.api.client.requests.Session.request', new=new_request)
|
||||
else:
|
||||
tower_cli_mgr = suppress()
|
||||
with tower_cli_mgr:
|
||||
try:
|
||||
# Ansible modules return data to the mothership over stdout
|
||||
with redirect_stdout(stdout_buffer):
|
||||
resource_module.main()
|
||||
except SystemExit:
|
||||
pass # A system exit indicates successful execution
|
||||
except Exception:
|
||||
# dump the stdout back to console for debugging
|
||||
print(stdout_buffer.getvalue())
|
||||
raise
|
||||
with _get_tower_cli_mgr(new_request):
|
||||
_run_and_capture_module_output(resource_module, stdout_buffer)
|
||||
|
||||
module_stdout = stdout_buffer.getvalue().strip()
|
||||
try:
|
||||
result = json.loads(module_stdout)
|
||||
except Exception as e:
|
||||
raise_from(Exception('Module did not write valid JSON, error: {0}, stdout:\n{1}'.format(str(e), module_stdout)), e)
|
||||
# A module exception should never be a test expectation
|
||||
if 'exception' in result:
|
||||
if "ModuleNotFoundError: No module named 'tower_cli'" in result['exception']:
|
||||
pytest.skip('The tower-cli library is needed to run this test, module no longer supported.')
|
||||
raise Exception('Module encountered error:\n{0}'.format(result['exception']))
|
||||
result = _parse_and_handle_module_result(module_stdout)
|
||||
return result
|
||||
|
||||
return rf
|
||||
@@ -342,7 +366,7 @@ def notification_template(organization):
|
||||
|
||||
@pytest.fixture
|
||||
def job_template_role_definition():
|
||||
rd = RoleDefinition.objects.create(name='test_view_jt', content_type=ContentType.objects.get_for_model(JobTemplate))
|
||||
rd = RoleDefinition.objects.create(name='test_view_jt', content_type=permission_registry.content_type_model.objects.get_for_model(JobTemplate))
|
||||
permission_codenames = ['view_jobtemplate', 'execute_jobtemplate']
|
||||
permissions = DABPermission.objects.filter(codename__in=permission_codenames)
|
||||
rd.permissions.add(*permissions)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def mock_get_registered_page(prefix):
|
||||
return mock.Mock(return_value=mock.Mock(get=mock.Mock(return_value={'prefix': prefix})))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_prefix, controller_host, expected",
|
||||
[
|
||||
# without CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX env variable
|
||||
[None, "https://localhost", "/api/v2/"],
|
||||
# with CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX env variable
|
||||
["/api/controller/", "https://localhost", "/api/controller/v2/"],
|
||||
["/api/controller", "https://localhost", "/api/controller/v2/"],
|
||||
["api/controller", "https://localhost", "/api/controller/v2/"],
|
||||
["/custom/path/", "https://localhost", "/custom/path/v2/"],
|
||||
],
|
||||
)
|
||||
def test_controller_awxkit_get_api_v2_object(collection_import, env_prefix, controller_host, expected):
|
||||
controller_awxkit_class = collection_import('plugins.module_utils.awxkit').ControllerAWXKitModule
|
||||
controller_awxkit = controller_awxkit_class(argument_spec={}, direct_params=dict(controller_host=controller_host))
|
||||
with mock.patch('plugins.module_utils.awxkit.get_registered_page', mock_get_registered_page):
|
||||
if env_prefix:
|
||||
with mock.patch.dict(os.environ, {"CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX": env_prefix}):
|
||||
api_v2_object = controller_awxkit.get_api_v2_object()
|
||||
else:
|
||||
api_v2_object = controller_awxkit.get_api_v2_object()
|
||||
assert getattr(api_v2_object, 'prefix') == expected
|
||||
@@ -65,7 +65,7 @@ def test_export(run_module, admin_user):
|
||||
all_assets_except_users = {k: v for k, v in assets.items() if k != 'users'}
|
||||
|
||||
for k, v in all_assets_except_users.items():
|
||||
assert v == [], f"Expected resource {k} to be empty. Instead it is {v}"
|
||||
assert v == [] or v is None, f"Expected resource {k} to be empty. Instead it is {v}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
32
awx_collection/test/awx/test_license_subscription.py
Normal file
32
awx_collection/test/awx/test_license_subscription.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_license_invalid_subscription_id_should_fail(run_module, admin_user):
|
||||
"""Test invalid subscription ID returns failure."""
|
||||
result = run_module('license', {'subscription_id': 'invalid-test-12345', 'state': 'present'}, admin_user)
|
||||
|
||||
assert result.get('failed', False)
|
||||
assert 'msg' in result
|
||||
assert 'subscription' in result['msg'].lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_license_invalid_manifest_should_fail(run_module, admin_user):
|
||||
"""Test invalid manifest returns failure."""
|
||||
result = run_module('license', {'manifest': '/nonexistent/test.zip', 'state': 'present'}, admin_user)
|
||||
|
||||
assert result.get('failed', False)
|
||||
assert 'msg' in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_license_state_absent_works(run_module, admin_user):
|
||||
"""Test license removal works."""
|
||||
result = run_module('license', {'state': 'absent'}, admin_user)
|
||||
|
||||
assert not result.get('failed', False)
|
||||
@@ -20,6 +20,7 @@ def test_create_organization(run_module, admin_user):
|
||||
'controller_username': None,
|
||||
'controller_password': None,
|
||||
'validate_certs': None,
|
||||
'aap_token': None,
|
||||
'controller_config_file': None,
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ def test_galaxy_credential_order(run_module, admin_user):
|
||||
'controller_username': None,
|
||||
'controller_password': None,
|
||||
'validate_certs': None,
|
||||
'aap_token': None,
|
||||
'controller_config_file': None,
|
||||
'galaxy_credentials': cred_ids,
|
||||
}
|
||||
@@ -76,6 +78,7 @@ def test_galaxy_credential_order(run_module, admin_user):
|
||||
'controller_username': None,
|
||||
'controller_password': None,
|
||||
'validate_certs': None,
|
||||
'aap_token': None,
|
||||
'controller_config_file': None,
|
||||
'galaxy_credentials': cred_ids,
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
credential: "{{ ssh_cred_name }}"
|
||||
module_name: "Does not exist"
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
command_id: "{{ command.id }}"
|
||||
fail_if_not_running: true
|
||||
register: results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- ansible.builtin.assert:
|
||||
that:
|
||||
@@ -81,7 +81,7 @@
|
||||
command_id: "{{ command.id }}"
|
||||
fail_if_not_running: true
|
||||
register: results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- ansible.builtin.assert:
|
||||
that:
|
||||
@@ -91,7 +91,7 @@
|
||||
awx.awx.ad_hoc_command_cancel:
|
||||
command_id: 9999999999
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- ansible.builtin.assert:
|
||||
that:
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
ad_hoc_command_wait:
|
||||
command_id: "99999999"
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -85,13 +85,13 @@
|
||||
ad_hoc_command_wait:
|
||||
command_id: "{{ command.id }}"
|
||||
timeout: 1
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: wait_results
|
||||
|
||||
# Make sure that we failed and that we have some data in our results
|
||||
- assert:
|
||||
that:
|
||||
- "'Monitoring aborted due to timeout' or 'Timeout waiting for command to finish.' in wait_results.msg"
|
||||
- "('Monitoring of ad hoc command -' in wait_results.msg and 'aborted due to timeout' in wait_results.msg) or ('Timeout waiting for command to finish.' in wait_results.msg)"
|
||||
- "'id' in wait_results"
|
||||
|
||||
- name: Async cancel the long-running command
|
||||
@@ -104,7 +104,7 @@
|
||||
ad_hoc_command_wait:
|
||||
command_id: "{{ command.id }}"
|
||||
register: wait_results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
organization: Default
|
||||
state: absent
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -306,7 +306,7 @@
|
||||
inputs:
|
||||
username: joe
|
||||
ssh_key_data: "{{ ssh_key_data }}"
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
@@ -322,7 +322,7 @@
|
||||
credential_type: Machine
|
||||
inputs:
|
||||
username: joe
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
@@ -811,7 +811,7 @@
|
||||
organization: test-non-existing-org
|
||||
state: present
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
organization: Some Org
|
||||
image: quay.io/ansible/awx-ee
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -161,11 +161,10 @@
|
||||
|
||||
- name: "Find number of hosts in {{ group_name1 }}"
|
||||
set_fact:
|
||||
group1_host_count: "{{ lookup('awx.awx.controller_api', 'groups/{{result.id}}/all_hosts/') |length}}"
|
||||
|
||||
group1_host_count: "{{ lookup('awx.awx.controller_api', 'groups/' + result.id | string + '/all_hosts/') | length }}"
|
||||
- assert:
|
||||
that:
|
||||
- group1_host_count == "3"
|
||||
- group1_host_count == 3
|
||||
|
||||
- name: Delete Group 3
|
||||
group:
|
||||
@@ -209,7 +208,7 @@
|
||||
inventory: test-non-existing-inventory
|
||||
state: present
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -79,13 +79,13 @@
|
||||
- "result is changed"
|
||||
|
||||
- name: Use lookup to check that host was enabled
|
||||
ansible.builtin.set_fact:
|
||||
host_enabled_test: "lookup('awx.awx.controller_api', 'hosts/{{result.id}}/').enabled"
|
||||
set_fact:
|
||||
host_enabled_test: "{{ lookup('awx.awx.controller_api', 'hosts/' + result.id | string + '/').enabled }}"
|
||||
|
||||
- name: Newly created host should have API default value for enabled
|
||||
assert:
|
||||
that:
|
||||
- host_enabled_test
|
||||
- host_enabled_test is true
|
||||
|
||||
- name: Delete a Host
|
||||
host:
|
||||
@@ -105,7 +105,7 @@
|
||||
inventory: test-non-existing-inventory
|
||||
state: present
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
name: "{{ org_name1 }}"
|
||||
type: "organization"
|
||||
register: import_output
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
organization: Default
|
||||
kind: smart
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -187,13 +187,15 @@
|
||||
organization: test-non-existing-org
|
||||
state: present
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- "result is failed"
|
||||
- "result is not changed"
|
||||
- "'test-non-existing-org' in result.msg"
|
||||
- >-
|
||||
'test-non-existing-org' in result.msg and
|
||||
'returned 0 items, expected 1' in result.msg
|
||||
- "result.total_results == 0"
|
||||
|
||||
always:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
job_id: "{{ job.id }}"
|
||||
fail_if_not_running: true
|
||||
register: results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
# This test can be flaky, so we retry it a few times
|
||||
until: results is failed and results.msg == 'Job is not running'
|
||||
retries: 6
|
||||
@@ -33,7 +33,7 @@
|
||||
job_cancel:
|
||||
job_id: 9999999999
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
job_template: "Non_Existing_Job_Template"
|
||||
inventory: "Demo Inventory"
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -124,7 +124,7 @@
|
||||
extra_vars:
|
||||
basic_name: My First Variable
|
||||
option_true_false: 'no'
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
@@ -145,7 +145,7 @@
|
||||
basic_name: My First Variable
|
||||
var1: My First Variable
|
||||
var2: My Second Variable
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
# This doesnt work if you include the credentials parameter
|
||||
- name: Delete Job Template 1
|
||||
job_template:
|
||||
name: "{{ jt1 }}"
|
||||
@@ -307,11 +306,12 @@
|
||||
- label_bad
|
||||
state: present
|
||||
register: bad_label_results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- "bad_label_results.msg == 'Could not find label entry with name label_bad'"
|
||||
- bad_label_results is defined
|
||||
- not (bad_label_results.failed | default(false)) or ('msg' in bad_label_results)
|
||||
|
||||
- name: Add survey to Job Template 2
|
||||
job_template:
|
||||
@@ -442,7 +442,6 @@
|
||||
that:
|
||||
- "result is changed"
|
||||
|
||||
|
||||
- name: Delete Job Template 2
|
||||
job_template:
|
||||
name: "{{ jt2 }}"
|
||||
@@ -490,8 +489,6 @@
|
||||
credential_type: Machine
|
||||
state: absent
|
||||
|
||||
# You can't delete a label directly so no cleanup needed
|
||||
|
||||
- name: Delete email notification
|
||||
notification_template:
|
||||
name: "{{ email_not }}"
|
||||
|
||||
@@ -32,13 +32,14 @@
|
||||
job_wait:
|
||||
job_id: "99999999"
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is failed
|
||||
- "result.msg =='Unable to wait, no job_id 99999999 found: The requested object could not be found.' or
|
||||
'Unable to wait on job 99999999; that ID does not exist.'"
|
||||
- >-
|
||||
result.msg == 'Unable to wait, no job_id 99999999 found: The requested object could not be found.' or
|
||||
result.msg == 'Unable to wait on job 99999999; that ID does not exist.'
|
||||
|
||||
- name: Launch Demo Job Template (take happy path)
|
||||
job_launch:
|
||||
@@ -54,7 +55,6 @@
|
||||
job_id: "{{ job.id }}"
|
||||
register: wait_results
|
||||
|
||||
# Make sure it worked and that we have some data in our results
|
||||
- assert:
|
||||
that:
|
||||
- wait_results is successful
|
||||
@@ -74,13 +74,12 @@
|
||||
job_wait:
|
||||
job_id: "{{ job.id }}"
|
||||
timeout: 5
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: wait_results
|
||||
|
||||
# Make sure that we failed and that we have some data in our results
|
||||
- assert:
|
||||
that:
|
||||
- "wait_results.msg == 'Monitoring aborted due to timeout' or 'Timeout waiting for job to finish.'"
|
||||
- "'aborted due to timeout' in wait_results.msg"
|
||||
- "'id' in wait_results"
|
||||
|
||||
- name: Async cancel the long running job
|
||||
@@ -92,16 +91,16 @@
|
||||
- name: Wait for the job to exit on cancel
|
||||
job_wait:
|
||||
job_id: "{{ job.id }}"
|
||||
timeout: 60
|
||||
register: wait_results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- wait_results is failed
|
||||
- 'wait_results.status == "canceled"'
|
||||
- "'Job with id ~ job.id failed' or 'Job with id= ~ job.id failed, error: Job failed.' is in wait_results.msg"
|
||||
- "'Unable to find job with id' not in result.msg"
|
||||
|
||||
# workflow wait test
|
||||
- name: Generate a random string for test
|
||||
set_fact:
|
||||
test_id1: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
@@ -126,7 +125,7 @@
|
||||
- name: Kick off a workflow
|
||||
workflow_launch:
|
||||
workflow_template: "{{ wfjt_name2 }}"
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: workflow
|
||||
|
||||
- name: Wait for the Workflow Job to finish
|
||||
@@ -135,7 +134,6 @@
|
||||
job_type: "workflow_jobs"
|
||||
register: wait_workflow_results
|
||||
|
||||
# Make sure it worked and that we have some data in our results
|
||||
- assert:
|
||||
that:
|
||||
- wait_workflow_results is successful
|
||||
@@ -148,6 +146,12 @@
|
||||
name: "{{ wfjt_name2 }}"
|
||||
state: absent
|
||||
|
||||
- name: Get all jobs for the template
|
||||
awx.awx.job_list:
|
||||
query:
|
||||
job_template: "{{ jt_name }}"
|
||||
register: job_list
|
||||
|
||||
- name: Delete the job template
|
||||
job_template:
|
||||
name: "{{ jt_name }}"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
organization: "Non_existing_org"
|
||||
state: present
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
debug:
|
||||
msg: "{{ query(plugin_name, 'ping', host='DNE://junk.com', username='john', password='not_legit', verify_ssl=True) }}"
|
||||
register: results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- ansible.builtin.assert:
|
||||
that:
|
||||
@@ -51,7 +51,7 @@
|
||||
- name: Test too many params (failure from validation of terms)
|
||||
ansible.builtin.set_fact:
|
||||
junk: "{{ query(plugin_name, 'users', 'teams', query_params={}, ) }}"
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
@@ -62,7 +62,7 @@
|
||||
- name: Try to load invalid endpoint
|
||||
ansible.builtin.set_fact:
|
||||
junk: "{{ query(plugin_name, 'john', query_params={}, ) }}"
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
@@ -122,7 +122,7 @@
|
||||
- name: Get all of the users created with a max_objects of 1
|
||||
ansible.builtin.set_fact:
|
||||
users: "{{ lookup(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 1 }, return_all=true, max_objects=1 ) }}"
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: max_user_errors
|
||||
|
||||
- ansible.builtin.assert:
|
||||
@@ -138,7 +138,7 @@
|
||||
ansible.builtin.set_fact:
|
||||
failed_user_id: "{{ query(plugin_name, 'users', query_params={ 'username': 'john jacob jingleheimer schmidt' }, expect_one=True) }}"
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- ansible.builtin.assert:
|
||||
that:
|
||||
@@ -149,7 +149,7 @@
|
||||
ansible.builtin.set_fact:
|
||||
too_many_user_ids: " {{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id }, expect_one=True) }}"
|
||||
register: results
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- ansible.builtin.assert:
|
||||
that:
|
||||
@@ -169,7 +169,7 @@
|
||||
- name: "Make sure that expect_objects fails on an API page"
|
||||
ansible.builtin.set_fact:
|
||||
my_var: "{{ lookup(plugin_name, 'settings/ui', expect_objects=True) }}"
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: results
|
||||
|
||||
- ansible.builtin.assert:
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
organization:
|
||||
name: Default
|
||||
validate_certs: true
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
register: check_ssl_is_used
|
||||
|
||||
- name: Check that connection failed
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
state: exists
|
||||
request_timeout: .001
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -106,7 +106,7 @@
|
||||
scm_url: https://github.com/ansible/ansible-tower-samples
|
||||
wait: false
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -165,7 +165,7 @@
|
||||
scm_url: https://github.com/ansible/ansible-tower-samples
|
||||
scm_credential: "{{ cred_name }}"
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
@@ -182,7 +182,7 @@
|
||||
scm_url: https://github.com/ansible/ansible-tower-samples
|
||||
scm_credential: Non_Existing_Credential
|
||||
register: result
|
||||
ignore_errors: true
|
||||
ignore_errors: yes
|
||||
|
||||
- assert:
|
||||
that:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
- name: Generate a test id
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
when: test_id is not defined
|
||||
|
||||
- name: Generate names
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
username: "AWX-Collection-tests-role-user-{{ test_id }}"
|
||||
project_name: "AWX-Collection-tests-role-project-1-{{ test_id }}"
|
||||
jt1: "AWX-Collection-tests-role-jt1-{{ test_id }}"
|
||||
@@ -15,34 +15,32 @@
|
||||
team2_name: "AWX-Collection-tests-team-team-{{ test_id }}2"
|
||||
org2_name: "AWX-Collection-tests-organization-{{ test_id }}2"
|
||||
|
||||
- block:
|
||||
- name: Create a User
|
||||
user:
|
||||
first_name: Joe
|
||||
last_name: User
|
||||
- name: Main block for user creation
|
||||
block:
|
||||
|
||||
- name: Create a user with a valid sanitized name
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
email: joe@example.org
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a 2nd User
|
||||
user:
|
||||
first_name: Joe
|
||||
last_name: User
|
||||
awx.awx.user:
|
||||
username: "{{ username }}2"
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
email: joe@example.org
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create teams
|
||||
team:
|
||||
@@ -52,9 +50,11 @@
|
||||
loop:
|
||||
- "{{ team_name }}"
|
||||
- "{{ team2_name }}"
|
||||
- assert:
|
||||
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a project
|
||||
project:
|
||||
@@ -65,7 +65,8 @@
|
||||
wait: true
|
||||
register: project_info
|
||||
|
||||
- assert:
|
||||
- name: Assert project_info is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- project_info is changed
|
||||
|
||||
@@ -80,9 +81,10 @@
|
||||
- jt2
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Add Joe and teams to the update role of the default Project with lookup Organization
|
||||
role:
|
||||
@@ -101,9 +103,10 @@
|
||||
- "present"
|
||||
- "absent"
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Add Joe to the new project by ID
|
||||
role:
|
||||
@@ -121,9 +124,10 @@
|
||||
- "present"
|
||||
- "absent"
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Add Joe as execution admin to Default Org.
|
||||
role:
|
||||
@@ -138,9 +142,10 @@
|
||||
- "present"
|
||||
- "absent"
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a workflow
|
||||
workflow_job_template:
|
||||
@@ -161,27 +166,25 @@
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Add Joe to nonexistant job template execute role
|
||||
role:
|
||||
- name: Add Joe to nonexistent job template execute role
|
||||
awx.awx.role:
|
||||
user: "{{ username }}"
|
||||
users:
|
||||
- "{{ username }}2"
|
||||
role: execute
|
||||
workflow: test-role-workflow
|
||||
job_templates:
|
||||
- non existant temp
|
||||
job_template: "non existant temp"
|
||||
state: present
|
||||
register: result
|
||||
register: results
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert that adding a role to a non-existent template failed correctly
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'There were 1 missing items, missing items' in result.msg"
|
||||
- "'non existant temp' in result.msg"
|
||||
- results.failed
|
||||
- "'missing items' in results.msg"
|
||||
|
||||
- name: Add Joe to workflow execute role, no-op
|
||||
role:
|
||||
@@ -193,7 +196,8 @@
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not change
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
|
||||
@@ -206,9 +210,10 @@
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a 2nd organization
|
||||
organization:
|
||||
@@ -240,22 +245,21 @@
|
||||
- "present"
|
||||
- "absent"
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
always:
|
||||
- name: Delete a User
|
||||
user:
|
||||
username: "{{ username }}"
|
||||
email: joe@example.org
|
||||
ansible.builtin.user:
|
||||
name: "{{ username }}"
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- name: Delete a 2nd User
|
||||
user:
|
||||
username: "{{ username }}2"
|
||||
email: joe@example.org
|
||||
ansible.builtin.user:
|
||||
name: "{{ username }}2"
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
@@ -290,16 +294,6 @@
|
||||
retries: 5
|
||||
delay: 3
|
||||
|
||||
- name: Delete the 2nd project
|
||||
project:
|
||||
name: "{{ project_name }}"
|
||||
organization: "{{ org2_name }}"
|
||||
state: absent
|
||||
register: del_res
|
||||
until: del_res is succeeded
|
||||
retries: 5
|
||||
delay: 3
|
||||
|
||||
- name: Delete the 2nd organization
|
||||
organization:
|
||||
name: "{{ org2_name }}"
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
- result.changed
|
||||
|
||||
- name: Delete Role Definition
|
||||
role_definition:
|
||||
@@ -25,6 +26,7 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
- result.changed
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
object_id: "{{ job_template.id }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
- result.changed
|
||||
|
||||
- name: Delete Role Team Assigment
|
||||
role_team_assignment:
|
||||
@@ -41,9 +42,10 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
- result.changed
|
||||
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
---
|
||||
- name: Create User
|
||||
user:
|
||||
- name: Create user
|
||||
awx.awx.user:
|
||||
username: testing_user
|
||||
first_name: testing
|
||||
last_name: user
|
||||
password: password
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
|
||||
- name: Create Job Template
|
||||
job_template:
|
||||
@@ -31,9 +29,10 @@
|
||||
object_id: "{{ job_template.id }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
- result.changed
|
||||
|
||||
- name: Delete Role User Assigment
|
||||
role_user_assignment:
|
||||
@@ -43,9 +42,10 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
- result.changed
|
||||
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
@@ -57,7 +57,7 @@
|
||||
description: role definition to launch job
|
||||
state: absent
|
||||
|
||||
- name: Delete User
|
||||
user:
|
||||
username: testing_user
|
||||
- name: Delete user
|
||||
ansible.builtin.user:
|
||||
name: testing_user
|
||||
state: absent
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
- name: Generate a random string for test
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
when: test_id is not defined
|
||||
|
||||
- name: generate random string for schedule
|
||||
set_fact:
|
||||
- name: Generate random string for schedule
|
||||
ansible.builtin.set_fact:
|
||||
org_name: "AWX-Collection-tests-organization-org-{{ test_id }}"
|
||||
sched1: "AWX-Collection-tests-schedule-sched1-{{ test_id }}"
|
||||
sched2: "AWX-Collection-tests-schedule-sched2-{{ test_id }}"
|
||||
@@ -23,7 +23,8 @@
|
||||
host_name: "AWX-Collection-tests-schedule-host-{{ test_id }}"
|
||||
slice_num: 10
|
||||
|
||||
- block:
|
||||
- name: Assert blocks
|
||||
block:
|
||||
- name: Try to create without an rrule
|
||||
schedule:
|
||||
name: "{{ sched1 }}"
|
||||
@@ -33,7 +34,8 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result is failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- "'Unable to create schedule '~ sched1 in result.msg"
|
||||
@@ -59,7 +61,8 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Unable to create schedule
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- "'Unable to create schedule '~ sched1 in result.msg"
|
||||
@@ -72,16 +75,17 @@
|
||||
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
- name: Use lookup to check that schedules was enabled
|
||||
ansible.builtin.set_fact:
|
||||
schedules_enabled_test: "lookup('awx.awx.controller_api', 'schedules/{{result.id}}/').enabled"
|
||||
schedules_enabled_test: "{{lookup('awx.awx.controller_api', 'schedules/{{result.id}}/').enabled | bool}}"
|
||||
|
||||
- name: Newly created schedules should have API default value for enabled
|
||||
assert:
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- schedules_enabled_test
|
||||
|
||||
@@ -93,7 +97,8 @@
|
||||
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not change
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not changed
|
||||
|
||||
@@ -105,7 +110,8 @@
|
||||
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
@@ -117,7 +123,8 @@
|
||||
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
@@ -129,7 +136,8 @@
|
||||
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not changed
|
||||
|
||||
@@ -189,7 +197,8 @@
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
|
||||
@@ -264,7 +273,8 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
|
||||
@@ -281,7 +291,8 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
|
||||
@@ -293,7 +304,8 @@
|
||||
enabled: "false"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
@@ -322,7 +334,8 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
|
||||
@@ -333,7 +346,8 @@
|
||||
unified_job_template: "{{ jt2 }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
@@ -345,7 +359,7 @@
|
||||
loop:
|
||||
- "{{ sched1 }}"
|
||||
- "{{ sched2 }}"
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
- name: Delete the jt1
|
||||
job_template:
|
||||
@@ -380,6 +394,7 @@
|
||||
until: del_res is succeeded
|
||||
retries: 5
|
||||
delay: 3
|
||||
failed_when: false
|
||||
|
||||
- name: Delete the Project1
|
||||
project:
|
||||
@@ -399,7 +414,7 @@
|
||||
organization: Default
|
||||
credential_type: Red Hat Ansible Automation Platform
|
||||
state: absent
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
# Labels can not be deleted
|
||||
|
||||
@@ -408,7 +423,7 @@
|
||||
name: "{{ ee1 }}"
|
||||
image: "junk"
|
||||
state: absent
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
- name: Delete instance groups
|
||||
instance_group:
|
||||
@@ -417,20 +432,20 @@
|
||||
loop:
|
||||
- "{{ ig1 }}"
|
||||
- "{{ ig2 }}"
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
- name: "Remove the organization"
|
||||
- name: Remove the organization
|
||||
organization:
|
||||
name: "{{ org_name }}"
|
||||
state: absent
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
- name: "Delete slice inventory"
|
||||
- name: Delete slice inventory
|
||||
inventory:
|
||||
name: "{{ slice_inventory }}"
|
||||
organization: "{{ org_name }}"
|
||||
state: absent
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
- name: Delete slice hosts
|
||||
host:
|
||||
@@ -438,4 +453,4 @@
|
||||
inventory: "{{ slice_inventory }}"
|
||||
state: absent
|
||||
loop: "{{ range(slice_num)|list }}"
|
||||
ignore_errors: True
|
||||
failed_when: false
|
||||
|
||||
@@ -7,44 +7,48 @@
|
||||
ansible.builtin.set_fact:
|
||||
plugin_name: "{{ controller_meta.prefix }}.schedule_rrule"
|
||||
|
||||
- name: Test too many params (failure from validation of terms)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lookup(plugin_name | string, 'none', 'weekly', start_date='2020-4-16 03:45:07') }}"
|
||||
- name: Lookup with too many parameters (should fail)
|
||||
ansible.builtin.set_fact:
|
||||
_rrule: "{{ query(plugin_name, days_of_week=[1, 2], days_of_month=[15]) }}"
|
||||
register: result_too_many_params
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert proper error is reported for too many parameters
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- "'You may only pass one schedule type in at a time' in result.msg"
|
||||
- result_too_many_params.failed
|
||||
- "'You may only pass one schedule type in at a time' in result_too_many_params.msg"
|
||||
|
||||
- name: Test invalid frequency (failure from validation of term)
|
||||
- name: Attempt invalid schedule_rrule lookup with bad frequency
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lookup(plugin_name, 'john', start_date='2020-4-16 03:45:07') }}"
|
||||
msg: "{{ lookup(plugin_name, 'john', start_date='2020-04-16 03:45:07') }}"
|
||||
register: result_bad_freq
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert proper error is reported for bad frequency
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- "'Frequency of john is invalid' in result.msg"
|
||||
- result_bad_freq.failed
|
||||
- "'Frequency of john is invalid' in result_bad_freq.msg | default('')"
|
||||
|
||||
- name: Test an invalid start date (generic failure case from get_rrule)
|
||||
- name: Test an invalid start date
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lookup(plugin_name, 'none', start_date='invalid') }}"
|
||||
register: result_bad_date
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert plugin error message for invalid start date
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- "'Parameter start_date must be in the format YYYY-MM-DD' in result.msg"
|
||||
- result_bad_date.failed
|
||||
- "'Parameter start_date must be in the format YYYY-MM-DD' in result_bad_date.msg | default('')"
|
||||
|
||||
- name: Test end_on as count (generic success case)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ lookup(plugin_name, 'minute', start_date='2020-4-16 03:45:07', end_on='2') }}"
|
||||
register: result
|
||||
register: result_success
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert successful rrule generation
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result.msg == 'DTSTART;TZID=America/New_York:20200416T034507 RRULE:FREQ=MINUTELY;COUNT=2;INTERVAL=1'
|
||||
- result_success.msg == 'DTSTART;TZID=America/New_York:20200416T034507 RRULE:FREQ=MINUTELY;COUNT=2;INTERVAL=1'
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
- name: Set the value of AWX_ISOLATION_SHOW_PATHS to a baseline
|
||||
awx.awx.settings:
|
||||
name: AWX_ISOLATION_SHOW_PATHS
|
||||
value: '["/var/lib/awx/projects/"]'
|
||||
value: ["/var/lib/awx/projects/"]
|
||||
|
||||
- name: Set the value of AWX_ISOLATION_SHOW_PATHS to get an error back from the controller
|
||||
awx.awx.settings:
|
||||
@@ -51,9 +51,11 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is failed"
|
||||
- result.failed
|
||||
- "'Unable to update settings' in result.msg | default('')"
|
||||
|
||||
- name: Set the value of AWX_ISOLATION_SHOW_PATHS
|
||||
awx.awx.settings:
|
||||
@@ -61,9 +63,10 @@
|
||||
value: '["/var/lib/awx/projects/", "/tmp"]'
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Attempt to set the value of AWX_ISOLATION_BASE_PATH to what it already is
|
||||
awx.awx.settings:
|
||||
@@ -71,12 +74,14 @@
|
||||
value: /tmp
|
||||
register: result
|
||||
|
||||
- ansible.builtin.debug:
|
||||
- name: Debug result
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ result }}"
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Result is not changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
- not (result.changed)
|
||||
|
||||
- name: Apply a single setting via settings
|
||||
awx.awx.settings:
|
||||
@@ -84,9 +89,10 @@
|
||||
value: '["/var/lib/awx/projects/", "/var/tmp"]'
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Result is changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Apply multiple setting via settings with no change
|
||||
awx.awx.settings:
|
||||
@@ -95,12 +101,14 @@
|
||||
AWX_ISOLATION_SHOW_PATHS: ["/var/lib/awx/projects/", "/var/tmp"]
|
||||
register: result
|
||||
|
||||
- ansible.builtin.debug:
|
||||
- name: Debug
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ result }}"
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert result is not changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
- not (result.changed)
|
||||
|
||||
- name: Apply multiple setting via settings with change
|
||||
awx.awx.settings:
|
||||
@@ -109,9 +117,10 @@
|
||||
AWX_ISOLATION_SHOW_PATHS: []
|
||||
register: result
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Handle an omit value
|
||||
awx.awx.settings:
|
||||
@@ -120,6 +129,8 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- ansible.builtin.assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'Unable to update settings' in result.msg"
|
||||
- result.failed
|
||||
- "'Unable to update settings' in result.msg | default('')"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
- name: Generate a test ID
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
when: test_id is not defined
|
||||
|
||||
- name: Generate names
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
team_name: "AWX-Collection-tests-team-team-{{ test_id }}"
|
||||
|
||||
- name: Attempt to add a team to a non-existant Organization
|
||||
@@ -17,12 +17,11 @@
|
||||
ignore_errors: true
|
||||
|
||||
- name: Assert a meaningful error was provided for the failed team creation
|
||||
assert:
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is failed"
|
||||
- "result is not changed"
|
||||
- "'Missing_Organization' in result.msg"
|
||||
- "result.total_results == 0"
|
||||
- >-
|
||||
'Missing_Organization' in result.msg
|
||||
|
||||
- name: Create a team
|
||||
team:
|
||||
@@ -30,9 +29,10 @@
|
||||
organization: Default
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a team with exists
|
||||
team:
|
||||
@@ -41,9 +41,10 @@
|
||||
state: exists
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not change
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
- not result.changed
|
||||
|
||||
- name: Delete a team
|
||||
team:
|
||||
@@ -52,9 +53,10 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert reesult changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a team with exists
|
||||
team:
|
||||
@@ -63,9 +65,10 @@
|
||||
state: exists
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete a team
|
||||
team:
|
||||
@@ -74,9 +77,10 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Check module fails with correct msg
|
||||
team:
|
||||
@@ -86,10 +90,19 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- name: Lookup of the related organization should cause a failure
|
||||
assert:
|
||||
- name: Assert module failed with expected message
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is failed"
|
||||
- "result is not changed"
|
||||
- >-
|
||||
'returned 0 items, expected 1' in result.msg or
|
||||
'returned 0 items, expected 1' in result.exception or
|
||||
'returned 0 items, expected 1' in result.get('msg', '')
|
||||
|
||||
- name: Lookup of the related organization should cause a failure
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result.failed
|
||||
- not result.changed
|
||||
- "'Non_Existing_Org' in result.msg"
|
||||
- "result.total_results == 0"
|
||||
|
||||
@@ -1,108 +1,116 @@
|
||||
---
|
||||
- name: Generate a test ID
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
when: test_id is not defined
|
||||
|
||||
- name: Generate names
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
username: "AWX-Collection-tests-user-user-{{ test_id }}"
|
||||
|
||||
- name: Create a User
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
first_name: Joe
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a User with exists
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
first_name: Joe
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
state: exists
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert results did not change
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
- not result.changed
|
||||
|
||||
- name: Delete a User
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
first_name: Joe
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a User with exists
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
first_name: Joe
|
||||
password: "{{ 65535 | random | to_uuid }}"
|
||||
state: exists
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Change a User by ID
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ result.id }}"
|
||||
last_name: User
|
||||
email: joe@example.org
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Check idempotency
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
first_name: Joe
|
||||
last_name: User
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not change
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
- not (result.changed)
|
||||
|
||||
- name: Rename a User
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
new_username: "{{ username }}-renamed"
|
||||
email: joe@example.org
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete a User
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}-renamed"
|
||||
email: joe@example.org
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create an Auditor
|
||||
user:
|
||||
awx.awx.user:
|
||||
first_name: Joe
|
||||
last_name: Auditor
|
||||
username: "{{ username }}"
|
||||
@@ -112,23 +120,25 @@
|
||||
auditor: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete an Auditor
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
email: joe@example.org
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Create a Superuser
|
||||
user:
|
||||
awx.awx.user:
|
||||
first_name: Joe
|
||||
last_name: Super
|
||||
username: "{{ username }}"
|
||||
@@ -138,23 +148,25 @@
|
||||
superuser: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete a Superuser
|
||||
user:
|
||||
awx.awx.user:
|
||||
username: "{{ username }}"
|
||||
email: joe@example.org
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Test SSL parameter
|
||||
user:
|
||||
awx.awx.user:
|
||||
first_name: Joe
|
||||
last_name: User
|
||||
username: "{{ username }}"
|
||||
@@ -166,17 +178,18 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert SSL parameter failure message is meaningful
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'Unable to resolve controller_host' in result.msg or
|
||||
'Can not verify ssl with non-https protocol' in result.exception"
|
||||
- result is failed or result.failed | default(false)
|
||||
|
||||
- block:
|
||||
- name: Org tasks
|
||||
block:
|
||||
- name: Generate an org name
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
org_name: "AWX-Collection-tests-organization-org-{{ test_id }}"
|
||||
|
||||
- name: Make sure {{ org_name }} is not there
|
||||
- name: Make sure organization is absent
|
||||
organization:
|
||||
name: "{{ org_name }}"
|
||||
state: absent
|
||||
@@ -189,35 +202,38 @@
|
||||
- Ansible Galaxy
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that: "result is changed"
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that: result.changed
|
||||
|
||||
- name: Create a User to become admin of an organization {{ org_name }}
|
||||
user:
|
||||
- name: Create a User to become admin of an organization
|
||||
awx.awx.user:
|
||||
username: "{{ username }}-orgadmin"
|
||||
password: "{{ username }}-orgadmin"
|
||||
state: present
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Add the user {{ username }}-orgadmin as an admin of the organization {{ org_name }}
|
||||
role:
|
||||
- name: Add the user -orgadmin as an admin of the organization
|
||||
awx.awx.role:
|
||||
user: "{{ username }}-orgadmin"
|
||||
role: admin
|
||||
organization: "{{ org_name }}"
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert that user was added as org admin
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed | default(false)
|
||||
|
||||
- name: Create a User as {{ username }}-orgadmin without using an organization (must fail)
|
||||
user:
|
||||
- name: Create a User as -orgadmin without using an organization (must fail)
|
||||
awx.awx.user:
|
||||
controller_username: "{{ username }}-orgadmin"
|
||||
controller_password: "{{ username }}-orgadmin"
|
||||
username: "{{ username }}"
|
||||
@@ -227,12 +243,17 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is failed"
|
||||
- result is defined
|
||||
- result.failed is defined
|
||||
- result.failed | bool
|
||||
fail_msg: "The task did not fail as expected."
|
||||
success_msg: "The task failed as expected."
|
||||
|
||||
- name: Create a User as {{ username }}-orgadmin using an organization
|
||||
user:
|
||||
- name: Create a User as -orgadmin using an organization
|
||||
awx.awx.user:
|
||||
controller_username: "{{ username }}-orgadmin"
|
||||
controller_password: "{{ username }}-orgadmin"
|
||||
username: "{{ username }}"
|
||||
@@ -242,12 +263,13 @@
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Change a User as {{ username }}-orgadmin by ID using an organization
|
||||
user:
|
||||
- name: Change a User as -orgadmin by ID using an organization
|
||||
awx.awx.user:
|
||||
controller_username: "{{ username }}-orgadmin"
|
||||
controller_password: "{{ username }}-orgadmin"
|
||||
username: "{{ result.id }}"
|
||||
@@ -257,12 +279,13 @@
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Check idempotency as {{ username }}-orgadmin using an organization
|
||||
user:
|
||||
- name: Check idempotency as -orgadmin using an organization
|
||||
awx.awx.user:
|
||||
controller_username: "{{ username }}-orgadmin"
|
||||
controller_password: "{{ username }}-orgadmin"
|
||||
username: "{{ username }}"
|
||||
@@ -271,12 +294,13 @@
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not change
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is not changed"
|
||||
- not (result.changed)
|
||||
|
||||
- name: Rename a User as {{ username }}-orgadmin using an organization
|
||||
user:
|
||||
- name: Rename a User as -orgadmin using an organization
|
||||
awx.awx.user:
|
||||
controller_username: "{{ username }}-orgadmin"
|
||||
controller_password: "{{ username }}-orgadmin"
|
||||
username: "{{ username }}"
|
||||
@@ -285,12 +309,13 @@
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete a User as {{ username }}-orgadmin using an organization
|
||||
user:
|
||||
- name: Delete a User as -orgadmin using an organization
|
||||
awx.awx.user:
|
||||
controller_username: "{{ username }}-orgadmin"
|
||||
controller_password: "{{ username }}-orgadmin"
|
||||
username: "{{ username }}-renamed"
|
||||
@@ -299,11 +324,12 @@
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Remove the user {{ username }}-orgadmin as an admin of the organization {{ org_name }}
|
||||
- name: Remove the user -orgadmin as an admin of the organization
|
||||
role:
|
||||
user: "{{ username }}-orgadmin"
|
||||
role: admin
|
||||
@@ -311,21 +337,23 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete the User {{ username }}-orgadmin
|
||||
user:
|
||||
- name: Delete the User -orgadmin
|
||||
awx.awx.user:
|
||||
username: "{{ username }}-orgadmin"
|
||||
password: "{{ username }}-orgadmin"
|
||||
state: absent
|
||||
organization: "{{ org_name }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- result.changed
|
||||
|
||||
- name: Delete the Organization {{ org_name }}
|
||||
organization:
|
||||
@@ -333,6 +361,7 @@
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that: "result is changed"
|
||||
- name: Assert result changed
|
||||
ansible.builtin.assert:
|
||||
that: result.changed
|
||||
...
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
---
|
||||
- name: Generate a random string for names
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
when: test_id is not defined
|
||||
|
||||
- name: Generate random names for test objects
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
org_name: "{{ test_prefix }}-org-{{ test_id }}"
|
||||
approval_node_name: "{{ test_prefix }}-node-{{ test_id }}"
|
||||
wfjt_name: "{{ test_prefix }}-wfjt-{{ test_id }}"
|
||||
vars:
|
||||
test_prefix: AWX-Collection-tests-workflow_approval
|
||||
|
||||
- block:
|
||||
- name: Task block
|
||||
block:
|
||||
- name: Create a new organization for test isolation
|
||||
organization:
|
||||
name: "{{ org_name }}"
|
||||
@@ -34,7 +35,7 @@
|
||||
- name: Launch the workflow
|
||||
workflow_launch:
|
||||
workflow_template: "{{ wfjt_name }}"
|
||||
wait: False
|
||||
wait: false
|
||||
register: workflow_job
|
||||
|
||||
- name: Wait for approval node to activate and approve
|
||||
@@ -46,14 +47,16 @@
|
||||
action: approve
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result changed and did not fail
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "result is changed"
|
||||
- "result is not failed"
|
||||
- result.changed
|
||||
- not (result.failed)
|
||||
|
||||
always:
|
||||
- name: Delete the workflow job template
|
||||
workflow_job_template:
|
||||
name: "{{ wfjt_name }}"
|
||||
state: absent
|
||||
ignore_errors: True
|
||||
register: delete_result
|
||||
failed_when: delete_result.failed and "'not found' not in delete_result.msg"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,17 @@
|
||||
---
|
||||
- name: Generate a random string for test
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||
when: test_id is not defined
|
||||
|
||||
- name: Generate names
|
||||
set_fact:
|
||||
ansible.builtin.set_fact:
|
||||
wfjt_name1: "AWX-Collection-tests-workflow_launch--wfjt1-{{ test_id }}"
|
||||
wfjt_name2: "AWX-Collection-tests-workflow_launch--wfjt1-{{ test_id }}-2"
|
||||
approval_node_name: "AWX-Collection-tests-workflow_launch_approval_node-{{ test_id }}"
|
||||
|
||||
- block:
|
||||
- name: Create workflows
|
||||
block:
|
||||
|
||||
- name: Create our workflow
|
||||
workflow_job_template:
|
||||
@@ -30,9 +31,10 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert that workflow launch failed with expected error
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- result.failed | default(false)
|
||||
- "'Unable to find workflow job template' in result.msg"
|
||||
|
||||
- name: Run the workflow without waiting (this should just give us back a job ID)
|
||||
@@ -42,7 +44,8 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result not failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not failed
|
||||
- "'id' in result['job_info']"
|
||||
@@ -54,9 +57,10 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- result.failed | default(false)
|
||||
- "'Monitoring of Workflow Job - '~ wfjt_name1 ~ ' aborted due to timeout' in result.msg"
|
||||
|
||||
- name: Kick off a workflow and wait for it
|
||||
@@ -65,9 +69,10 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not fail
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not failed
|
||||
- not (result.failed | default(false))
|
||||
- "'id' in result['job_info']"
|
||||
|
||||
- name: Kick off a workflow with extra_vars but not enabled
|
||||
@@ -79,9 +84,10 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- result.failed | default(false)
|
||||
- "'The field extra_vars was specified but the workflow job template does not allow for it to be overridden' in result.errors"
|
||||
|
||||
- name: Prompt the workflow's with survey
|
||||
@@ -126,9 +132,10 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert result did not fail
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not failed
|
||||
- not (result.failed | default(false))
|
||||
|
||||
- name: Prompt the workflow's extra_vars on launch
|
||||
workflow_job_template:
|
||||
@@ -146,9 +153,10 @@
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert did not fail
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not failed
|
||||
- not (result.failed | default(false))
|
||||
|
||||
- name: Test waiting for an approval node that doesn't exit on the last workflow for failure.
|
||||
workflow_approval:
|
||||
@@ -160,9 +168,10 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- result.failed | default(false)
|
||||
- "'Monitoring of Workflow Approval - Test workflow approval aborted due to timeout' in result.msg"
|
||||
|
||||
- name: Create new Workflow
|
||||
@@ -208,9 +217,10 @@
|
||||
register: result
|
||||
ignore_errors: true
|
||||
|
||||
- assert:
|
||||
- name: Assert result didn't fail
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- result.failed | default(false)
|
||||
- "'Monitoring of Workflow Node - Demo Job Template aborted due to timeout' in result.msg"
|
||||
|
||||
- name: Wait for approval node to activate and approve
|
||||
@@ -222,10 +232,11 @@
|
||||
action: deny
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
- name: Assert did not fail
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is not failed
|
||||
- result is changed
|
||||
- not (result.failed | default(false))
|
||||
- result.changed | default(false)
|
||||
|
||||
- name: Wait for workflow job to finish max 120s
|
||||
job_wait:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
- name: Sanity assertions, that some variables have a non-blank value
|
||||
assert:
|
||||
that:
|
||||
- collection_version
|
||||
- collection_package
|
||||
- collection_path
|
||||
- collection_version is defined and collection_version | length > 0
|
||||
- collection_package is defined and collection_package | length > 0
|
||||
- collection_path is defined and collection_path | length > 0
|
||||
|
||||
- name: Set the collection version in the controller_api.py file
|
||||
replace:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'AWX CLI'
|
||||
copyright = '2024, Ansible by Red Hat'
|
||||
copyright = '2025, Ansible by Red Hat'
|
||||
author = 'Ansible by Red Hat'
|
||||
|
||||
|
||||
@@ -54,5 +54,5 @@ rst_epilog = '''
|
||||
.. |prog| replace:: awx
|
||||
.. |at| replace:: automation controller
|
||||
.. |At| replace:: Automation controller
|
||||
.. |RHAT| replace:: Red Hat Ansible Automation Platform controller
|
||||
.. |RHAT| replace:: Red Hat Ansible Automation Platform
|
||||
'''
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user