Compare commits

...

29 Commits

Author SHA1 Message Date
John Westcott IV
84f67c7f82 Merge pull request #13961 from ansible/feature_django_upgrade_psycopg2
Upgrade to Django 4.2 LTS
2023-05-17 11:45:53 -04:00
Alan Rominger
433c28caa8 Materialize label page after getting 204 code (#14010) 2023-05-16 16:12:18 -04:00
Rick Elrod
fa05f55512 [collection] Fix sanity tests on ansible-core 2.15 (#14007)
Signed-off-by: Rick Elrod <rick@elrod.me>
2023-05-15 14:39:14 -05:00
Alan Rominger
0d5c0bcb91 Skip constructed_inventory in a more correct loop (#14004) 2023-05-15 13:48:59 -04:00
Rick Elrod
f3fa75d832 [wsrelay] Handle heartbeet shutdown and redis drop (#13991)
This fixes two different exceptions in wsrelay.

* One resulted from heartbeet getting ability in #13858 to gracefully
  shut down. When we saw the message come through, we didn't fully
  clean up the connection to the web node.

* The second resulted when Redis disappeared. We still want to exit in
  that case, but it's better to log a message and exit gracefully
  instead of crashing out.

Signed-off-by: Rick Elrod <rick@elrod.me>
2023-05-15 10:46:23 -05:00
John Westcott IV
285b7b0e5f Fixing using QuerySet.iterator() after prefetch_related() without specifying chunk_size is deprecated 2023-05-11 11:45:47 -04:00
John Westcott IV
08e8147374 Removing deprecated django.utils.timezone.utc alias in favor of datetime.timezone.utc 2023-05-11 11:45:47 -04:00
John Westcott IV
09bd398a9e Replacing depricated index_togeather with new indexes 2023-05-11 11:45:47 -04:00
John Westcott IV
8d6f50fae8 Upgrading djgno to 4.2 LTS 2023-05-11 11:45:15 -04:00
John Westcott IV
ecfbcb641e Adding upgrade to django-oauth-toolkit pre-migraiton 2023-05-11 11:43:33 -04:00
Shane McDonald
e434b1e0f3 Merge pull request #13987 from fosterseth/fix_ui_csp
Fix content security policy
2023-05-11 11:03:09 -04:00
Seth Foster
66c3acf777 Fix content security policy 2023-05-11 10:42:23 -04:00
John Westcott IV
ed1983bd8c Merge pull request #13977 from john-westcott-iv/awxkit_import_fix
Skip constructed_inventory endpoint in awxkit import
2023-05-11 09:04:32 -04:00
John Westcott IV
5c4277958c Merge pull request #13976 from john-westcott-iv/collection_job_wait_remove_depreciated_field_check
Change the job_wait integration test
2023-05-11 08:29:50 -04:00
John Westcott IV
7e4da7efa2 Updated pycryptography (#13964)
Co-authored-by: Jessica Steurer <70719005+jay-steurer@users.noreply.github.com>
2023-05-11 09:25:56 -03:00
Christian Adams
7b1cb281c2 Merge pull request #13980 from rooftopcellist/extract-ui-next-strings
Update make target for extracting strings to do so for ui_next too
2023-05-10 23:18:44 -04:00
Christian M. Adams
dee39f3f1c Update make target for extracting strings to do so for ui_next too 2023-05-10 19:20:21 -04:00
John Westcott IV
ba7f97f84b Skip constructed_inventory endpoint in awxkit import 2023-05-10 14:24:27 -04:00
Alan Rominger
85e7189ee3 Add error handling to scm_version.py script (#13521)
raise Exception in the case that return code is non-zero

this approach has shown itself to be the most consistently reliable across multiple ecosystems
2023-05-10 14:20:56 -04:00
Alan Rominger
06430741ab Fix 400 error from job labels sublist (#13972)
This was caused by an incorrect parent_key ref from label to job
  also applies to workflow_job labels

This fixes a regression introduced by a recent merge (#13957)
2023-05-10 11:37:59 -04:00
John Westcott IV
cf091d7836 Change job_wait collection test to always try and delete created objects 2023-05-10 11:13:20 -04:00
John Westcott IV
a66acd87e6 Removes test of depreciated fields that have been removed from job_wait collection 2023-05-10 11:10:07 -04:00
Shane McDonald
595b4e3876 Merge pull request #13956 from shanemcd/get-your-strings-together
Clean up string formatting issues from black migration
2023-05-10 10:14:09 -04:00
Rick Elrod
74c46568c1 [wsrelay] switch from psycopg 3 to asyncpg (#13965)
Due to dependency issues specifically around upgrading to Django 4.2, we
cannot feasibly have a dependency on psycopg2 and psycopg3. The only
place that was currently using psycopg3 was wsrelay.

Change wsrelay to use the asyncpg library and psycopg2 instead.

Tested locally on kind with a dev build of awx.

Signed-off-by: Rick Elrod <rick@elrod.me>
2023-05-10 09:10:35 -05:00
Shane McDonald
05e9b29460 Merge pull request #13963 from Akasurde/doc_fix
Minor typo fix in docs
2023-05-10 08:33:01 -04:00
Shane McDonald
f1196fc019 Clean up string formatting issues from black migration 2023-05-10 08:19:23 -04:00
John Westcott IV
7f020052db Make state exists universal in collection (#13890)
Make state: exists available for all API modules

Make state:exists return the ID just like it would if it created the resource
2023-05-10 09:05:29 -03:00
Rick Elrod
53260213ba Issue template: Remind people to use security@ (#13971)
Signed-off-by: Rick Elrod <rick@elrod.me>
2023-05-09 11:00:02 -05:00
Abhijeet Kasurde
7d1ee37689 Minor typo fix in docs
Signed-off-by: Abhijeet Kasurde <akasurde@redhat.com>
2023-05-08 07:47:07 -07:00
111 changed files with 2701 additions and 544 deletions

View File

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

View File

@@ -660,10 +660,12 @@ awx-kube-dev-build: Dockerfile.kube-dev
## generate UI .pot file, an empty template of strings yet to be translated
pot: $(UI_BUILD_FLAG_FILE)
$(NPM_BIN) --prefix awx/ui --loglevel warn run extract-template --clean
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run extract-template --clean
## generate UI .po files for each locale (will update translated strings for `en`)
po: $(UI_BUILD_FLAG_FILE)
$(NPM_BIN) --prefix awx/ui --loglevel warn run extract-strings -- --clean
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run extract-strings -- --clean
## generate API django .pot .po
messages:

View File

@@ -347,7 +347,7 @@ class FieldLookupBackend(BaseFilterBackend):
args.append(Q(**{k: v}))
for role_name in role_filters:
if not hasattr(queryset.model, 'accessible_pk_qs'):
raise ParseError(_('Cannot apply role_level filter to this list because its model ' 'does not use roles for access control.'))
raise ParseError(_('Cannot apply role_level filter to this list because its model does not use roles for access control.'))
args.append(Q(pk__in=queryset.model.accessible_pk_qs(request.user, role_name)))
if or_filters:
q = Q()

View File

@@ -169,7 +169,7 @@ class APIView(views.APIView):
self.__init_request_error__ = exc
except UnsupportedMediaType as exc:
exc.detail = _(
'You did not use correct Content-Type in your HTTP request. ' 'If you are using our REST API, the Content-Type must be application/json'
'You did not use correct Content-Type in your HTTP request. If you are using our REST API, the Content-Type must be application/json'
)
self.__init_request_error__ = exc
return drf_request

View File

@@ -71,7 +71,7 @@ class Metadata(metadata.SimpleMetadata):
'url': _('URL for this {}.'),
'related': _('Data structure with URLs of related resources.'),
'summary_fields': _(
'Data structure with name/description for related resources. ' 'The output for some objects may be limited for performance reasons.'
'Data structure with name/description for related resources. The output for some objects may be limited for performance reasons.'
),
'created': _('Timestamp when this {} was created.'),
'modified': _('Timestamp when this {} was last modified.'),

View File

@@ -220,7 +220,7 @@ class CopySerializer(serializers.Serializer):
view = self.context.get('view', None)
obj = view.get_object()
if name == obj.name:
raise serializers.ValidationError(_('The original object is already named {}, a copy from' ' it cannot have the same name.'.format(name)))
raise serializers.ValidationError(_('The original object is already named {}, a copy from it cannot have the same name.'.format(name)))
return attrs
@@ -760,7 +760,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
class UnifiedJobSerializer(BaseSerializer):
show_capabilities = ['start', 'delete']
event_processing_finished = serializers.BooleanField(
help_text=_('Indicates whether all of the events generated by this ' 'unified job have been saved to the database.'), read_only=True
help_text=_('Indicates whether all of the events generated by this unified job have been saved to the database.'), read_only=True
)
class Meta:
@@ -1579,7 +1579,7 @@ class ProjectPlaybooksSerializer(ProjectSerializer):
class ProjectInventoriesSerializer(ProjectSerializer):
inventory_files = serializers.ReadOnlyField(help_text=_('Array of inventory files and directories available within this project, ' 'not comprehensive.'))
inventory_files = serializers.ReadOnlyField(help_text=_('Array of inventory files and directories available within this project, not comprehensive.'))
class Meta:
model = Project
@@ -2905,7 +2905,7 @@ class CredentialSerializer(BaseSerializer):
):
if getattr(self.instance, related_objects).count() > 0:
raise ValidationError(
_('You cannot change the credential type of the credential, as it may break the functionality' ' of the resources using it.')
_('You cannot change the credential type of the credential, as it may break the functionality of the resources using it.')
)
return credential_type
@@ -2925,7 +2925,7 @@ class CredentialSerializerCreate(CredentialSerializer):
default=None,
write_only=True,
allow_null=True,
help_text=_('Write-only field used to add user to owner role. If provided, ' 'do not give either team or organization. Only valid for creation.'),
help_text=_('Write-only field used to add user to owner role. If provided, do not give either team or organization. Only valid for creation.'),
)
team = serializers.PrimaryKeyRelatedField(
queryset=Team.objects.all(),
@@ -2933,14 +2933,14 @@ class CredentialSerializerCreate(CredentialSerializer):
default=None,
write_only=True,
allow_null=True,
help_text=_('Write-only field used to add team to owner role. If provided, ' 'do not give either user or organization. Only valid for creation.'),
help_text=_('Write-only field used to add team to owner role. If provided, do not give either user or organization. Only valid for creation.'),
)
organization = serializers.PrimaryKeyRelatedField(
queryset=Organization.objects.all(),
required=False,
default=None,
allow_null=True,
help_text=_('Inherit permissions from organization roles. If provided on creation, ' 'do not give either user or team.'),
help_text=_('Inherit permissions from organization roles. If provided on creation, do not give either user or team.'),
)
class Meta:
@@ -2962,7 +2962,7 @@ class CredentialSerializerCreate(CredentialSerializer):
if len(owner_fields) > 1:
received = ", ".join(sorted(owner_fields))
raise serializers.ValidationError(
{"detail": _("Only one of 'user', 'team', or 'organization' should be provided, " "received {} fields.".format(received))}
{"detail": _("Only one of 'user', 'team', or 'organization' should be provided, received {} fields.".format(received))}
)
if attrs.get('team'):
@@ -3622,7 +3622,7 @@ class SystemJobSerializer(UnifiedJobSerializer):
try:
return obj.result_stdout
except StdoutMaxBytesExceeded as e:
return _("Standard Output too large to display ({text_size} bytes), " "only download supported for sizes over {supported_size} bytes.").format(
return _("Standard Output too large to display ({text_size} bytes), only download supported for sizes over {supported_size} bytes.").format(
text_size=e.total, supported_size=e.supported
)
@@ -4536,7 +4536,7 @@ class JobLaunchSerializer(BaseSerializer):
if cred.unique_hash() in provided_mapping.keys():
continue # User replaced credential with new of same type
errors.setdefault('credentials', []).append(
_('Removing {} credential at launch time without replacement is not supported. ' 'Provided list lacked credential(s): {}.').format(
_('Removing {} credential at launch time without replacement is not supported. Provided list lacked credential(s): {}.').format(
cred.unique_hash(display=True), ', '.join([str(c) for c in removed_creds])
)
)
@@ -5019,7 +5019,7 @@ class NotificationTemplateSerializer(BaseSerializer):
for subevent in event_messages:
if subevent not in ('running', 'approved', 'timed_out', 'denied'):
error_list.append(
_("Workflow Approval event '{}' invalid, must be one of " "'running', 'approved', 'timed_out', or 'denied'").format(subevent)
_("Workflow Approval event '{}' invalid, must be one of 'running', 'approved', 'timed_out', or 'denied'").format(subevent)
)
continue
subevent_messages = event_messages[subevent]
@@ -5559,7 +5559,7 @@ class InstanceGroupSerializer(BaseSerializer):
instances = serializers.SerializerMethodField()
is_container_group = serializers.BooleanField(
required=False,
help_text=_('Indicates whether instances in this group are containerized.' 'Containerized groups have a designated Openshift or Kubernetes cluster.'),
help_text=_('Indicates whether instances in this group are containerized.Containerized groups have a designated Openshift or Kubernetes cluster.'),
)
# NOTE: help_text is duplicated from field definitions, no obvious way of
# both defining field details here and also getting the field's help_text
@@ -5570,7 +5570,7 @@ class InstanceGroupSerializer(BaseSerializer):
required=False,
initial=0,
label=_('Policy Instance Percentage'),
help_text=_("Minimum percentage of all instances that will be automatically assigned to " "this group when new instances come online."),
help_text=_("Minimum percentage of all instances that will be automatically assigned to this group when new instances come online."),
)
policy_instance_minimum = serializers.IntegerField(
default=0,
@@ -5578,7 +5578,7 @@ class InstanceGroupSerializer(BaseSerializer):
required=False,
initial=0,
label=_('Policy Instance Minimum'),
help_text=_("Static minimum number of Instances that will be automatically assign to " "this group when new instances come online."),
help_text=_("Static minimum number of Instances that will be automatically assign to this group when new instances come online."),
)
max_concurrent_jobs = serializers.IntegerField(
default=0,

View File

@@ -565,7 +565,7 @@ class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
if self.relationship not in ask_mapping:
return {"msg": _("Related template cannot accept {} on launch.").format(self.relationship)}
elif sub.passwords_needed:
return {"msg": _("Credential that requires user input on launch " "cannot be used in saved launch configuration.")}
return {"msg": _("Credential that requires user input on launch cannot be used in saved launch configuration.")}
ask_field_name = ask_mapping[self.relationship]
@@ -2501,7 +2501,7 @@ class JobTemplateSurveySpec(GenericAPIView):
return Response(
dict(
error=_(
"$encrypted$ is a reserved keyword for password question defaults, " "survey question {idx} is type {survey_item[type]}."
"$encrypted$ is a reserved keyword for password question defaults, survey question {idx} is type {survey_item[type]}."
).format(**context)
),
status=status.HTTP_400_BAD_REQUEST,
@@ -3333,7 +3333,6 @@ class JobLabelList(SubListAPIView):
serializer_class = serializers.LabelSerializer
parent_model = models.Job
relationship = 'labels'
parent_key = 'job'
class WorkflowJobLabelList(JobLabelList):
@@ -4056,7 +4055,7 @@ class UnifiedJobStdout(RetrieveAPIView):
return super(UnifiedJobStdout, self).retrieve(request, *args, **kwargs)
except models.StdoutMaxBytesExceeded as e:
response_message = _(
"Standard Output too large to display ({text_size} bytes), " "only download supported for sizes over {supported_size} bytes."
"Standard Output too large to display ({text_size} bytes), only download supported for sizes over {supported_size} bytes."
).format(text_size=e.total, supported_size=e.supported)
if request.accepted_renderer.format == 'json':
return Response({'range': {'start': 0, 'end': 1, 'absolute_end': 1}, 'content': response_message})

View File

@@ -50,7 +50,7 @@ class UnifiedJobDeletionMixin(object):
return Response({"error": _("Job has not finished processing events.")}, status=status.HTTP_400_BAD_REQUEST)
else:
# if it has been > 1 minute, events are probably lost
logger.warning('Allowing deletion of {} through the API without all events ' 'processed.'.format(obj.log_format))
logger.warning('Allowing deletion of {} through the API without all events processed.'.format(obj.log_format))
# Manually cascade delete events if unpartitioned job
if obj.has_unpartitioned_events:

View File

@@ -114,7 +114,7 @@ class WebhookReceiverBase(APIView):
# Ensure that the full contents of the request are captured for multiple uses.
request.body
logger.debug("headers: {}\n" "data: {}\n".format(request.headers, request.data))
logger.debug("headers: {}\ndata: {}\n".format(request.headers, request.data))
obj = self.get_object()
self.check_signature(obj)

View File

@@ -35,7 +35,7 @@ class TestStringListBooleanField:
field = StringListBooleanField()
with pytest.raises(ValidationError) as e:
field.to_internal_value(value)
assert e.value.detail[0] == "Expected None, True, False, a string or list " "of strings but got {} instead.".format(type(value))
assert e.value.detail[0] == "Expected None, True, False, a string or list of strings but got {} instead.".format(type(value))
@pytest.mark.parametrize("value_in, value_known", FIELD_VALUES)
def test_to_representation_valid(self, value_in, value_known):
@@ -48,7 +48,7 @@ class TestStringListBooleanField:
field = StringListBooleanField()
with pytest.raises(ValidationError) as e:
field.to_representation(value)
assert e.value.detail[0] == "Expected None, True, False, a string or list " "of strings but got {} instead.".format(type(value))
assert e.value.detail[0] == "Expected None, True, False, a string or list of strings but got {} instead.".format(type(value))
class TestListTuplesField:
@@ -67,7 +67,7 @@ class TestListTuplesField:
field = ListTuplesField()
with pytest.raises(ValidationError) as e:
field.to_internal_value(value)
assert e.value.detail[0] == "Expected a list of tuples of max length 2 " "but got {} instead.".format(t)
assert e.value.detail[0] == "Expected a list of tuples of max length 2 but got {} instead.".format(t)
class TestStringListPathField:

View File

@@ -2234,7 +2234,7 @@ class WorkflowJobAccess(BaseAccess):
if not node_access.can_add({'reference_obj': node}):
wj_add_perm = False
if not wj_add_perm and self.save_messages:
self.messages['workflow_job_template'] = _('You do not have permission to the workflow job ' 'resources required for relaunch.')
self.messages['workflow_job_template'] = _('You do not have permission to the workflow job resources required for relaunch.')
return wj_add_perm
def can_cancel(self, obj):

View File

@@ -87,7 +87,7 @@ class RecordedQueryLog(object):
)
log.commit()
log.execute(
'INSERT INTO queries (pid, version, argv, time, sql, explain, bt) ' 'VALUES (?, ?, ?, ?, ?, ?, ?);',
'INSERT INTO queries (pid, version, argv, time, sql, explain, bt) VALUES (?, ?, ?, ?, ?, ?, ?);',
(os.getpid(), version, ' '.join(sys.argv), seconds, sql, explain, bt),
)
log.commit()

View File

@@ -800,7 +800,7 @@ class CredentialTypeInjectorField(JSONSchemaField):
def validate_env_var_allowed(self, env_var):
if env_var.startswith('ANSIBLE_'):
raise django_exceptions.ValidationError(
_('Environment variable {} may affect Ansible configuration so its ' 'use is not allowed in credentials.').format(env_var),
_('Environment variable {} may affect Ansible configuration so its use is not allowed in credentials.').format(env_var),
code='invalid',
params={'value': env_var},
)

View File

@@ -23,7 +23,7 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--days', dest='days', type=int, default=90, metavar='N', help='Remove activity stream events more than N days old')
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would ' 'be removed)')
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would be removed)')
def init_logging(self):
log_levels = dict(enumerate([logging.ERROR, logging.INFO, logging.DEBUG, 0]))

View File

@@ -152,7 +152,7 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--days', dest='days', type=int, default=90, metavar='N', help='Remove jobs/updates executed more than N days ago. Defaults to 90.')
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would ' 'be removed)')
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would be removed)')
parser.add_argument('--jobs', dest='only_jobs', action='store_true', default=False, help='Remove jobs')
parser.add_argument('--ad-hoc-commands', dest='only_ad_hoc_commands', action='store_true', default=False, help='Remove ad hoc commands')
parser.add_argument('--project-updates', dest='only_project_updates', action='store_true', default=False, help='Remove project updates')

View File

@@ -44,7 +44,7 @@ class Command(BaseCommand):
'- To list all (now deprecated) custom virtual environments run:',
'awx-manage list_custom_venvs',
'',
'- To export the contents of a (deprecated) virtual environment, ' 'run the following command while supplying the path as an argument:',
'- To export the contents of a (deprecated) virtual environment, run the following command while supplying the path as an argument:',
'awx-manage export_custom_venv /path/to/venv',
'',
'- Run these commands with `-q` to remove tool tips.',

View File

@@ -13,7 +13,7 @@ class Command(BaseCommand):
Deprovision a cluster node
"""
help = 'Remove instance from the database. ' 'Specify `--hostname` to use this command.'
help = 'Remove instance from the database. Specify `--hostname` to use this command.'
def add_arguments(self, parser):
parser.add_argument('--hostname', dest='hostname', type=str, help='Hostname used during provisioning')

View File

@@ -22,7 +22,7 @@ class Command(BaseCommand):
'# Discovered Virtual Environments:',
'\n'.join(venvs),
'',
'- To export the contents of a (deprecated) virtual environment, ' 'run the following command while supplying the path as an argument:',
'- To export the contents of a (deprecated) virtual environment, run the following command while supplying the path as an argument:',
'awx-manage export_custom_venv /path/to/venv',
'',
'- To view the connections a (deprecated) virtual environment had in the database, run the following command while supplying the path as an argument:',

View File

@@ -122,7 +122,7 @@ class URLModificationMiddleware(MiddlewareMixin):
field_class=fields.DictField,
read_only=True,
label=_('Formats of all available named urls'),
help_text=_('Read-only list of key-value pairs that shows the standard format of all ' 'available named URLs.'),
help_text=_('Read-only list of key-value pairs that shows the standard format of all available named URLs.'),
category=_('Named URL'),
category_slug='named-url',
)

View File

@@ -12,22 +12,17 @@ def migrate_event_data(apps, schema_editor):
# https://www.postgresql.org/docs/9.1/datatype-numeric.html)
for tblname in ('main_jobevent', 'main_inventoryupdateevent', 'main_projectupdateevent', 'main_adhoccommandevent', 'main_systemjobevent'):
with connection.cursor() as cursor:
# rename the current event table
cursor.execute(f'ALTER TABLE {tblname} RENAME TO _old_{tblname};')
# create a *new* table with the same schema
cursor.execute(f'CREATE TABLE {tblname} (LIKE _old_{tblname} INCLUDING ALL);')
# alter the *new* table so that the primary key is a big int
# This loop used to do roughly the following:
# Rename the table to _old_<tablename>
# Create a new table form the old table (it would have no rows)
# Drop the old sequnce and create a new on tied to the new table and set the sequence to the last number from the old table
# This used to work with postgres spitting out a NOTICE and DETAIL
# With the django 4.2 upgrade that changed to an ERROR and HINT
# By the time we hit the 4.2 upgrade, no one should be upgrading a database this old directly to this new schema
# So we no longer really care about having to do all of this work, we only need a table with a bigint ID field
# And this can be achieved by just changing the id column type...
cursor.execute(f'ALTER TABLE {tblname} ALTER COLUMN id TYPE bigint USING id::bigint;')
# recreate counter for the new table's primary key to
# start where the *old* table left off (we have to do this because the
# counter changed from an int to a bigint)
cursor.execute(f'DROP SEQUENCE IF EXISTS "{tblname}_id_seq" CASCADE;')
cursor.execute(f'CREATE SEQUENCE "{tblname}_id_seq";')
cursor.execute(f'ALTER TABLE "{tblname}" ALTER COLUMN "id" ' f"SET DEFAULT nextval('{tblname}_id_seq');")
cursor.execute(f"SELECT setval('{tblname}_id_seq', (SELECT MAX(id) FROM _old_{tblname}), true);")
cursor.execute(f'DROP TABLE _old_{tblname};')
class FakeAlterField(migrations.AlterField):
def database_forwards(self, *args):

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.2.16 on 2023-04-21 14:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL),
('main', '0182_constructed_inventory'),
('oauth2_provider', '0005_auto_20211222_2352'),
]
operations = [
migrations.AddField(
model_name='oauth2accesstoken',
name='id_token',
field=models.OneToOneField(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL
),
),
migrations.AddField(
model_name='oauth2application',
name='algorithm',
field=models.CharField(
blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5
),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ logger = logging.getLogger('awx.main.migrations')
def migrate_org_admin_to_use(apps, schema_editor):
logger.info('Initiated migration from Org admin to use role')
roles_added = 0
for org in Organization.objects.prefetch_related('admin_role__members').iterator():
for org in Organization.objects.prefetch_related('admin_role__members').iterator(chunk_size=1000):
igs = list(org.instance_groups.all())
if not igs:
continue

View File

@@ -158,7 +158,7 @@ class ec2(PluginFileInjector):
return {
# vars that change
'ec2_block_devices': (
"dict(block_device_mappings | map(attribute='device_name') | list | zip(block_device_mappings " "| map(attribute='ebs.volume_id') | list))"
"dict(block_device_mappings | map(attribute='device_name') | list | zip(block_device_mappings | map(attribute='ebs.volume_id') | list))"
),
'ec2_dns_name': 'public_dns_name',
'ec2_group_name': 'placement.group_name',
@@ -635,7 +635,7 @@ class satellite6(PluginFileInjector):
"environment": {
"prefix": "{}environment_".format(group_prefix),
"separator": "",
"key": "foreman['environment_name'] | lower | regex_replace(' ', '') | " "regex_replace('[^A-Za-z0-9_]', '_') | regex_replace('none', '')",
"key": "foreman['environment_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_') | regex_replace('none', '')",
},
"location": {
"prefix": "{}location_".format(group_prefix),
@@ -656,7 +656,7 @@ class satellite6(PluginFileInjector):
"content_view": {
"prefix": "{}content_view_".format(group_prefix),
"separator": "",
"key": "foreman['content_facet_attributes']['content_view_name'] | " "lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')",
"key": "foreman['content_facet_attributes']['content_view_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')",
},
}

View File

@@ -91,7 +91,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
related_name='credentials',
null=False,
on_delete=models.CASCADE,
help_text=_('Specify the type of credential you want to create. Refer ' 'to the documentation for details on each type.'),
help_text=_('Specify the type of credential you want to create. Refer to the documentation for details on each type.'),
)
managed = models.BooleanField(default=False, editable=False)
organization = models.ForeignKey(
@@ -103,7 +103,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
related_name='credentials',
)
inputs = CredentialInputField(
blank=True, default=dict, help_text=_('Enter inputs using either JSON or YAML syntax. ' 'Refer to the documentation for example syntax.')
blank=True, default=dict, help_text=_('Enter inputs using either JSON or YAML syntax. Refer to the documentation for example syntax.')
)
admin_role = ImplicitRoleField(
parent_role=[
@@ -195,6 +195,9 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
@cached_property
def dynamic_input_fields(self):
# if the credential is not yet saved we can't access the input_sources
if not self.id:
return []
return [obj.input_field_name for obj in self.input_sources.all()]
def _password_field_allows_ask(self, field):
@@ -343,12 +346,12 @@ class CredentialType(CommonModelNameNotUnique):
managed = models.BooleanField(default=False, editable=False)
namespace = models.CharField(max_length=1024, null=True, default=None, editable=False)
inputs = CredentialTypeInputField(
blank=True, default=dict, help_text=_('Enter inputs using either JSON or YAML syntax. ' 'Refer to the documentation for example syntax.')
blank=True, default=dict, help_text=_('Enter inputs using either JSON or YAML syntax. Refer to the documentation for example syntax.')
)
injectors = CredentialTypeInjectorField(
blank=True,
default=dict,
help_text=_('Enter injectors using either JSON or YAML syntax. ' 'Refer to the documentation for example syntax.'),
help_text=_('Enter injectors using either JSON or YAML syntax. Refer to the documentation for example syntax.'),
)
@classmethod
@@ -602,9 +605,7 @@ ManagedCredentialType(
'id': 'become_method',
'label': gettext_noop('Privilege Escalation Method'),
'type': 'string',
'help_text': gettext_noop(
'Specify a method for "become" operations. This is ' 'equivalent to specifying the --become-method ' 'Ansible parameter.'
),
'help_text': gettext_noop('Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.'),
},
{
'id': 'become_username',
@@ -746,7 +747,7 @@ ManagedCredentialType(
'id': 'host',
'label': gettext_noop('Host (Authentication URL)'),
'type': 'string',
'help_text': gettext_noop('The host to authenticate with. For example, ' 'https://openstack.business.com/v2.0/'),
'help_text': gettext_noop('The host to authenticate with. For example, https://openstack.business.com/v2.0/'),
},
{
'id': 'project',
@@ -797,7 +798,7 @@ ManagedCredentialType(
'id': 'host',
'label': gettext_noop('VCenter Host'),
'type': 'string',
'help_text': gettext_noop('Enter the hostname or IP address that corresponds ' 'to your VMware vCenter.'),
'help_text': gettext_noop('Enter the hostname or IP address that corresponds to your VMware vCenter.'),
},
{'id': 'username', 'label': gettext_noop('Username'), 'type': 'string'},
{
@@ -822,7 +823,7 @@ ManagedCredentialType(
'id': 'host',
'label': gettext_noop('Satellite 6 URL'),
'type': 'string',
'help_text': gettext_noop('Enter the URL that corresponds to your Red Hat ' 'Satellite 6 server. For example, https://satellite.example.org'),
'help_text': gettext_noop('Enter the URL that corresponds to your Red Hat Satellite 6 server. For example, https://satellite.example.org'),
},
{'id': 'username', 'label': gettext_noop('Username'), 'type': 'string'},
{
@@ -847,7 +848,7 @@ ManagedCredentialType(
'id': 'username',
'label': gettext_noop('Service Account Email Address'),
'type': 'string',
'help_text': gettext_noop('The email address assigned to the Google Compute ' 'Engine service account.'),
'help_text': gettext_noop('The email address assigned to the Google Compute Engine service account.'),
},
{
'id': 'project',
@@ -867,7 +868,7 @@ ManagedCredentialType(
'format': 'ssh_private_key',
'secret': True,
'multiline': True,
'help_text': gettext_noop('Paste the contents of the PEM file associated ' 'with the service account email.'),
'help_text': gettext_noop('Paste the contents of the PEM file associated with the service account email.'),
},
],
'required': ['username', 'ssh_key_data'],
@@ -885,7 +886,7 @@ ManagedCredentialType(
'id': 'subscription',
'label': gettext_noop('Subscription ID'),
'type': 'string',
'help_text': gettext_noop('Subscription ID is an Azure construct, which is ' 'mapped to a username.'),
'help_text': gettext_noop('Subscription ID is an Azure construct, which is mapped to a username.'),
},
{'id': 'username', 'label': gettext_noop('Username'), 'type': 'string'},
{
@@ -906,7 +907,7 @@ ManagedCredentialType(
'id': 'cloud_environment',
'label': gettext_noop('Azure Cloud Environment'),
'type': 'string',
'help_text': gettext_noop('Environment variable AZURE_CLOUD_ENVIRONMENT when' ' using Azure GovCloud or Azure stack.'),
'help_text': gettext_noop('Environment variable AZURE_CLOUD_ENVIRONMENT when using Azure GovCloud or Azure stack.'),
},
],
'required': ['subscription'],
@@ -1037,7 +1038,7 @@ ManagedCredentialType(
'label': gettext_noop('Username'),
'type': 'string',
'help_text': gettext_noop(
'Red Hat Ansible Automation Platform username id to authenticate as.' 'This should not be set if an OAuth token is being used.'
'Red Hat Ansible Automation Platform username id to authenticate as.This should not be set if an OAuth token is being used.'
),
},
{
@@ -1051,7 +1052,7 @@ ManagedCredentialType(
'label': gettext_noop('OAuth Token'),
'type': 'string',
'secret': True,
'help_text': gettext_noop('An OAuth token to use to authenticate with.' 'This should not be set if username/password are being used.'),
'help_text': gettext_noop('An OAuth token to use to authenticate with.This should not be set if username/password are being used.'),
},
{'id': 'verify_ssl', 'label': gettext_noop('Verify SSL'), 'type': 'boolean', 'secret': False},
],
@@ -1162,7 +1163,7 @@ ManagedCredentialType(
'id': 'auth_url',
'label': gettext_noop('Auth Server URL'),
'type': 'string',
'help_text': gettext_noop('The URL of a Keycloak server token_endpoint, if using ' 'SSO auth.'),
'help_text': gettext_noop('The URL of a Keycloak server token_endpoint, if using SSO auth.'),
},
{
'id': 'token',

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import datetime
from datetime import timezone
import logging
from collections import defaultdict
@@ -10,7 +11,7 @@ from django.db import models, DatabaseError
from django.db.models.functions import Cast
from django.utils.dateparse import parse_datetime
from django.utils.text import Truncator
from django.utils.timezone import utc, now
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str
@@ -422,7 +423,7 @@ class BasePlaybookEvent(CreatedModifiedModel):
if not isinstance(kwargs['created'], datetime.datetime):
kwargs['created'] = parse_datetime(kwargs['created'])
if not kwargs['created'].tzinfo:
kwargs['created'] = kwargs['created'].replace(tzinfo=utc)
kwargs['created'] = kwargs['created'].replace(tzinfo=timezone.utc)
except (KeyError, ValueError):
kwargs.pop('created', None)
@@ -432,7 +433,7 @@ class BasePlaybookEvent(CreatedModifiedModel):
if not isinstance(kwargs['job_created'], datetime.datetime):
kwargs['job_created'] = parse_datetime(kwargs['job_created'])
if not kwargs['job_created'].tzinfo:
kwargs['job_created'] = kwargs['job_created'].replace(tzinfo=utc)
kwargs['job_created'] = kwargs['job_created'].replace(tzinfo=timezone.utc)
except (KeyError, ValueError):
kwargs.pop('job_created', None)
@@ -470,11 +471,11 @@ class JobEvent(BasePlaybookEvent):
class Meta:
app_label = 'main'
ordering = ('pk',)
index_together = [
('job', 'job_created', 'event'),
('job', 'job_created', 'uuid'),
('job', 'job_created', 'parent_uuid'),
('job', 'job_created', 'counter'),
indexes = [
models.Index(fields=['job', 'job_created', 'event']),
models.Index(fields=['job', 'job_created', 'uuid']),
models.Index(fields=['job', 'job_created', 'parent_uuid']),
models.Index(fields=['job', 'job_created', 'counter']),
]
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
@@ -632,10 +633,10 @@ class ProjectUpdateEvent(BasePlaybookEvent):
class Meta:
app_label = 'main'
ordering = ('pk',)
index_together = [
('project_update', 'job_created', 'event'),
('project_update', 'job_created', 'uuid'),
('project_update', 'job_created', 'counter'),
indexes = [
models.Index(fields=['project_update', 'job_created', 'event']),
models.Index(fields=['project_update', 'job_created', 'uuid']),
models.Index(fields=['project_update', 'job_created', 'counter']),
]
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
@@ -734,7 +735,7 @@ class BaseCommandEvent(CreatedModifiedModel):
if not isinstance(kwargs['created'], datetime.datetime):
kwargs['created'] = parse_datetime(kwargs['created'])
if not kwargs['created'].tzinfo:
kwargs['created'] = kwargs['created'].replace(tzinfo=utc)
kwargs['created'] = kwargs['created'].replace(tzinfo=timezone.utc)
except (KeyError, ValueError):
kwargs.pop('created', None)
@@ -770,10 +771,10 @@ class AdHocCommandEvent(BaseCommandEvent):
class Meta:
app_label = 'main'
ordering = ('-pk',)
index_together = [
('ad_hoc_command', 'job_created', 'event'),
('ad_hoc_command', 'job_created', 'uuid'),
('ad_hoc_command', 'job_created', 'counter'),
indexes = [
models.Index(fields=['ad_hoc_command', 'job_created', 'event']),
models.Index(fields=['ad_hoc_command', 'job_created', 'uuid']),
models.Index(fields=['ad_hoc_command', 'job_created', 'counter']),
]
EVENT_TYPES = [
@@ -875,9 +876,9 @@ class InventoryUpdateEvent(BaseCommandEvent):
class Meta:
app_label = 'main'
ordering = ('-pk',)
index_together = [
('inventory_update', 'job_created', 'uuid'),
('inventory_update', 'job_created', 'counter'),
indexes = [
models.Index(fields=['inventory_update', 'job_created', 'uuid']),
models.Index(fields=['inventory_update', 'job_created', 'counter']),
]
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
@@ -920,9 +921,9 @@ class SystemJobEvent(BaseCommandEvent):
class Meta:
app_label = 'main'
ordering = ('-pk',)
index_together = [
('system_job', 'job_created', 'uuid'),
('system_job', 'job_created', 'counter'),
indexes = [
models.Index(fields=['system_job', 'job_created', 'uuid']),
models.Index(fields=['system_job', 'job_created', 'counter']),
]
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')

View File

@@ -106,28 +106,28 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
has_active_failures = models.BooleanField(
default=False,
editable=False,
help_text=_('This field is deprecated and will be removed in a future release. ' 'Flag indicating whether any hosts in this inventory have failed.'),
help_text=_('This field is deprecated and will be removed in a future release. Flag indicating whether any hosts in this inventory have failed.'),
)
total_hosts = models.PositiveIntegerField(
default=0,
editable=False,
help_text=_('This field is deprecated and will be removed in a future release. ' 'Total number of hosts in this inventory.'),
help_text=_('This field is deprecated and will be removed in a future release. Total number of hosts in this inventory.'),
)
hosts_with_active_failures = models.PositiveIntegerField(
default=0,
editable=False,
help_text=_('This field is deprecated and will be removed in a future release. ' 'Number of hosts in this inventory with active failures.'),
help_text=_('This field is deprecated and will be removed in a future release. Number of hosts in this inventory with active failures.'),
)
total_groups = models.PositiveIntegerField(
default=0,
editable=False,
help_text=_('This field is deprecated and will be removed in a future release. ' 'Total number of groups in this inventory.'),
help_text=_('This field is deprecated and will be removed in a future release. Total number of groups in this inventory.'),
)
has_inventory_sources = models.BooleanField(
default=False,
editable=False,
help_text=_(
'This field is deprecated and will be removed in a future release. ' 'Flag indicating whether this inventory has any external inventory sources.'
'This field is deprecated and will be removed in a future release. Flag indicating whether this inventory has any external inventory sources.'
),
)
total_inventory_sources = models.PositiveIntegerField(
@@ -424,7 +424,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
for t in tasks:
t.task_impact = t._get_task_impact()
UnifiedJob.objects.bulk_update(tasks, ['task_impact'])
logger.debug("Finished updating inventory computed fields, pk={0}, in " "{1:.3f} seconds".format(self.pk, time.time() - start_time))
logger.debug("Finished updating inventory computed fields, pk={0}, in {1:.3f} seconds".format(self.pk, time.time() - start_time))
def websocket_emit_status(self, status):
connection.on_commit(
@@ -1055,16 +1055,16 @@ class InventorySourceOptions(BaseModel):
# the actual inventory source being used (Amazon requires Amazon
# credentials; Rackspace requires Rackspace credentials; etc...)
if source.replace('ec2', 'aws') != cred.kind:
return _('Cloud-based inventory sources (such as %s) require ' 'credentials for the matching cloud service.') % source
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
# from the instance metadata instead of those explicitly provided.
elif source in CLOUD_PROVIDERS and source != 'ec2':
return _('Credential is required for a cloud source.')
elif source == 'custom' and cred and cred.credential_type.kind in ('scm', 'ssh', 'insights', 'vault'):
return _('Credentials of type machine, source control, insights and vault are ' 'disallowed for custom inventory sources.')
return _('Credentials of type machine, source control, insights and vault are disallowed for custom inventory sources.')
elif source == 'scm' and cred and cred.credential_type.kind in ('insights', 'vault'):
return _('Credentials of type insights and vault are ' 'disallowed for scm inventory sources.')
return _('Credentials of type insights and vault are disallowed for scm inventory sources.')
return None
def get_cloud_credential(self):

View File

@@ -101,7 +101,7 @@ class JobOptions(BaseModel):
max_length=1024,
default='',
blank=True,
help_text=_('Branch to use in job run. Project default used if blank. ' 'Only allowed if project allow_override field is set to true.'),
help_text=_('Branch to use in job run. Project default used if blank. Only allowed if project allow_override field is set to true.'),
)
forks = models.PositiveIntegerField(
blank=True,
@@ -253,7 +253,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
job_slice_count = models.PositiveIntegerField(
blank=True,
default=1,
help_text=_("The number of jobs to slice into at runtime. " "Will cause the Job Template to launch a workflow if value is greater than 1."),
help_text=_("The number of jobs to slice into at runtime. Will cause the Job Template to launch a workflow if value is greater than 1."),
)
admin_role = ImplicitRoleField(parent_role=['organization.job_template_admin_role'])
@@ -596,12 +596,12 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
job_slice_number = models.PositiveIntegerField(
blank=True,
default=0,
help_text=_("If part of a sliced job, the ID of the inventory slice operated on. " "If not part of sliced job, parameter is not used."),
help_text=_("If part of a sliced job, the ID of the inventory slice operated on. If not part of sliced job, parameter is not used."),
)
job_slice_count = models.PositiveIntegerField(
blank=True,
default=1,
help_text=_("If ran as part of sliced jobs, the total number of slices. " "If 1, job is not part of a sliced job."),
help_text=_("If ran as part of sliced jobs, the total number of slices. If 1, job is not part of a sliced job."),
)
def _get_parent_field_name(self):

View File

@@ -675,4 +675,4 @@ class WebhookMixin(models.Model):
if response.status_code < 400:
logger.debug("Webhook status update sent.")
else:
logger.error("Posting webhook status failed, code: {}\n" "{}\n" "Payload sent: {}".format(response.status_code, response.text, json.dumps(data)))
logger.error("Posting webhook status failed, code: {}\n" "{}\nPayload sent: {}".format(response.status_code, response.text, json.dumps(data)))

View File

@@ -74,7 +74,7 @@ class ProjectOptions(models.Model):
return []
local_path = models.CharField(
max_length=1024, blank=True, help_text=_('Local path (relative to PROJECTS_ROOT) containing ' 'playbooks and related files for this project.')
max_length=1024, blank=True, help_text=_('Local path (relative to PROJECTS_ROOT) containing playbooks and related files for this project.')
)
scm_type = models.CharField(
@@ -276,11 +276,11 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
scm_update_cache_timeout = models.PositiveIntegerField(
default=0,
blank=True,
help_text=_('The number of seconds after the last project update ran that a new ' 'project update will be launched as a job dependency.'),
help_text=_('The number of seconds after the last project update ran that a new project update will be launched as a job dependency.'),
)
allow_override = models.BooleanField(
default=False,
help_text=_('Allow changing the SCM branch or revision in a job template ' 'that uses this project.'),
help_text=_('Allow changing the SCM branch or revision in a job template that uses this project.'),
)
# credential (keys) used to validate content signature

View File

@@ -141,7 +141,7 @@ class Role(models.Model):
app_label = 'main'
verbose_name_plural = _('roles')
db_table = 'main_rbac_roles'
index_together = [("content_type", "object_id")]
indexes = [models.Index(fields=["content_type", "object_id"])]
ordering = ("content_type", "object_id")
role_field = models.TextField(null=False)
@@ -447,10 +447,10 @@ class RoleAncestorEntry(models.Model):
app_label = 'main'
verbose_name_plural = _('role_ancestors')
db_table = 'main_rbac_role_ancestors'
index_together = [
("ancestor", "content_type_id", "object_id"), # used by get_roles_on_resource
("ancestor", "content_type_id", "role_field"), # used by accessible_objects
("ancestor", "descendent"), # used by rebuild_role_ancestor_list in the NOT EXISTS clauses.
indexes = [
models.Index(fields=["ancestor", "content_type_id", "object_id"]), # used by get_roles_on_resource
models.Index(fields=["ancestor", "content_type_id", "role_field"]), # used by accessible_objects
models.Index(fields=["ancestor", "descendent"]), # used by rebuild_role_ancestor_list in the NOT EXISTS clauses.
]
descendent = models.ForeignKey(Role, null=False, on_delete=models.CASCADE, related_name='+')

View File

@@ -82,7 +82,7 @@ class WorkflowNodeBase(CreatedModifiedModel, LaunchTimeConfig):
related_name='%(class)ss_always',
)
all_parents_must_converge = models.BooleanField(
default=False, help_text=_("If enabled then the node will only run if all of the parent nodes " "have met the criteria to reach this node")
default=False, help_text=_("If enabled then the node will only run if all of the parent nodes have met the criteria to reach this node")
)
unified_job_template = models.ForeignKey(
'UnifiedJobTemplate',
@@ -181,7 +181,7 @@ class WorkflowJobTemplateNode(WorkflowNodeBase):
max_length=512,
default=uuid4,
blank=False,
help_text=_('An identifier for this node that is unique within its workflow. ' 'It is copied to workflow job nodes corresponding to this node.'),
help_text=_('An identifier for this node that is unique within its workflow. It is copied to workflow job nodes corresponding to this node.'),
)
instance_groups = OrderedManyToManyField(
'InstanceGroup',
@@ -334,7 +334,7 @@ class WorkflowJobNode(WorkflowNodeBase):
accepted_fields, ignored_fields, errors = ujt_obj._accept_or_ignore_job_kwargs(**node_prompts_data)
if errors:
logger.info(
_('Bad launch configuration starting template {template_pk} as part of ' 'workflow {workflow_pk}. Errors:\n{error_text}').format(
_('Bad launch configuration starting template {template_pk} as part of workflow {workflow_pk}. Errors:\n{error_text}').format(
template_pk=ujt_obj.pk, workflow_pk=self.pk, error_text=errors
)
)
@@ -647,7 +647,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
null=True,
default=None,
on_delete=models.SET_NULL,
help_text=_("If automatically created for a sliced job run, the job template " "the workflow job was created from."),
help_text=_("If automatically created for a sliced job run, the job template the workflow job was created from."),
)
is_sliced_job = models.BooleanField(default=False)
is_bulk_job = models.BooleanField(default=False)
@@ -714,7 +714,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
wj = self.get_workflow_job()
while wj and wj.workflow_job_template_id:
if wj.pk in wj_ids:
logger.critical('Cycles detected in the workflow jobs graph, ' 'this is not normal and suggests task manager degeneracy.')
logger.critical('Cycles detected in the workflow jobs graph, this is not normal and suggests task manager degeneracy.')
break
wj_ids.add(wj.pk)
ancestors.append(wj.workflow_job_template)

View File

@@ -8,7 +8,7 @@ class CustomNotificationBase(object):
DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}'
DEFAULT_APPROVAL_RUNNING_BODY = (
'The approval node "{{ approval_node_name }}" needs review. ' 'This approval node can be viewed at: {{ workflow_url }}\n\n{{ job_metadata }}'
'The approval node "{{ approval_node_name }}" needs review. This approval node can be viewed at: {{ workflow_url }}\n\n{{ job_metadata }}'
)
DEFAULT_APPROVAL_APPROVED_MSG = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}'

View File

@@ -32,7 +32,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
"success": {"body": DEFAULT_BODY},
"error": {"body": DEFAULT_BODY},
"workflow_approval": {
"running": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" needs review. ' 'This node can be viewed at: {{ workflow_url }}"}'},
"running": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" needs review. This node can be viewed at: {{ workflow_url }}"}'},
"approved": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was approved. {{ workflow_url }}"}'},
"timed_out": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" has timed out. {{ workflow_url }}"}'},
"denied": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was denied. {{ workflow_url }}"}'},

View File

@@ -77,7 +77,7 @@ def test_credential_validation_error_with_multiple_owner_fields(post, admin, ali
}
response = post(reverse('api:credential_list'), params, admin)
assert response.status_code == 400
assert response.data['detail'][0] == ("Only one of 'user', 'team', or 'organization' should be provided, " "received organization, team, user fields.")
assert response.data['detail'][0] == ("Only one of 'user', 'team', or 'organization' should be provided, received organization, team, user fields.")
@pytest.mark.django_db
@@ -925,7 +925,7 @@ def test_credential_type_mutability(patch, organization, admin, credentialtype_s
response = _change_credential_type()
assert response.status_code == 400
expected = ['You cannot change the credential type of the credential, ' 'as it may break the functionality of the resources using it.']
expected = ['You cannot change the credential type of the credential, as it may break the functionality of the resources using it.']
assert response.data['credential_type'] == expected
response = patch(reverse('api:credential_detail', kwargs={'pk': cred.pk}), {'name': 'Worst credential ever'}, admin)
@@ -962,7 +962,7 @@ def test_vault_credential_type_mutability(patch, organization, admin, credential
response = _change_credential_type()
assert response.status_code == 400
expected = ['You cannot change the credential type of the credential, ' 'as it may break the functionality of the resources using it.']
expected = ['You cannot change the credential type of the credential, as it may break the functionality of the resources using it.']
assert response.data['credential_type'] == expected
response = patch(reverse('api:credential_detail', kwargs={'pk': cred.pk}), {'name': 'Worst credential ever'}, admin)
@@ -994,7 +994,7 @@ def test_cloud_credential_type_mutability(patch, organization, admin, credential
response = _change_credential_type()
assert response.status_code == 400
expected = ['You cannot change the credential type of the credential, ' 'as it may break the functionality of the resources using it.']
expected = ['You cannot change the credential type of the credential, as it may break the functionality of the resources using it.']
assert response.data['credential_type'] == expected
response = patch(reverse('api:credential_detail', kwargs={'pk': cred.pk}), {'name': 'Worst credential ever'}, admin)

View File

@@ -51,6 +51,16 @@ def test_job_relaunch_permission_denied_response(post, get, inventory, project,
r = post(reverse('api:job_relaunch', kwargs={'pk': job.pk}), {}, jt_user, expect=201)
@pytest.mark.django_db
def test_label_sublist(get, admin_user, organization):
job = Job.objects.create()
label = Label.objects.create(organization=organization, name='Steve')
job.labels.add(label)
r = get(url=reverse('api:job_label_list', kwargs={'pk': job.pk}), user=admin_user, expect=200)
assert r.data['count'] == 1
assert r.data['results'].pop()['id'] == label.id
@pytest.mark.django_db
def test_job_relaunch_prompts_not_accepted_response(post, get, inventory, project, credential, net_credential, machine_credential):
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project)

View File

@@ -271,6 +271,7 @@ def test_inventory_update_excessively_long_name(inventory, inventory_source):
class TestHostManager:
def test_host_filter_not_smart(self, setup_ec2_gce, organization):
smart_inventory = Inventory(name='smart', organization=organization, host_filter='inventory_sources__source=ec2')
smart_inventory.save()
assert len(smart_inventory.hosts.all()) == 0
def test_host_distinctness(self, setup_inventory_groups, organization):

View File

@@ -121,7 +121,7 @@ def read_content(private_data_dir, raw_env, inventory_update):
break
alias = 'file_reference_{}'.format(i)
else:
raise RuntimeError('Test not able to cope with >10 references by env vars. ' 'Something probably went very wrong.')
raise RuntimeError('Test not able to cope with >10 references by env vars. Something probably went very wrong.')
file_aliases[abs_file_path] = alias
for env_key in inverse_env[runner_path]:
env[env_key] = '{{{{ {} }}}}'.format(alias)
@@ -234,7 +234,7 @@ def test_inventory_update_injected_content(this_kind, inventory, fake_credential
source_dir = os.path.join(base_dir, this_kind) # this_kind is a global
if not os.path.exists(source_dir):
raise FileNotFoundError('Maybe you never made reference files? ' 'MAKE_INVENTORY_REFERENCE_FILES=true py.test ...\noriginal: {}')
raise FileNotFoundError('Maybe you never made reference files? MAKE_INVENTORY_REFERENCE_FILES=true py.test ...\noriginal: {}')
files_dir = os.path.join(source_dir, 'files')
try:
expected_file_list = os.listdir(files_dir)

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from django.utils.timezone import utc
from datetime import timezone
import pytest
from awx.main.models import JobEvent, ProjectUpdateEvent, AdHocCommandEvent, InventoryUpdateEvent, SystemJobEvent
@@ -18,7 +18,7 @@ from awx.main.models import JobEvent, ProjectUpdateEvent, AdHocCommandEvent, Inv
@pytest.mark.parametrize('created', [datetime(2018, 1, 1).isoformat(), datetime(2018, 1, 1)])
def test_event_parse_created(job_identifier, cls, created):
event = cls.create_from_data(**{job_identifier: 123, 'created': created})
assert event.created == datetime(2018, 1, 1).replace(tzinfo=utc)
assert event.created == datetime(2018, 1, 1).replace(tzinfo=timezone.utc)
@pytest.mark.parametrize(

View File

@@ -371,7 +371,7 @@ class TestExtraVarSanitation(TestJobExecution):
# are deemed trustable, because they can only be added by users w/ enough
# privilege to add/modify a Job Template)
UNSAFE = '{{ lookup(' 'pipe' ',' 'ls -la' ') }}'
UNSAFE = "{{ lookup('pipe', 'ls -la') }}"
def test_vars_unsafe_by_default(self, job, private_data_dir, mock_me):
job.created_by = User(pk=123, username='angry-spud')

View File

@@ -88,6 +88,6 @@ def test_global_creation_always_possible(all_views):
creatable_view = View
if not creatable or not global_view:
continue
assert 'POST' in global_view().allowed_methods, 'Resource {} should be creatable in global list view {}. ' 'Can be created now in {}'.format(
assert 'POST' in global_view().allowed_methods, 'Resource {} should be creatable in global list view {}. Can be created now in {}'.format(
model, global_view, creatable_view
)

View File

@@ -93,7 +93,7 @@ class TestSmartFilterQueryFromString:
@pytest.mark.parametrize(
"filter_string",
[
'ansible_facts__facts__facts__blank=' 'ansible_facts__a__b__c__ space =ggg',
'ansible_facts__facts__facts__blank=ansible_facts__a__b__c__ space =ggg',
],
)
def test_invalid_filter_strings(self, mock_get_host_model, filter_string):
@@ -104,7 +104,7 @@ class TestSmartFilterQueryFromString:
@pytest.mark.parametrize(
"filter_string",
[
'created_by__password__icontains=pbkdf2' 'search=foo or created_by__password__icontains=pbkdf2',
'created_by__password__icontains=pbkdf2search=foo or created_by__password__icontains=pbkdf2',
'created_by__password__icontains=pbkdf2 or search=foo',
],
)

View File

@@ -716,7 +716,7 @@ def parse_yaml_or_json(vars_str, silent_failure=True):
if silent_failure:
return {}
raise ParseError(
_('Cannot parse as JSON (error: {json_error}) or ' 'YAML (error: {yaml_error}).').format(json_error=str(json_err), yaml_error=str(yaml_err))
_('Cannot parse as JSON (error: {json_error}) or YAML (error: {yaml_error}).').format(json_error=str(json_err), yaml_error=str(yaml_err))
)
return vars_dict

View File

@@ -253,7 +253,7 @@ def dict_to_mem_data(data, inventory=None):
if isinstance(hv, dict):
host.variables.update(hv)
else:
logger.warning('Expected dict of vars for ' 'host "%s", got %s instead', hk, str(type(hv)))
logger.warning('Expected dict of vars for host "%s", got %s instead', hk, str(type(hv)))
group.add_host(host)
elif isinstance(hosts, (list, tuple)):
for hk in hosts:
@@ -262,13 +262,13 @@ def dict_to_mem_data(data, inventory=None):
continue
group.add_host(host)
else:
logger.warning('Expected dict or list of "hosts" for ' 'group "%s", got %s instead', k, str(type(hosts)))
logger.warning('Expected dict or list of "hosts" for group "%s", got %s instead', k, str(type(hosts)))
# Process group variables.
vars = v.get('vars', {})
if isinstance(vars, dict):
group.variables.update(vars)
else:
logger.warning('Expected dict of vars for ' 'group "%s", got %s instead', k, str(type(vars)))
logger.warning('Expected dict of vars for group "%s", got %s instead', k, str(type(vars)))
# Process child groups.
children = v.get('children', [])
if isinstance(children, (list, tuple)):
@@ -277,7 +277,7 @@ def dict_to_mem_data(data, inventory=None):
if child and c != 'ungrouped':
group.add_child_group(child)
else:
logger.warning('Expected list of children for ' 'group "%s", got %s instead', k, str(type(children)))
logger.warning('Expected list of children for group "%s", got %s instead', k, str(type(children)))
# Load host names from a list.
elif isinstance(v, (list, tuple)):
@@ -288,7 +288,7 @@ def dict_to_mem_data(data, inventory=None):
group.add_host(host)
else:
logger.warning('')
logger.warning('Expected dict or list for group "%s", ' 'got %s instead', k, str(type(v)))
logger.warning('Expected dict or list for group "%s", got %s instead', k, str(type(v)))
if k not in ['all', 'ungrouped']:
inventory.all_group.add_child_group(group)
@@ -299,6 +299,6 @@ def dict_to_mem_data(data, inventory=None):
if isinstance(meta_hostvars, dict):
v.variables.update(meta_hostvars)
else:
logger.warning('Expected dict of vars for ' 'host "%s", got %s instead', k, str(type(meta_hostvars)))
logger.warning('Expected dict of vars for host "%s", got %s instead', k, str(type(meta_hostvars)))
return inventory

View File

@@ -5,13 +5,14 @@ from typing import Dict
import aiohttp
from aiohttp import client_exceptions
import aioredis
from channels.layers import get_channel_layer
from django.conf import settings
from django.apps import apps
import psycopg
import asyncpg
from awx.main.analytics.broadcast_websocket import (
RelayWebsocketStats,
@@ -180,6 +181,9 @@ class WebsocketRelayConnection:
return
continue
except aioredis.errors.ConnectionClosedError:
logger.info(f"Producer {name} lost connection to Redis, shutting down.")
return
await websocket.send_json(wrap_broadcast_msg(group, msg))
except ConnectionResetError:
@@ -205,64 +209,92 @@ class WebSocketRelayManager(object):
# hostname -> ip
self.known_hosts: Dict[str, str] = dict()
async def pg_consumer(self, conn):
async def on_heartbeet(self, conn, pid, channel, payload):
try:
await conn.execute("LISTEN web_heartbeet")
async for notif in conn.notifies():
if notif is not None and notif.channel == "web_heartbeet":
try:
payload = json.loads(notif.payload)
except json.JSONDecodeError:
logmsg = "Failed to decode message from pg_notify channel `web_heartbeet`"
if logger.isEnabledFor(logging.DEBUG):
logmsg = "{} {}".format(logmsg, payload)
logger.warning(logmsg)
continue
if not payload or channel != "web_heartbeet":
return
# Skip if the message comes from the same host we are running on
# In this case, we'll be sharing a redis, no need to relay.
if payload.get("hostname") == self.local_hostname:
continue
try:
payload = json.loads(payload)
except json.JSONDecodeError:
logmsg = "Failed to decode message from pg_notify channel `web_heartbeet`"
if logger.isEnabledFor(logging.DEBUG):
logmsg = "{} {}".format(logmsg, payload)
logger.warning(logmsg)
return
if payload.get("action") == "online":
hostname = payload["hostname"]
ip = payload["ip"]
if ip is None:
# If we don't get an IP, just try the hostname, maybe it resolves
ip = hostname
self.known_hosts[hostname] = ip
logger.debug(f"Web host {hostname} ({ip}) online heartbeat received.")
elif payload.get("action") == "offline":
hostname = payload["hostname"]
del self.known_hosts[hostname]
logger.debug(f"Web host {hostname} ({ip}) offline heartbeat received.")
# Skip if the message comes from the same host we are running on
# In this case, we'll be sharing a redis, no need to relay.
if payload.get("hostname") == self.local_hostname:
return
if payload.get("action") == "online":
hostname = payload.get("hostname")
ip = payload.get("ip")
if ip is None:
# If we don't get an IP, just try the hostname, maybe it resolves
ip = hostname
if ip is None:
logger.warning(f"Received invalid online heartbeet, missing hostname and ip: {payload}")
return
self.known_hosts[hostname] = ip
logger.debug(f"Web host {hostname} ({ip}) online heartbeat received.")
elif payload.get("action") == "offline":
hostname = payload.get("hostname")
ip = payload.get("ip")
if ip is None:
# If we don't get an IP, just try the hostname, maybe it resolves
ip = hostname
if ip is None:
logger.warning(f"Received invalid offline heartbeet, missing hostname and ip: {payload}")
return
self.cleanup_offline_host(ip)
logger.debug(f"Web host {hostname} ({ip}) offline heartbeat received.")
except Exception as e:
# This catch-all is the same as the one above. asyncio will eat the exception
# but we want to know about it.
logger.exception(f"pg_consumer exception: {e}")
logger.exception(f"on_heartbeet exception: {e}")
def cleanup_offline_host(self, hostname):
"""
Given a hostname, try to cancel its task/connection and remove it from
the list of hosts we know about.
If the host isn't in the list, assume that it was already deleted and
don't error.
"""
if hostname in self.relay_connections:
self.relay_connections[hostname].cancel()
del self.relay_connections[hostname]
if hostname in self.known_hosts:
del self.known_hosts[hostname]
try:
self.stats_mgr.delete_remote_host_stats(hostname)
except KeyError:
pass
async def run(self):
event_loop = asyncio.get_running_loop()
stats_mgr = RelayWebsocketStatsManager(event_loop, self.local_hostname)
stats_mgr.start()
self.stats_mgr = RelayWebsocketStatsManager(event_loop, self.local_hostname)
self.stats_mgr.start()
# Set up a pg_notify consumer for allowing web nodes to "provision" and "deprovision" themselves gracefully.
database_conf = settings.DATABASES['default']
async_conn = await psycopg.AsyncConnection.connect(
dbname=database_conf['NAME'],
async_conn = await asyncpg.connect(
database=database_conf['NAME'],
host=database_conf['HOST'],
user=database_conf['USER'],
password=database_conf['PASSWORD'],
port=database_conf['PORT'],
**database_conf.get("OPTIONS", {}),
# We cannot include these because asyncpg doesn't allow all the options that psycopg does.
# **database_conf.get("OPTIONS", {}),
)
await async_conn.set_autocommit(True)
event_loop.create_task(self.pg_consumer(async_conn))
await async_conn.add_listener("web_heartbeet", self.on_heartbeet)
# Establishes a websocket connection to /websocket/relay on all API servers
while True:
# logger.info("Current known hosts: {}".format(self.known_hosts))
future_remote_hosts = self.known_hosts.keys()
current_remote_hosts = self.relay_connections.keys()
deleted_remote_hosts = set(current_remote_hosts) - set(future_remote_hosts)
@@ -291,13 +323,10 @@ class WebSocketRelayManager(object):
logger.info(f"Adding {new_remote_hosts} to websocket broadcast list")
for h in deleted_remote_hosts:
self.relay_connections[h].cancel()
del self.relay_connections[h]
del self.known_hosts[h]
stats_mgr.delete_remote_host_stats(h)
self.cleanup_offline_host(h)
for h in new_remote_hosts:
stats = stats_mgr.new_remote_host_stats(h)
stats = self.stats_mgr.new_remote_host_stats(h)
relay_connection = WebsocketRelayConnection(name=self.local_hostname, stats=stats, remote_host=self.known_hosts[h])
relay_connection.start()
self.relay_connections[h] = relay_connection

View File

@@ -58,7 +58,7 @@ class ActionModule(ActionBase):
if res.status_code != 200:
result['failed'] = True
result['msg'] = 'Expected {} to return a status code of 200 but returned status ' 'code "{}" instead with content "{}".'.format(
result['msg'] = 'Expected {} to return a status code of 200 but returned status code "{}" instead with content "{}".'.format(
url, res.status_code, res.content
)
return result
@@ -87,7 +87,7 @@ class ActionModule(ActionBase):
continue
elif res.status_code != 200:
result['failed'] = True
result['msg'] = 'Expected {} to return a status code of 200 but returned status ' 'code "{}" instead with content "{}".'.format(
result['msg'] = 'Expected {} to return a status code of 200 but returned status code "{}" instead with content "{}".'.format(
playbook_url, res.status_code, res.content
)
return result

View File

@@ -395,6 +395,7 @@ AUTHENTICATION_BACKENDS = (
OAUTH2_PROVIDER_APPLICATION_MODEL = 'main.OAuth2Application'
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'main.OAuth2AccessToken'
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken'
OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken"
OAUTH2_PROVIDER = {'ACCESS_TOKEN_EXPIRE_SECONDS': 31536000000, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 600, 'REFRESH_TOKEN_EXPIRE_SECONDS': 2628000}
ALLOW_OAUTH2_FOR_EXTERNAL_USERS = False

View File

@@ -269,7 +269,7 @@ class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider):
value = value[0]
if conf_key in ('attr_first_name', 'attr_last_name', 'attr_username', 'attr_email') and value is None:
logger.warning(
"Could not map user detail '%s' from SAML attribute '%s'; " "update SOCIAL_AUTH_SAML_ENABLED_IDPS['%s']['%s'] with the correct SAML attribute.",
"Could not map user detail '%s' from SAML attribute '%s'; update SOCIAL_AUTH_SAML_ENABLED_IDPS['%s']['%s'] with the correct SAML attribute.",
conf_key[5:],
key,
self.name,

View File

@@ -100,7 +100,7 @@ register(
'AUTHENTICATION_BACKENDS',
field_class=AuthenticationBackendsField,
label=_('Authentication Backends'),
help_text=_('List of authentication backends that are enabled based on ' 'license features and other authentication settings.'),
help_text=_('List of authentication backends that are enabled based on license features and other authentication settings.'),
read_only=True,
depends_on=AuthenticationBackendsField.get_all_required_settings(),
category=_('Authentication'),
@@ -360,7 +360,7 @@ def _register_ldap(append=None):
default=None,
label=_('LDAP Deny Group'),
help_text=_(
'Group DN denied from login. If specified, user will not be ' 'allowed to login if a member of this group. Only one deny group ' 'is supported.'
'Group DN denied from login. If specified, user will not be allowed to login if a member of this group. Only one deny group is supported.'
),
category=_('LDAP'),
category_slug='ldap',
@@ -426,7 +426,7 @@ def _register_ldap(append=None):
field_class=LDAPTeamMapField,
default={},
label=_('LDAP Team Map'),
help_text=_('Mapping between team members (users) and LDAP groups. Configuration' ' details are available in the documentation.'),
help_text=_('Mapping between team members (users) and LDAP groups. Configuration details are available in the documentation.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict(
@@ -461,7 +461,7 @@ register(
allow_blank=True,
default='',
label=_('RADIUS Server'),
help_text=_('Hostname/IP of RADIUS server. RADIUS authentication is ' 'disabled if this setting is empty.'),
help_text=_('Hostname/IP of RADIUS server. RADIUS authentication is disabled if this setting is empty.'),
category=_('RADIUS'),
category_slug='radius',
placeholder='radius.example.com',
@@ -564,9 +564,7 @@ register(
read_only=True,
default=SocialAuthCallbackURL('google-oauth2'),
label=_('Google OAuth2 Callback URL'),
help_text=_(
'Provide this URL as the callback URL for your application as part ' 'of your registration process. Refer to the ' 'documentation for more detail.'
),
help_text=_('Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
depends_on=['TOWER_URL_BASE'],
@@ -602,7 +600,7 @@ register(
field_class=fields.StringListField,
default=[],
label=_('Google OAuth2 Allowed Domains'),
help_text=_('Update this setting to restrict the domains who are allowed to ' 'login using Google OAuth2.'),
help_text=_('Update this setting to restrict the domains who are allowed to login using Google OAuth2.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder=['example.com'],
@@ -658,9 +656,7 @@ register(
read_only=True,
default=SocialAuthCallbackURL('github'),
label=_('GitHub OAuth2 Callback URL'),
help_text=_(
'Provide this URL as the callback URL for your application as part ' 'of your registration process. Refer to the ' 'documentation for more detail.'
),
help_text=_('Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.'),
category=_('GitHub OAuth2'),
category_slug='github',
depends_on=['TOWER_URL_BASE'],
@@ -723,9 +719,7 @@ register(
read_only=True,
default=SocialAuthCallbackURL('github-org'),
label=_('GitHub Organization OAuth2 Callback URL'),
help_text=_(
'Provide this URL as the callback URL for your application as part ' 'of your registration process. Refer to the ' 'documentation for more detail.'
),
help_text=_('Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.'),
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
depends_on=['TOWER_URL_BASE'],
@@ -760,7 +754,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Organization Name'),
help_text=_('The name of your GitHub organization, as used in your ' 'organization\'s URL: https://github.com/<yourorg>/.'),
help_text=_('The name of your GitHub organization, as used in your organization\'s URL: https://github.com/<yourorg>/.'),
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
)
@@ -839,7 +833,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Team ID'),
help_text=_('Find the numeric team ID using the Github API: ' 'http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
help_text=_('Find the numeric team ID using the Github API: http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
category=_('GitHub Team OAuth2'),
category_slug='github-team',
)
@@ -878,9 +872,7 @@ register(
read_only=True,
default=SocialAuthCallbackURL('github-enterprise'),
label=_('GitHub Enterprise OAuth2 Callback URL'),
help_text=_(
'Provide this URL as the callback URL for your application as part ' 'of your registration process. Refer to the ' 'documentation for more detail.'
),
help_text=_('Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
depends_on=['TOWER_URL_BASE'],
@@ -892,7 +884,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Enterprise URL'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise ' 'documentation for more details.'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
)
@@ -904,7 +896,7 @@ register(
default='',
label=_('GitHub Enterprise API URL'),
help_text=_(
'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github ' 'Enterprise documentation for more details.'
'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.'
),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
@@ -967,9 +959,7 @@ register(
read_only=True,
default=SocialAuthCallbackURL('github-enterprise-org'),
label=_('GitHub Enterprise Organization OAuth2 Callback URL'),
help_text=_(
'Provide this URL as the callback URL for your application as part ' 'of your registration process. Refer to the ' 'documentation for more detail.'
),
help_text=_('Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.'),
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
depends_on=['TOWER_URL_BASE'],
@@ -981,7 +971,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization URL'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise ' 'documentation for more details.'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-org',
)
@@ -993,7 +983,7 @@ register(
default='',
label=_('GitHub Enterprise Organization API URL'),
help_text=_(
'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github ' 'Enterprise documentation for more details.'
'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.'
),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-org',
@@ -1028,7 +1018,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization Name'),
help_text=_('The name of your GitHub Enterprise organization, as used in your ' 'organization\'s URL: https://github.com/<yourorg>/.'),
help_text=_('The name of your GitHub Enterprise organization, as used in your organization\'s URL: https://github.com/<yourorg>/.'),
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
)
@@ -1084,7 +1074,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Enterprise Team URL'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise ' 'documentation for more details.'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-team',
)
@@ -1096,7 +1086,7 @@ register(
default='',
label=_('GitHub Enterprise Team API URL'),
help_text=_(
'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github ' 'Enterprise documentation for more details.'
'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.'
),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-team',
@@ -1131,7 +1121,7 @@ register(
allow_blank=True,
default='',
label=_('GitHub Enterprise Team ID'),
help_text=_('Find the numeric team ID using the Github Enterprise API: ' 'http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
help_text=_('Find the numeric team ID using the Github Enterprise API: http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
)
@@ -1170,9 +1160,7 @@ register(
read_only=True,
default=SocialAuthCallbackURL('azuread-oauth2'),
label=_('Azure AD OAuth2 Callback URL'),
help_text=_(
'Provide this URL as the callback URL for your application as part' ' of your registration process. Refer to the' ' documentation for more detail. '
),
help_text=_('Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail. '),
category=_('Azure AD OAuth2'),
category_slug='azuread-oauth2',
depends_on=['TOWER_URL_BASE'],
@@ -1291,7 +1279,7 @@ register(
field_class=fields.BooleanField,
default=True,
label=_('Automatically Create Organizations and Teams on SAML Login'),
help_text=_('When enabled (the default), mapped Organizations and Teams ' 'will be created automatically on successful SAML login.'),
help_text=_('When enabled (the default), mapped Organizations and Teams will be created automatically on successful SAML login.'),
category=_('SAML'),
category_slug='saml',
)
@@ -1318,7 +1306,7 @@ register(
read_only=True,
default=get_saml_metadata_url,
label=_('SAML Service Provider Metadata URL'),
help_text=_('If your identity provider (IdP) allows uploading an XML ' 'metadata file, you can download one from this URL.'),
help_text=_('If your identity provider (IdP) allows uploading an XML metadata file, you can download one from this URL.'),
category=_('SAML'),
category_slug='saml',
)
@@ -1346,7 +1334,7 @@ register(
required=True,
validators=[validate_certificate],
label=_('SAML Service Provider Public Certificate'),
help_text=_('Create a keypair to use as a service provider (SP) ' 'and include the certificate content here.'),
help_text=_('Create a keypair to use as a service provider (SP) and include the certificate content here.'),
category=_('SAML'),
category_slug='saml',
)
@@ -1358,7 +1346,7 @@ register(
required=True,
validators=[validate_private_key],
label=_('SAML Service Provider Private Key'),
help_text=_('Create a keypair to use as a service provider (SP) ' 'and include the private key content here.'),
help_text=_('Create a keypair to use as a service provider (SP) and include the private key content here.'),
category=_('SAML'),
category_slug='saml',
encrypted=True,
@@ -1369,7 +1357,7 @@ register(
field_class=SAMLOrgInfoField,
required=True,
label=_('SAML Service Provider Organization Info'),
help_text=_('Provide the URL, display name, and the name of your app. Refer to' ' the documentation for example syntax.'),
help_text=_('Provide the URL, display name, and the name of your app. Refer to the documentation for example syntax.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict(
@@ -1383,7 +1371,7 @@ register(
allow_blank=True,
required=True,
label=_('SAML Service Provider Technical Contact'),
help_text=_('Provide the name and email address of the technical contact for' ' your service provider. Refer to the documentation' ' for example syntax.'),
help_text=_('Provide the name and email address of the technical contact for your service provider. Refer to the documentation for example syntax.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([('givenName', 'Technical Contact'), ('emailAddress', 'techsup@example.com')]),
@@ -1395,7 +1383,7 @@ register(
allow_blank=True,
required=True,
label=_('SAML Service Provider Support Contact'),
help_text=_('Provide the name and email address of the support contact for your' ' service provider. Refer to the documentation for' ' example syntax.'),
help_text=_('Provide the name and email address of the support contact for your service provider. Refer to the documentation for example syntax.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([('givenName', 'Support Contact'), ('emailAddress', 'support@example.com')]),
@@ -1457,9 +1445,7 @@ register(
allow_null=True,
default={'requestedAuthnContext': False},
label=_('SAML Security Config'),
help_text=_(
'A dict of key value pairs that are passed to the underlying' ' python-saml security setting' ' https://github.com/onelogin/python-saml#settings'
),
help_text=_('A dict of key value pairs that are passed to the underlying python-saml security setting https://github.com/onelogin/python-saml#settings'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict(
@@ -1491,7 +1477,7 @@ register(
allow_null=True,
default=None,
label=_('SAML Service Provider extra configuration data'),
help_text=_('A dict of key value pairs to be passed to the underlying' ' python-saml Service Provider configuration setting.'),
help_text=_('A dict of key value pairs to be passed to the underlying python-saml Service Provider configuration setting.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict(),

View File

@@ -390,7 +390,7 @@ class LDAPSearchUnionField(fields.ListField):
search_args = []
for i in range(len(data)):
if not isinstance(data[i], list):
raise ValidationError('In order to ultilize LDAP Union, input element No. %d' ' should be a search query array.' % (i + 1))
raise ValidationError('In order to ultilize LDAP Union, input element No. %d should be a search query array.' % (i + 1))
try:
search_args.append(self.ldap_search_field_class().run_validation(data[i]))
except Exception as e:

View File

@@ -56,7 +56,7 @@ register(
field_class=fields.IntegerField,
min_value=100,
label=_('Max Job Events Retrieved by UI'),
help_text=_('Maximum number of job events for the UI to retrieve within a ' 'single request.'),
help_text=_('Maximum number of job events for the UI to retrieve within a single request.'),
category=_('UI'),
category_slug='ui',
)
@@ -65,7 +65,7 @@ register(
'UI_LIVE_UPDATES_ENABLED',
field_class=fields.BooleanField,
label=_('Enable Live Updates in the UI'),
help_text=_('If disabled, the page will not refresh when events are received. ' 'Reloading the page will be required to get the latest details.'),
help_text=_('If disabled, the page will not refresh when events are received. Reloading the page will be required to get the latest details.'),
category=_('UI'),
category_slug='ui',
)

View File

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

View File

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

View File

@@ -247,7 +247,7 @@ def main():
if organization:
lookup_data['organization'] = org_id
credential = module.get_one('credentials', name_or_id=name, **{'data': lookup_data})
credential = module.get_one('credentials', name_or_id=name, check_exists=(state == 'exists'), **{'data': lookup_data})
# Attempt to look up credential to copy based on the provided name
if copy_from:
@@ -265,10 +265,6 @@ def main():
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(credential)
if state == 'exists' and credential is not None:
# If credential exists and state is exists, we're done here.
module.exit_json(**module.json_output)
# Attempt to look up the related items the user specified (these will fail the module if not found)
if user:
user_id = module.resolve_name_to_id('users', user)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -264,7 +264,6 @@ options:
description:
- Maximum time in seconds to wait for a job to finish (server-side).
type: int
default: 0
job_slice_count:
description:
- The number of jobs to slice into at runtime. Will cause the Job Template to launch a workflow if value is greater than 1.
@@ -295,7 +294,7 @@ options:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
choices: ["present", "absent", "exists"]
type: str
notification_templates_started:
description:
@@ -444,7 +443,7 @@ def main():
notification_templates_success=dict(type="list", elements='str'),
notification_templates_error=dict(type="list", elements='str'),
prevent_instance_group_fallback=dict(type="bool"),
state=dict(choices=['present', 'absent'], default='present'),
state=dict(choices=['present', 'absent', 'exists'], default='present'),
)
# Create a module for ourselves
@@ -484,7 +483,7 @@ def main():
new_fields['execution_environment'] = module.resolve_name_to_id('execution_environments', ee)
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('job_templates', name_or_id=name, **{'data': search_fields})
existing_item = module.get_one('job_templates', name_or_id=name, check_exists=(state == 'exists'), **{'data': search_fields})
# Attempt to look up credential to copy based on the provided name
if copy_from:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,43 @@
that:
- "result is changed"
- name: Run an application with exists
application:
name: "{{ app1_name }}"
authorization_grant_type: "password"
client_type: "public"
organization: "Default"
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete our application
application:
name: "{{ app1_name }}"
organization: "Default"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Run an application with exists
application:
name: "{{ app1_name }}"
authorization_grant_type: "password"
client_type: "public"
organization: "Default"
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete our application
application:
name: "{{ app1_name }}"

View File

@@ -47,6 +47,42 @@
that:
- "result is changed"
- name: Create an Org-specific credential with an ID with exists
credential:
name: "{{ ssh_cred_name1 }}"
organization: Default
credential_type: Machine
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an Org-specific credential with an ID
credential:
name: "{{ ssh_cred_name1 }}"
organization: Default
credential_type: Machine
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an Org-specific credential with an ID with exists
credential:
name: "{{ ssh_cred_name1 }}"
organization: Default
credential_type: Machine
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete a Org-specific credential
credential:
name: "{{ ssh_cred_name1 }}"

View File

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

View File

@@ -22,6 +22,48 @@
that:
- "result is changed"
- name: Add Tower credential type with exists
credential_type:
description: Credential type for Test
name: "{{ cred_type_name }}"
kind: cloud
inputs: {"fields": [{"type": "string", "id": "username", "label": "Username"}, {"secret": true, "type": "string", "id": "password", "label": "Password"}], "required": ["username", "password"]}
injectors: {"extra_vars": {"test": "foo"}}
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete the credential type
credential_type:
description: Credential type for Test
name: "{{ cred_type_name }}"
kind: cloud
inputs: {"fields": [{"type": "string", "id": "username", "label": "Username"}, {"secret": true, "type": "string", "id": "password", "label": "Password"}], "required": ["username", "password"]}
injectors: {"extra_vars": {"test": "foo"}}
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Add Tower credential type with exists
credential_type:
description: Credential type for Test
name: "{{ cred_type_name }}"
kind: cloud
inputs: {"fields": [{"type": "string", "id": "username", "label": "Username"}, {"secret": true, "type": "string", "id": "password", "label": "Password"}], "required": ["username", "password"]}
injectors: {"extra_vars": {"test": "foo"}}
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Rename Tower credential type
credential_type:
name: "{{ cred_type_name }}"

View File

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

View File

@@ -19,9 +19,9 @@
name: "{{ inv_name }}"
organization: Default
state: present
register: result
registuer: result
- name: Create a Group
- name: Create Group 1
group:
name: "{{ group_name1 }}"
inventory: "{{ result.id }}"
@@ -34,7 +34,46 @@
that:
- "result is changed"
- name: Create a Group
- name: Create Group 1 with exists
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is not changed"
- name: Delete Group 1
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: absent
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Create Group 1 with exists
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Create Group 2
group:
name: "{{ group_name2 }}"
inventory: "{{ inv_name }}"
@@ -47,7 +86,7 @@
that:
- "result is changed"
- name: Create a Group
- name: Create Group 3
group:
name: "{{ group_name3 }}"
inventory: "{{ inv_name }}"
@@ -69,7 +108,7 @@
- "{{ host_name2 }}"
- "{{ host_name3 }}"
- name: Create a Group with hosts and sub group
- name: Create Group 1 with hosts and sub group of Group 2
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
@@ -83,7 +122,7 @@
foo: bar
register: result
- name: Create a Group with hosts and sub group
- name: Create Group 1 with hosts and sub group
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
@@ -104,18 +143,7 @@
that:
- group1_host_count == "3"
- name: Delete a Group
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Delete a Group
- name: Delete Group 2
group:
name: "{{ group_name2 }}"
inventory: "{{ inv_name }}"
@@ -127,7 +155,7 @@
that:
- "result is not changed"
- name: Delete a Group
- name: Delete Group 3
group:
name: "{{ group_name3 }}"
inventory: "{{ inv_name }}"
@@ -136,7 +164,19 @@
- assert:
that:
- "result is not changed"
- "result is changed"
# If we delete group 1 first it will delete group 2 and 3
- name: Delete Group 1
group:
name: "{{ group_name1 }}"
inventory: "{{ inv_name }}"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Check module fails with correct msg
group:

View File

@@ -29,6 +29,45 @@
that:
- "result is changed"
- name: Create a Host with exists
host:
name: "{{ host_name }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is not changed"
- name: Delete a Host
host:
name: "{{ host_name }}"
inventory: "{{ inv_name }}"
state: absent
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Create a Host with exists
host:
name: "{{ host_name }}"
inventory: "{{ inv_name }}"
state: exists
variables:
foo: bar
register: result
- assert:
that:
- "result is changed"
- name: Delete a Host
host:
name: "{{ result.id }}"

View File

@@ -38,6 +38,42 @@
that:
- "result is changed"
- name: Create an Instance Group with exists
instance_group:
name: "{{ group_name1 }}"
policy_instance_percentage: 34
policy_instance_minimum: 12
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an Instance Group
instance_group:
name: "{{ group_name1 }}"
policy_instance_percentage: 34
policy_instance_minimum: 12
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an Instance Group with exists
instance_group:
name: "{{ group_name1 }}"
policy_instance_percentage: 34
policy_instance_minimum: 12
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Update an Instance Group
instance_group:
name: "{{ result.id }}"

View File

@@ -51,6 +51,45 @@
that:
- "result is changed"
- name: Create an Inventory with exists
inventory:
name: "{{ inv_name1 }}"
organization: Default
instance_groups:
- "{{ group_name1 }}"
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an Inventory
inventory:
name: "{{ inv_name1 }}"
organization: Default
instance_groups:
- "{{ group_name1 }}"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an Inventory with exists
inventory:
name: "{{ inv_name1 }}"
organization: Default
instance_groups:
- "{{ group_name1 }}"
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Test Inventory module idempotency
inventory:
name: "{{ result.id }}"

View File

@@ -30,7 +30,7 @@
organization: Default
name: "{{ openstack_inv }}"
- name: Create a source inventory
- name: Create an source inventory
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
@@ -47,6 +47,60 @@
that:
- "result is changed"
- name: Create an source inventory with exists
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
inventory: "{{ openstack_inv }}"
credential: "{{ credential_result.id }}"
overwrite: true
update_on_launch: true
source_vars:
private: false
source: openstack
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete an source inventory
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
inventory: "{{ openstack_inv }}"
credential: "{{ credential_result.id }}"
overwrite: true
update_on_launch: true
source_vars:
private: false
source: openstack
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create an source inventory with exists
inventory_source:
name: "{{ openstack_inv_source }}"
description: Source for Test inventory
inventory: "{{ openstack_inv }}"
credential: "{{ credential_result.id }}"
overwrite: true
update_on_launch: true
source_vars:
private: false
source: openstack
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete the inventory source with an invalid cred and source_project specified
inventory_source:
name: "{{ result.id }}"

View File

@@ -10,6 +10,7 @@
cred1: "AWX-Collection-tests-job_template-cred1-{{ test_id }}"
cred2: "AWX-Collection-tests-job_template-cred2-{{ test_id }}"
cred3: "AWX-Collection-tests-job_template-cred3-{{ test_id }}"
inv1: "AWX-Collection-tests-job_template-inv-{{ test_id }}"
proj1: "AWX-Collection-tests-job_template-proj-{{ test_id }}"
jt1: "AWX-Collection-tests-job_template-jt1-{{ test_id }}"
jt2: "AWX-Collection-tests-job_template-jt2-{{ test_id }}"
@@ -25,6 +26,11 @@
- Ansible Galaxy
register: result
- name: Create an inventory
inventory:
name: "{{ inv1 }}"
organization: "{{ org_name }}"
- name: Create a Demo Project
project:
name: "{{ proj1 }}"
@@ -104,7 +110,7 @@
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
@@ -119,6 +125,63 @@
that:
- "jt1_result is changed"
- name: Create Job Template 1 with exists
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
- "{{ cred2 }}"
instance_groups:
- "{{ group_name1 }}"
job_type: run
state: exists
register: jt1_result
- assert:
that:
- "jt1_result is not changed"
- name: Delete Job Template 1
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
- "{{ cred2 }}"
instance_groups:
- "{{ group_name1 }}"
job_type: run
state: absent
register: jt1_result
- assert:
that:
- "jt1_result is changed"
- name: Create Job Template 1 with exists
job_template:
name: "{{ jt1 }}"
project: "{{ proj1 }}"
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credentials:
- "{{ cred1 }}"
- "{{ cred2 }}"
instance_groups:
- "{{ group_name1 }}"
job_type: run
state: exists
register: jt1_result
- assert:
that:
- "jt1_result is changed"
- name: Add a credential to this JT
job_template:
name: "{{ jt1 }}"
@@ -218,7 +281,7 @@
name: "{{ jt2 }}"
organization: Default
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credential: "{{ cred3 }}"
job_type: run
@@ -236,7 +299,7 @@
name: "{{ jt2 }}"
organization: Default
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credential: "{{ cred3 }}"
job_type: run
@@ -384,7 +447,7 @@
job_template:
name: "{{ jt2 }}"
project: "{{ proj1 }}"
inventory: Demo Inventory
inventory: "{{ inv1 }}"
playbook: hello_world.yml
credential: "{{ cred3 }}"
job_type: run
@@ -444,6 +507,12 @@
organization: Default
state: absent
- name: Delete an inventory
inventory:
name: "{{ inv1 }}"
organization: "{{ org_name }}"
state: absent
- name: "Remove the organization"
organization:
name: "{{ org_name }}"

View File

@@ -9,168 +9,156 @@
jt_name: "AWX-Collection-tests-job_wait-long_running-{{ test_id }}"
proj_name: "AWX-Collection-tests-job_wait-long_running-{{ test_id }}"
- name: Assure that the demo project exists
project:
name: "{{ proj_name }}"
scm_type: 'git'
scm_url: 'https://github.com/ansible/test-playbooks.git'
scm_update_on_launch: true
organization: Default
- block:
- name: Create a project
project:
name: "{{ proj_name }}"
scm_type: 'git'
scm_url: 'https://github.com/ansible/test-playbooks.git'
scm_update_on_launch: true
organization: Default
- name: Create a job template
job_template:
name: "{{ jt_name }}"
playbook: "sleep.yml"
job_type: run
project: "{{ proj_name }}"
inventory: "Demo Inventory"
extra_vars:
sleep_interval: 300
- name: Create a job template
job_template:
name: "{{ jt_name }}"
playbook: "sleep.yml"
job_type: run
project: "{{ proj_name }}"
inventory: "Demo Inventory"
extra_vars:
sleep_interval: 600
- name: Validate that interval superceeds min/max
job_wait:
min_interval: 10
max_interval: 20
interval: 12
job_id: "99999999"
register: result
ignore_errors: true
- name: Check module fails with correct msg
job_wait:
job_id: "99999999"
register: result
ignore_errors: true
- assert:
that:
- "result.msg =='Unable to wait on job 99999999; that ID does not exist.' or
'min and max interval have been depricated, please use interval instead, interval will be set to 12'"
- 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.'"
- name: Check module fails with correct msg
job_wait:
job_id: "99999999"
register: result
ignore_errors: true
- name: Launch Demo Job Template (take happy path)
job_launch:
job_template: "Demo Job Template"
register: job
- 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.'"
- assert:
that:
- job is changed
- name: Launch Demo Job Template (take happy path)
job_launch:
job_template: "Demo Job Template"
register: job
- name: Wait for the Job to finish
job_wait:
job_id: "{{ job.id }}"
register: wait_results
- assert:
that:
- job is changed
# Make sure it worked and that we have some data in our results
- assert:
that:
- wait_results is successful
- "'elapsed' in wait_results"
- "'id' in wait_results"
- name: Wait for the Job to finish
job_wait:
job_id: "{{ job.id }}"
register: wait_results
- name: Launch a long running job
job_launch:
job_template: "{{ jt_name }}"
register: job
# Make sure it worked and that we have some data in our results
- assert:
that:
- wait_results is successful
- "'elapsed' in wait_results"
- "'id' in wait_results"
- assert:
that:
- job is changed
- name: Launch a long running job
job_launch:
job_template: "{{ jt_name }}"
register: job
- name: Timeout waiting for the job to complete
job_wait:
job_id: "{{ job.id }}"
timeout: 5
ignore_errors: true
register: wait_results
- assert:
that:
- job is changed
# 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.'"
- "'id' in wait_results"
- name: Timeout waiting for the job to complete
job_wait:
job_id: "{{ job.id }}"
timeout: 5
ignore_errors: true
register: wait_results
- name: Async cancel the long running job
job_cancel:
job_id: "{{ job.id }}"
async: 3600
poll: 0
# 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.'"
- "'id' in wait_results"
- name: Wait for the job to exit on cancel
job_wait:
job_id: "{{ job.id }}"
register: wait_results
ignore_errors: true
- name: Async cancel the long running job
job_cancel:
job_id: "{{ job.id }}"
async: 3600
poll: 0
- assert:
that:
- wait_results is failed
- 'wait_results.status == "canceled"'
- "wait_results.msg == 'Job with id {{ job.id }} failed' or 'Job with id={{ job.id }} failed, error: Job failed.'"
- name: Wait for the job to exit on cancel
job_wait:
job_id: "{{ job.id }}"
register: wait_results
ignore_errors: true
# workflow wait test
- name: Generate a random string for test
set_fact:
test_id1: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id1 is not defined
- assert:
that:
- wait_results is failed
- 'wait_results.status == "canceled"'
- "wait_results.msg == 'Job with id {{ job.id }} failed' or 'Job with id={{ job.id }} failed, error: Job failed.'"
- name: Generate names
set_fact:
wfjt_name2: "AWX-Collection-tests-workflow_launch--wfjt1-{{ test_id1 }}"
- name: Delete the job template
job_template:
name: "{{ jt_name }}"
playbook: "sleep.yml"
job_type: run
project: "{{ proj_name }}"
inventory: "Demo Inventory"
state: absent
- name: Create our workflow
workflow_job_template:
name: "{{ wfjt_name2 }}"
state: present
- name: Delete the project
project:
name: "{{ proj_name }}"
organization: Default
state: absent
- name: Add a node
workflow_job_template_node:
workflow_job_template: "{{ wfjt_name2 }}"
unified_job_template: "Demo Job Template"
identifier: leaf
register: new_node
# workflow wait test
- name: Generate a random string for test
set_fact:
test_id1: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
when: test_id1 is not defined
- name: Kick off a workflow
workflow_launch:
workflow_template: "{{ wfjt_name2 }}"
ignore_errors: true
register: workflow
- name: Generate names
set_fact:
wfjt_name2: "AWX-Collection-tests-workflow_launch--wfjt1-{{ test_id1 }}"
- name: Wait for the Workflow Job to finish
job_wait:
job_id: "{{ workflow.job_info.id }}"
job_type: "workflow_jobs"
register: wait_workflow_results
- name: Create our workflow
workflow_job_template:
name: "{{ wfjt_name2 }}"
state: present
# Make sure it worked and that we have some data in our results
- assert:
that:
- wait_workflow_results is successful
- "'elapsed' in wait_workflow_results"
- "'id' in wait_workflow_results"
- name: Add a node
workflow_job_template_node:
workflow_job_template: "{{ wfjt_name2 }}"
unified_job_template: "Demo Job Template"
identifier: leaf
register: new_node
always:
- name: Clean up test workflow
workflow_job_template:
name: "{{ wfjt_name2 }}"
state: absent
- name: Kick off a workflow
workflow_launch:
workflow_template: "{{ wfjt_name2 }}"
ignore_errors: true
register: workflow
- name: Delete the job template
job_template:
name: "{{ jt_name }}"
playbook: "sleep.yml"
job_type: run
project: "{{ proj_name }}"
inventory: "Demo Inventory"
state: absent
- name: Wait for the Workflow Job to finish
job_wait:
job_id: "{{ workflow.job_info.id }}"
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
- "'elapsed' in wait_workflow_results"
- "'id' in wait_workflow_results"
- name: Clean up test workflow
workflow_job_template:
name: "{{ wfjt_name2 }}"
state: absent
- name: Delete the project
project:
name: "{{ proj_name }}"
organization: Default
state: absent

View File

@@ -13,6 +13,22 @@
name: "{{ label_name }}"
organization: Default
state: present
register: results
- assert:
that:
- "results is changed"
- name: Create a Label with exists
label:
name: "{{ label_name }}"
organization: Default
state: exists
register: results
- assert:
that:
- "results is not changed"
- name: Check module fails with correct msg
label:

View File

@@ -36,6 +36,75 @@
that:
- result is changed
- name: Create Slack notification with custom messages with exists
notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack
notification_configuration:
token: a_token
channels:
- general
messages:
started:
message: "{{ '{{' }} job_friendly_name {{' }}' }} {{ '{{' }} job.id {{' }}' }} started"
success:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} completed in {{ '{{' }} job.elapsed {{ '}}' }} seconds"
error:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} FAILED! Please look at {{ '{{' }} job.url {{ '}}' }}"
state: exists
register: result
- assert:
that:
- result is not changed
- name: Delete Slack notification with custom messages
notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack
notification_configuration:
token: a_token
channels:
- general
messages:
started:
message: "{{ '{{' }} job_friendly_name {{' }}' }} {{ '{{' }} job.id {{' }}' }} started"
success:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} completed in {{ '{{' }} job.elapsed {{ '}}' }} seconds"
error:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} FAILED! Please look at {{ '{{' }} job.url {{ '}}' }}"
state: absent
register: result
- assert:
that:
- result is changed
- name: Create Slack notification with custom messages with exists
notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack
notification_configuration:
token: a_token
channels:
- general
messages:
started:
message: "{{ '{{' }} job_friendly_name {{' }}' }} {{ '{{' }} job.id {{' }}' }} started"
success:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} completed in {{ '{{' }} job.elapsed {{ '}}' }} seconds"
error:
message: "{{ '{{' }} job_friendly_name {{ '}}' }} FAILED! Please look at {{ '{{' }} job.url {{ '}}' }}"
state: exists
register: result
- assert:
that:
- result is changed
- name: Delete Slack notification
notification_template:
name: "{{ slack_not }}"

View File

@@ -25,6 +25,39 @@
- assert:
that: "result is changed"
- name: "Create a new organization with exists"
organization:
name: "{{ org_name }}"
galaxy_credentials:
- Ansible Galaxy
state: exists
register: result
- assert:
that: "result is not changed"
- name: "Delete a new organization"
organization:
name: "{{ org_name }}"
galaxy_credentials:
- Ansible Galaxy
state: absent
register: result
- assert:
that: "result is changed"
- name: "Create a new organization with exists"
organization:
name: "{{ org_name }}"
galaxy_credentials:
- Ansible Galaxy
state: exists
register: result
- assert:
that: "result is changed"
- name: "Make sure making the same org is not a change"
organization:
name: "{{ org_name }}"

View File

@@ -39,6 +39,48 @@
that:
- result is changed
- name: Create a git project without credentials and wait with exists
project:
name: "{{ project_name1 }}"
organization: Default
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
state: exists
register: result
- assert:
that:
- result is not changed
- name: Delete a git project without credentials and wait
project:
name: "{{ project_name1 }}"
organization: Default
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
state: absent
register: result
- assert:
that:
- result is changed
- name: Create a git project without credentials and wait with exists
project:
name: "{{ project_name1 }}"
organization: Default
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
state: exists
register: result
- assert:
that:
- result is changed
- name: Recreate the project to validate not changed
project:
name: "{{ project_name1 }}"

View File

@@ -76,6 +76,42 @@
that:
- result is changed
- name: Build a real schedule with exists
schedule:
name: "{{ sched1 }}"
state: exists
unified_job_template: "Demo Job Template"
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
register: result
- assert:
that:
- result is not changed
- name: Delete a real schedule
schedule:
name: "{{ sched1 }}"
state: absent
unified_job_template: "Demo Job Template"
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
register: result
- assert:
that:
- result is changed
- name: Build a real schedule with exists
schedule:
name: "{{ sched1 }}"
state: exists
unified_job_template: "Demo Job Template"
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
register: result
- assert:
that:
- result is changed
- name: Rebuild the same schedule
schedule:
name: "{{ sched1 }}"

View File

@@ -34,6 +34,39 @@
that:
- "result is changed"
- name: Create a team with exists
team:
name: "{{ team_name }}"
organization: Default
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete a team
team:
name: "{{ team_name }}"
organization: Default
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create a team with exists
team:
name: "{{ team_name }}"
organization: Default
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Delete a team
team:
name: "{{ team_name }}"

View File

@@ -20,6 +20,42 @@
that:
- "result is changed"
- name: Create a User with exists
user:
username: "{{ username }}"
first_name: Joe
password: "{{ 65535 | random | to_uuid }}"
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete a User
user:
username: "{{ username }}"
first_name: Joe
password: "{{ 65535 | random | to_uuid }}"
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create a User with exists
user:
username: "{{ username }}"
first_name: Joe
password: "{{ 65535 | random | to_uuid }}"
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Change a User by ID
user:
username: "{{ result.id }}"

View File

@@ -254,6 +254,65 @@
that:
- "result is changed"
- name: Create a workflow job template with exists
workflow_job_template:
name: "{{ wfjt_name }}"
organization: Default
inventory: Demo Inventory
extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}
labels:
- "{{ lab1 }}"
ask_inventory_on_launch: true
ask_scm_branch_on_launch: true
ask_limit_on_launch: true
ask_tags_on_launch: true
ask_variables_on_launch: true
state: exists
register: result
- assert:
that:
- "result is not changed"
- name: Delete a workflow job template
workflow_job_template:
name: "{{ wfjt_name }}"
organization: Default
inventory: Demo Inventory
extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}
labels:
- "{{ lab1 }}"
ask_inventory_on_launch: true
ask_scm_branch_on_launch: true
ask_limit_on_launch: true
ask_tags_on_launch: true
ask_variables_on_launch: true
state: absent
register: result
- assert:
that:
- "result is changed"
- name: Create a workflow job template with exists
workflow_job_template:
name: "{{ wfjt_name }}"
organization: Default
inventory: Demo Inventory
extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}
# We don't try with the label here because after we delete the first WFJT the label is delete with it because it has no references
ask_inventory_on_launch: true
ask_scm_branch_on_launch: true
ask_limit_on_launch: true
ask_tags_on_launch: true
ask_variables_on_launch: true
state: exists
register: result
- assert:
that:
- "result is changed"
- name: Create a workflow job template with bad label
workflow_job_template:
name: "{{ wfjt_name }}"

View File

@@ -1 +1,3 @@
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec
plugins/modules/import.py pylint:unused-import # Simply used as a feature flag conditional
test/awx/conftest.py pylint:unused-import # Used to make sure we are importing the right awxkit, see comment in conftest.py near imports

View File

@@ -0,0 +1,3 @@
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec
plugins/modules/import.py pylint:unused-import # Simply used as a feature flag conditional
test/awx/conftest.py pylint:unused-import # Used to make sure we are importing the right awxkit, see comment in conftest.py near imports

View File

@@ -253,7 +253,13 @@ class ApiV2(base.Base):
# Import methods
def _dependent_resources(self):
page_resource = {getattr(self, resource)._create().__item_class__: resource for resource in self.json}
page_resource = {}
for resource in self.json:
# The /api/v2/constructed_inventories endpoint is for the UI but will register as an Inventory endpoint
# We want to map the type to /api/v2/inventories/ which works for constructed too
if resource == 'constructed_inventory':
continue
page_resource[getattr(self, resource)._create().__item_class__] = resource
data_pages = [getattr(self, resource)._create().__item_class__ for resource in EXPORTABLE_RESOURCES]
for page_cls in itertools.chain(*has_create.page_creation_order(*data_pages)):
@@ -282,7 +288,18 @@ class ApiV2(base.Base):
if asset['natural_key']['type'] == 'user':
# We should only impose a default password if the resource doesn't exist.
post_data.setdefault('password', 'abc123')
_page = endpoint.post(post_data)
try:
_page = endpoint.post(post_data)
except exc.NoContent:
# desired exception under some circumstances, e.g. labels that already exist
if _page is None and 'name' in post_data:
results = endpoint.get(all_pages=True).results
for item in results:
if item['name'] == post_data['name']:
_page = item.get()
break
else:
raise
changed = True
if asset['natural_key']['type'] == 'project':
# When creating a project, we need to wait for its
@@ -302,8 +319,6 @@ class ApiV2(base.Base):
_page = _page.put(post_data)
changed = True
except exc.NoContent: # desired exception under some circumstances, e.g. labels that already exist
pass
except (exc.Common, AssertionError) as e:
identifier = asset.get("name", None) or asset.get("username", None) or asset.get("hostname", None)
log.error(f'{endpoint} "{identifier}": {e}.')
@@ -404,6 +419,7 @@ class ApiV2(base.Base):
for resource in self._dependent_resources():
endpoint = getattr(self, resource)
# Load up existing objects, so that we can try to update or link to them
self._cache.get_page(endpoint)
imported = self._import_list(endpoint, data.get(resource) or [])

View File

@@ -13,7 +13,7 @@ class SystemJobTemplate(UnifiedJobTemplate, HasNotifications):
# return job
jobs_pg = self.get_related('jobs', id=result.json['system_job'])
assert jobs_pg.count == 1, "system_job_template launched (id:%s) but unable to find matching " "job at %s/jobs/" % (result.json['job'], self.url)
assert jobs_pg.count == 1, "system_job_template launched (id:%s) but unable to find matching job at %s/jobs/" % (result.json['job'], self.url)
return jobs_pg.results[0]

View File

@@ -40,7 +40,7 @@ class UnifiedJob(HasStatus, base.Base):
Default behavior is to replace newline characters with a space, but this can be modified, including replacement
with ''. Pass replace_newlines=None to disable.
Additionally, you may replace any ' ' with another character (including ''). This is applied after the newline
Additionally, you may replace any with another character (including ''). This is applied after the newline
replacement. Default behavior is to not replace spaces.
"""
self.wait_until_completed()
@@ -147,7 +147,7 @@ class UnifiedJob(HasStatus, base.Base):
if host_loc.startswith(expected_prefix):
return host_loc
raise RuntimeError(
'Could not find a controller private_data_dir for this job. ' 'Searched for volume mount to {} inside of args {}'.format(expected_prefix, job_args)
'Could not find a controller private_data_dir for this job. Searched for volume mount to {} inside of args {}'.format(expected_prefix, job_args)
)

View File

@@ -139,7 +139,7 @@ def format_jq(output, fmt):
if fmt == '.':
return output
raise ImportError(
'To use `-f jq`, you must install the optional jq dependency.\n' '`pip install jq`\n',
'To use `-f jq`, you must install the optional jq dependency.\n`pip install jq`\n',
'Note that some platforms may require additional programs to '
'build jq from source (like `libtool`).\n'
'See https://pypi.org/project/jq/ for instructions.',

View File

@@ -55,7 +55,7 @@ def pk_or_name(v2, model_name, value, page=None):
return int(results.results[0].id)
if results.count > 1:
raise argparse.ArgumentTypeError(
'Multiple {0} exist with that {1}. ' 'To look up an ID, run:\n' 'awx {0} list --{1} "{2}" -f human'.format(model_name, identity, value)
'Multiple {0} exist with that {1}. To look up an ID, run:\nawx {0} list --{1} "{2}" -f human'.format(model_name, identity, value)
)
raise argparse.ArgumentTypeError('Could not find any {0} with that {1}.'.format(model_name, identity))
@@ -119,7 +119,7 @@ class ResourceOptionsParser(object):
'--all',
dest='all_pages',
action='store_true',
help=('fetch all pages of content from the API when ' 'returning results (instead of just the first page)'),
help=('fetch all pages of content from the API when returning results (instead of just the first page)'),
)
parser.add_argument(
'--order_by',

View File

@@ -22,7 +22,7 @@ class CustomAutoprogramDirective(AutoprogramDirective):
nodes[0][0].children = [heading]
# add a descriptive top synopsis of the reference guide
nodes[0].children.insert(1, paragraph(text=('This is an exhaustive guide of every available command in ' 'the awx CLI tool.')))
nodes[0].children.insert(1, paragraph(text=('This is an exhaustive guide of every available command in the awx CLI tool.')))
disclaimer = (
'The commands and parameters documented here can (and will) '
'vary based on a variety of factors, such as the AWX API '

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