mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
commit
8e317cabc0
15
CHANGELOG.md
15
CHANGELOG.md
@ -2,6 +2,21 @@
|
||||
|
||||
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
||||
|
||||
## 14.1.0 (Aug 25, 2020)
|
||||
- AWX images can now be built on ARM64 - https://github.com/ansible/awx/pull/7607
|
||||
- Added the Remote Archive SCM Type to support using immutable artifacts and releases (such as tarballs and zip files) as projects - https://github.com/ansible/awx/issues/7954
|
||||
- Deprecated official support for Mercurial-based project updates - https://github.com/ansible/awx/issues/7932
|
||||
- Added resource import/export support to the official AWX collection - https://github.com/ansible/awx/issues/7329
|
||||
- Added the ability to import YAML-based resources (instead of just JSON) when using the AWX CLI - https://github.com/ansible/awx/pull/7808
|
||||
- Users upgrading from older versions of AWX may encounter an issue that causes their postgres container to restart in a loop (https://github.com/ansible/awx/issues/7854) - if you encounter this, bring your containers down and then back up (e.g., `docker-compose down && docker-compose up -d`) after upgrading to 14.1.0.
|
||||
- Updated the AWX CLI to export labels associated with Workflow Job Templates - https://github.com/ansible/awx/pull/7847
|
||||
- Updated to the latest python-ldap to address a bug - https://github.com/ansible/awx/issues/7868
|
||||
- Upgraded git-python to fix a bug that caused workflows to sometimes fail - https://github.com/ansible/awx/issues/6119
|
||||
- Worked around a bug in the channels_redis library that slowly causes Daphne processes to leak memory over time - https://github.com/django/channels_redis/issues/212
|
||||
- Fixed a bug in the AWX CLI that prevented Workflow nodes from importing properly - https://github.com/ansible/awx/issues/7793
|
||||
- Fixed a bug in the awx.awx collection release process that templated the wrong version - https://github.com/ansible/awx/issues/7870
|
||||
- Fixed a bug that caused errors rendering stdout that contained UTF-16 surrogate pairs - https://github.com/ansible/awx/pull/7918
|
||||
|
||||
## 14.0.0 (Aug 6, 2020)
|
||||
- As part of our commitment to inclusivity in open source, we recently took some time to audit AWX's source code and user interface and replace certain terminology with more inclusive language. Strictly speaking, this isn't a bug or a feature, but we think it's important and worth calling attention to:
|
||||
* https://github.com/ansible/awx/commit/78229f58715fbfbf88177e54031f532543b57acc
|
||||
|
||||
@ -23,7 +23,7 @@ from rest_framework.request import clone_request
|
||||
# AWX
|
||||
from awx.api.fields import ChoiceNullField
|
||||
from awx.main.fields import JSONField, ImplicitRoleField
|
||||
from awx.main.models import InventorySource, NotificationTemplate
|
||||
from awx.main.models import NotificationTemplate
|
||||
from awx.main.scheduler.kubernetes import PodManager
|
||||
|
||||
|
||||
@ -115,19 +115,6 @@ class Metadata(metadata.SimpleMetadata):
|
||||
if getattr(field, 'write_only', False):
|
||||
field_info['write_only'] = True
|
||||
|
||||
# Special handling of inventory source_region choices that vary based on
|
||||
# selected inventory source.
|
||||
if field.field_name == 'source_regions':
|
||||
for cp in ('azure_rm', 'ec2', 'gce'):
|
||||
get_regions = getattr(InventorySource, 'get_%s_region_choices' % cp)
|
||||
field_info['%s_region_choices' % cp] = get_regions()
|
||||
|
||||
# Special handling of group_by choices for EC2.
|
||||
if field.field_name == 'group_by':
|
||||
for cp in ('ec2',):
|
||||
get_group_by_choices = getattr(InventorySource, 'get_%s_group_by_choices' % cp)
|
||||
field_info['%s_group_by_choices' % cp] = get_group_by_choices()
|
||||
|
||||
# Special handling of notification configuration where the required properties
|
||||
# are conditional on the type selected.
|
||||
if field.field_name == 'notification_configuration':
|
||||
|
||||
@ -7,6 +7,24 @@ from prometheus_client.parser import text_string_to_metric_families
|
||||
# Django REST Framework
|
||||
from rest_framework import renderers
|
||||
from rest_framework.request import override_method
|
||||
from rest_framework.utils import encoders
|
||||
|
||||
|
||||
class SurrogateEncoder(encoders.JSONEncoder):
|
||||
|
||||
def encode(self, obj):
|
||||
ret = super(SurrogateEncoder, self).encode(obj)
|
||||
try:
|
||||
ret.encode()
|
||||
except UnicodeEncodeError as e:
|
||||
if 'surrogates not allowed' in e.reason:
|
||||
ret = ret.encode('utf-8', 'replace').decode()
|
||||
return ret
|
||||
|
||||
|
||||
class DefaultJSONRenderer(renderers.JSONRenderer):
|
||||
|
||||
encoder_class = SurrogateEncoder
|
||||
|
||||
|
||||
class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer):
|
||||
|
||||
@ -1336,6 +1336,8 @@ class ProjectOptionsSerializer(BaseSerializer):
|
||||
attrs.pop('local_path', None)
|
||||
if 'local_path' in attrs and attrs['local_path'] not in valid_local_paths:
|
||||
errors['local_path'] = _('This path is already being used by another manual project.')
|
||||
if attrs.get('scm_branch') and scm_type == 'archive':
|
||||
errors['scm_branch'] = _('SCM branch cannot be used with archive projects.')
|
||||
if attrs.get('scm_refspec') and scm_type != 'git':
|
||||
errors['scm_refspec'] = _('SCM refspec can only be used with git projects.')
|
||||
|
||||
@ -1700,7 +1702,10 @@ class HostSerializer(BaseSerializerWithVariables):
|
||||
'type': j.job.job_type_name,
|
||||
'status': j.job.status,
|
||||
'finished': j.job.finished,
|
||||
} for j in obj.job_host_summaries.select_related('job__job_template').order_by('-created')[:5]])
|
||||
} for j in obj.job_host_summaries.select_related('job__job_template').order_by('-created').defer(
|
||||
'job__extra_vars',
|
||||
'job__artifacts',
|
||||
)[:5]])
|
||||
return d
|
||||
|
||||
def _get_host_port_from_name(self, name):
|
||||
@ -1932,7 +1937,7 @@ class InventorySourceOptionsSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential',
|
||||
'source_regions', 'instance_filters', 'group_by', 'overwrite', 'overwrite_vars',
|
||||
'enabled_var', 'enabled_value', 'host_filter', 'overwrite', 'overwrite_vars',
|
||||
'custom_virtualenv', 'timeout', 'verbosity')
|
||||
|
||||
def get_related(self, obj):
|
||||
@ -1952,7 +1957,7 @@ class InventorySourceOptionsSerializer(BaseSerializer):
|
||||
return ret
|
||||
|
||||
def validate(self, attrs):
|
||||
# TODO: Validate source, validate source_regions
|
||||
# TODO: Validate source
|
||||
errors = {}
|
||||
|
||||
source = attrs.get('source', self.instance and self.instance.source or '')
|
||||
|
||||
@ -242,6 +242,8 @@ class DashboardView(APIView):
|
||||
svn_failed_projects = svn_projects.filter(last_job_failed=True)
|
||||
hg_projects = user_projects.filter(scm_type='hg')
|
||||
hg_failed_projects = hg_projects.filter(last_job_failed=True)
|
||||
archive_projects = user_projects.filter(scm_type='archive')
|
||||
archive_failed_projects = archive_projects.filter(last_job_failed=True)
|
||||
data['scm_types'] = {}
|
||||
data['scm_types']['git'] = {'url': reverse('api:project_list', request=request) + "?scm_type=git",
|
||||
'label': 'Git',
|
||||
@ -258,6 +260,11 @@ class DashboardView(APIView):
|
||||
'failures_url': reverse('api:project_list', request=request) + "?scm_type=hg&last_job_failed=True",
|
||||
'total': hg_projects.count(),
|
||||
'failed': hg_failed_projects.count()}
|
||||
data['scm_types']['archive'] = {'url': reverse('api:project_list', request=request) + "?scm_type=archive",
|
||||
'label': 'Remote Archive',
|
||||
'failures_url': reverse('api:project_list', request=request) + "?scm_type=archive&last_job_failed=True",
|
||||
'total': archive_projects.count(),
|
||||
'failed': archive_failed_projects.count()}
|
||||
|
||||
user_list = get_user_queryset(request.user, models.User)
|
||||
team_list = get_user_queryset(request.user, models.Team)
|
||||
|
||||
@ -134,7 +134,8 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, Retri
|
||||
|
||||
# Do not allow changes to an Inventory kind.
|
||||
if kind is not None and obj.kind != kind:
|
||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||
return Response(dict(error=_('You cannot turn a regular inventory into a "smart" inventory.')),
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
return super(InventoryDetail, self).update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
|
||||
@ -228,7 +228,7 @@ def query_info(since, collection_type):
|
||||
|
||||
# Copies Job Events from db to a .csv to be shipped
|
||||
@table_version('events_table.csv', '1.1')
|
||||
@table_version('unified_jobs_table.csv', '1.0')
|
||||
@table_version('unified_jobs_table.csv', '1.1')
|
||||
@table_version('unified_job_template_table.csv', '1.0')
|
||||
@table_version('workflow_job_node_table.csv', '1.0')
|
||||
@table_version('workflow_job_template_node_table.csv', '1.0')
|
||||
@ -272,6 +272,8 @@ def copy_tables(since, full_path, subset=None):
|
||||
django_content_type.model,
|
||||
main_unifiedjob.organization_id,
|
||||
main_organization.name as organization_name,
|
||||
main_job.inventory_id,
|
||||
main_inventory.name,
|
||||
main_unifiedjob.created,
|
||||
main_unifiedjob.name,
|
||||
main_unifiedjob.unified_job_template_id,
|
||||
@ -289,6 +291,8 @@ def copy_tables(since, full_path, subset=None):
|
||||
main_unifiedjob.instance_group_id
|
||||
FROM main_unifiedjob
|
||||
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
|
||||
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id
|
||||
LEFT JOIN main_inventory ON main_job.inventory_id = main_inventory.id
|
||||
LEFT JOIN main_organization ON main_organization.id = main_unifiedjob.organization_id
|
||||
WHERE (main_unifiedjob.created > {0} OR main_unifiedjob.finished > {0})
|
||||
AND main_unifiedjob.launch_type != 'sync'
|
||||
|
||||
@ -180,7 +180,8 @@ def ship(path):
|
||||
auth=(rh_user, rh_password),
|
||||
headers=s.headers,
|
||||
timeout=(31, 31))
|
||||
if response.status_code != 202:
|
||||
# Accept 2XX status_codes
|
||||
if response.status_code >= 300:
|
||||
return logger.exception('Upload failed with status {}, {}'.format(response.status_code,
|
||||
response.text))
|
||||
run_now = now()
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import collections
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@ -12,12 +14,40 @@ from django.contrib.auth.models import User
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from channels.layers import get_channel_layer
|
||||
from channels.db import database_sync_to_async
|
||||
from channels_redis.core import RedisChannelLayer
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.consumers')
|
||||
XRF_KEY = '_auth_user_xrf'
|
||||
|
||||
|
||||
class BoundedQueue(asyncio.Queue):
|
||||
|
||||
def put_nowait(self, item):
|
||||
if self.full():
|
||||
# dispose the oldest item
|
||||
# if we actually get into this code block, it likely means that
|
||||
# this specific consumer has stopped reading
|
||||
# unfortunately, channels_redis will just happily continue to
|
||||
# queue messages specific to their channel until the heat death
|
||||
# of the sun: https://github.com/django/channels_redis/issues/212
|
||||
# this isn't a huge deal for browser clients that disconnect,
|
||||
# but it *does* cause a problem for our global broadcast topic
|
||||
# that's used to broadcast messages to peers in a cluster
|
||||
# if we get into this code block, it's better to drop messages
|
||||
# than to continue to malloc() forever
|
||||
self.get_nowait()
|
||||
return super(BoundedQueue, self).put_nowait(item)
|
||||
|
||||
|
||||
class ExpiringRedisChannelLayer(RedisChannelLayer):
|
||||
def __init__(self, *args, **kw):
|
||||
super(ExpiringRedisChannelLayer, self).__init__(*args, **kw)
|
||||
self.receive_buffer = collections.defaultdict(
|
||||
functools.partial(BoundedQueue, self.capacity)
|
||||
)
|
||||
|
||||
|
||||
class WebsocketSecretAuthHelper:
|
||||
"""
|
||||
Middlewareish for websockets to verify node websocket broadcast interconnect.
|
||||
|
||||
@ -152,7 +152,7 @@ def kv_backend(**kwargs):
|
||||
|
||||
sess = requests.Session()
|
||||
sess.headers['Authorization'] = 'Bearer {}'.format(token)
|
||||
# Compatability header for older installs of Hashicorp Vault
|
||||
# Compatibility header for older installs of Hashicorp Vault
|
||||
sess.headers['X-Vault-Token'] = token
|
||||
|
||||
if api_version == 'v2':
|
||||
|
||||
@ -58,7 +58,7 @@ class IsolatedManager(object):
|
||||
os.chmod(temp.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
||||
for host in hosts:
|
||||
inventory['all']['hosts'][host] = {
|
||||
"ansible_connection": "community.kubernetes.kubectl",
|
||||
"ansible_connection": "kubectl",
|
||||
"ansible_kubectl_config": path,
|
||||
}
|
||||
else:
|
||||
|
||||
@ -12,7 +12,6 @@ import sys
|
||||
import time
|
||||
import traceback
|
||||
import shutil
|
||||
from distutils.version import LooseVersion as Version
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@ -39,7 +38,6 @@ from awx.main.utils import (
|
||||
build_proot_temp_dir,
|
||||
get_licenser
|
||||
)
|
||||
from awx.main.utils.common import _get_ansible_version
|
||||
from awx.main.signals import disable_activity_stream
|
||||
from awx.main.constants import STANDARD_INVENTORY_UPDATE_ENV
|
||||
from awx.main.utils.pglock import advisory_lock
|
||||
@ -136,15 +134,10 @@ class AnsibleInventoryLoader(object):
|
||||
# inside of /venv/ansible, so we override the specified interpreter
|
||||
# https://github.com/ansible/ansible/issues/50714
|
||||
bargs = ['python', ansible_inventory_path, '-i', self.source]
|
||||
ansible_version = _get_ansible_version(ansible_inventory_path[:-len('-inventory')])
|
||||
if ansible_version != 'unknown':
|
||||
this_version = Version(ansible_version)
|
||||
if this_version >= Version('2.5'):
|
||||
bargs.extend(['--playbook-dir', self.source_dir])
|
||||
if this_version >= Version('2.8'):
|
||||
if self.verbosity:
|
||||
# INFO: -vvv, DEBUG: -vvvvv, for inventory, any more than 3 makes little difference
|
||||
bargs.append('-{}'.format('v' * min(5, self.verbosity * 2 + 1)))
|
||||
bargs.extend(['--playbook-dir', self.source_dir])
|
||||
if self.verbosity:
|
||||
# INFO: -vvv, DEBUG: -vvvvv, for inventory, any more than 3 makes little difference
|
||||
bargs.append('-{}'.format('v' * min(5, self.verbosity * 2 + 1)))
|
||||
logger.debug('Using base command: {}'.format(' '.join(bargs)))
|
||||
return bargs
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Internal tower command.
|
||||
Regsiter this instance with the database for HA tracking.
|
||||
Register this instance with the database for HA tracking.
|
||||
"""
|
||||
|
||||
help = (
|
||||
|
||||
@ -32,4 +32,7 @@ class Command(BaseCommand):
|
||||
sys.exit(1)
|
||||
i = i.first()
|
||||
ig.instances.remove(i)
|
||||
if i.hostname in ig.policy_instance_list:
|
||||
ig.policy_instance_list.remove(i.hostname)
|
||||
ig.save()
|
||||
print("Instance removed from instance group")
|
||||
|
||||
@ -48,7 +48,13 @@ class HostManager(models.Manager):
|
||||
"""When the parent instance of the host query set has a `kind=smart` and a `host_filter`
|
||||
set. Use the `host_filter` to generate the queryset for the hosts.
|
||||
"""
|
||||
qs = super(HostManager, self).get_queryset()
|
||||
qs = super(HostManager, self).get_queryset().defer(
|
||||
'last_job__extra_vars',
|
||||
'last_job_host_summary__job__extra_vars',
|
||||
'last_job__artifacts',
|
||||
'last_job_host_summary__job__artifacts',
|
||||
)
|
||||
|
||||
if (hasattr(self, 'instance') and
|
||||
hasattr(self.instance, 'host_filter') and
|
||||
hasattr(self.instance, 'kind')):
|
||||
|
||||
23
awx/main/migrations/0118_add_remote_archive_scm_type.py
Normal file
23
awx/main/migrations/0118_add_remote_archive_scm_type.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.11 on 2020-08-18 22:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0117_v400_remove_cloudforms_inventory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='scm_type',
|
||||
field=models.CharField(blank=True, choices=[('', 'Manual'), ('git', 'Git'), ('hg', 'Mercurial'), ('svn', 'Subversion'), ('insights', 'Red Hat Insights'), ('archive', 'Remote Archive')], default='', help_text='Specifies the source control system used to store the project.', max_length=8, verbose_name='SCM Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectupdate',
|
||||
name='scm_type',
|
||||
field=models.CharField(blank=True, choices=[('', 'Manual'), ('git', 'Git'), ('hg', 'Mercurial'), ('svn', 'Subversion'), ('insights', 'Red Hat Insights'), ('archive', 'Remote Archive')], default='', help_text='Specifies the source control system used to store the project.', max_length=8, verbose_name='SCM Type'),
|
||||
),
|
||||
]
|
||||
104
awx/main/migrations/0119_inventory_plugins.py
Normal file
104
awx/main/migrations/0119_inventory_plugins.py
Normal file
@ -0,0 +1,104 @@
|
||||
# Generated by Django 2.2.11 on 2020-07-20 19:56
|
||||
|
||||
import logging
|
||||
import yaml
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from awx.main.models.base import VarsDictProperty
|
||||
|
||||
from ._inventory_source_vars import FrozenInjectors
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
|
||||
def _get_inventory_sources(InventorySource):
|
||||
return InventorySource.objects.filter(source__in=['ec2', 'gce', 'azure_rm', 'vmware', 'satellite6', 'openstack', 'rhv', 'tower'])
|
||||
|
||||
|
||||
def inventory_source_vars_forward(apps, schema_editor):
|
||||
InventorySource = apps.get_model("main", "InventorySource")
|
||||
'''
|
||||
The Django app registry does not keep track of model inheritance. The
|
||||
source_vars_dict property comes from InventorySourceOptions via inheritance.
|
||||
This adds that property. Luckily, other properteries and functionality from
|
||||
InventorySourceOptions is not needed by the injector logic.
|
||||
'''
|
||||
setattr(InventorySource, 'source_vars_dict', VarsDictProperty('source_vars'))
|
||||
source_vars_backup = dict()
|
||||
|
||||
for inv_source_obj in _get_inventory_sources(InventorySource):
|
||||
|
||||
if inv_source_obj.source in FrozenInjectors:
|
||||
source_vars_backup[inv_source_obj.id] = dict(inv_source_obj.source_vars_dict)
|
||||
|
||||
injector = FrozenInjectors[inv_source_obj.source]()
|
||||
new_inv_source_vars = injector.inventory_as_dict(inv_source_obj, None)
|
||||
inv_source_obj.source_vars = yaml.dump(new_inv_source_vars)
|
||||
inv_source_obj.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0118_add_remote_archive_scm_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(inventory_source_vars_forward),
|
||||
migrations.RemoveField(
|
||||
model_name='inventorysource',
|
||||
name='group_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inventoryupdate',
|
||||
name='group_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inventorysource',
|
||||
name='instance_filters',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inventoryupdate',
|
||||
name='instance_filters',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inventorysource',
|
||||
name='source_regions',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inventoryupdate',
|
||||
name='source_regions',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventorysource',
|
||||
name='enabled_value',
|
||||
field=models.TextField(blank=True, default='', help_text='Only used when enabled_var is set. Value when the host is considered enabled. For example if enabled_var="status.power_state"and enabled_value="powered_on" with host variables:{ "status": { "power_state": "powered_on", "created": "2020-08-04T18:13:04+00:00", "healthy": true }, "name": "foobar", "ip_address": "192.168.2.1"}The host would be marked enabled. If power_state where any value other than powered_on then the host would be disabled when imported into Tower. If the key is not found then the host will be enabled'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventorysource',
|
||||
name='enabled_var',
|
||||
field=models.TextField(blank=True, default='', help_text='Retrieve the enabled state from the given dict of host variables. The enabled variable may be specified as "foo.bar", in which case the lookup will traverse into nested dicts, equivalent to: from_dict.get("foo", {}).get("bar", default)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventorysource',
|
||||
name='host_filter',
|
||||
field=models.TextField(blank=True, default='', help_text='Regex where only matching hosts will be imported into Tower.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryupdate',
|
||||
name='enabled_value',
|
||||
field=models.TextField(blank=True, default='', help_text='Only used when enabled_var is set. Value when the host is considered enabled. For example if enabled_var="status.power_state"and enabled_value="powered_on" with host variables:{ "status": { "power_state": "powered_on", "created": "2020-08-04T18:13:04+00:00", "healthy": true }, "name": "foobar", "ip_address": "192.168.2.1"}The host would be marked enabled. If power_state where any value other than powered_on then the host would be disabled when imported into Tower. If the key is not found then the host will be enabled'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryupdate',
|
||||
name='enabled_var',
|
||||
field=models.TextField(blank=True, default='', help_text='Retrieve the enabled state from the given dict of host variables. The enabled variable may be specified as "foo.bar", in which case the lookup will traverse into nested dicts, equivalent to: from_dict.get("foo", {}).get("bar", default)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryupdate',
|
||||
name='host_filter',
|
||||
field=models.TextField(blank=True, default='', help_text='Regex where only matching hosts will be imported into Tower.'),
|
||||
),
|
||||
]
|
||||
751
awx/main/migrations/_inventory_source_vars.py
Normal file
751
awx/main/migrations/_inventory_source_vars.py
Normal file
@ -0,0 +1,751 @@
|
||||
import json
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
FrozenInjectors = dict()
|
||||
|
||||
|
||||
class PluginFileInjector(object):
|
||||
plugin_name = None # Ansible core name used to reference plugin
|
||||
# every source should have collection, these are for the collection name
|
||||
namespace = None
|
||||
collection = None
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
"""Default implementation of inventory plugin file contents.
|
||||
There are some valid cases when all parameters can be obtained from
|
||||
the environment variables, example "plugin: linode" is valid
|
||||
ideally, however, some options should be filled from the inventory source data
|
||||
"""
|
||||
if self.plugin_name is None:
|
||||
raise NotImplementedError('At minimum the plugin name is needed for inventory plugin use.')
|
||||
proper_name = f'{self.namespace}.{self.collection}.{self.plugin_name}'
|
||||
return {'plugin': proper_name}
|
||||
|
||||
|
||||
class azure_rm(PluginFileInjector):
|
||||
plugin_name = 'azure_rm'
|
||||
namespace = 'azure'
|
||||
collection = 'azcollection'
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(azure_rm, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
|
||||
source_vars = inventory_source.source_vars_dict
|
||||
|
||||
ret['fail_on_template_errors'] = False
|
||||
|
||||
group_by_hostvar = {
|
||||
'location': {'prefix': '', 'separator': '', 'key': 'location'},
|
||||
'tag': {'prefix': '', 'separator': '', 'key': 'tags.keys() | list if tags else []'},
|
||||
# Introduced with https://github.com/ansible/ansible/pull/53046
|
||||
'security_group': {'prefix': '', 'separator': '', 'key': 'security_group'},
|
||||
'resource_group': {'prefix': '', 'separator': '', 'key': 'resource_group'},
|
||||
# Note, os_family was not documented correctly in script, but defaulted to grouping by it
|
||||
'os_family': {'prefix': '', 'separator': '', 'key': 'os_disk.operating_system_type'}
|
||||
}
|
||||
# by default group by everything
|
||||
# always respect user setting, if they gave it
|
||||
group_by = [
|
||||
grouping_name for grouping_name in group_by_hostvar
|
||||
if source_vars.get('group_by_{}'.format(grouping_name), True)
|
||||
]
|
||||
ret['keyed_groups'] = [group_by_hostvar[grouping_name] for grouping_name in group_by]
|
||||
if 'tag' in group_by:
|
||||
# Nasty syntax to reproduce "key_value" group names in addition to "key"
|
||||
ret['keyed_groups'].append({
|
||||
'prefix': '', 'separator': '',
|
||||
'key': r'dict(tags.keys() | map("regex_replace", "^(.*)$", "\1_") | list | zip(tags.values() | list)) if tags else []'
|
||||
})
|
||||
|
||||
# Compatibility content
|
||||
# TODO: add proper support for instance_filters non-specific to compatibility
|
||||
# TODO: add proper support for group_by non-specific to compatibility
|
||||
# Dashes were not configurable in azure_rm.py script, we do not want unicode, so always use this
|
||||
ret['use_contrib_script_compatible_sanitization'] = True
|
||||
# use same host names as script
|
||||
ret['plain_host_names'] = True
|
||||
# By default the script did not filter hosts
|
||||
ret['default_host_filters'] = []
|
||||
# User-given host filters
|
||||
user_filters = []
|
||||
old_filterables = [
|
||||
('resource_groups', 'resource_group'),
|
||||
('tags', 'tags')
|
||||
# locations / location would be an entry
|
||||
# but this would conflict with source_regions
|
||||
]
|
||||
for key, loc in old_filterables:
|
||||
value = source_vars.get(key, None)
|
||||
if value and isinstance(value, str):
|
||||
# tags can be list of key:value pairs
|
||||
# e.g. 'Creator:jmarshall, peanutbutter:jelly'
|
||||
# or tags can be a list of keys
|
||||
# e.g. 'Creator, peanutbutter'
|
||||
if key == "tags":
|
||||
# grab each key value pair
|
||||
for kvpair in value.split(','):
|
||||
# split into key and value
|
||||
kv = kvpair.split(':')
|
||||
# filter out any host that does not have key
|
||||
# in their tags.keys() variable
|
||||
user_filters.append('"{}" not in tags.keys()'.format(kv[0].strip()))
|
||||
# if a value is provided, check that the key:value pair matches
|
||||
if len(kv) > 1:
|
||||
user_filters.append('tags["{}"] != "{}"'.format(kv[0].strip(), kv[1].strip()))
|
||||
else:
|
||||
user_filters.append('{} not in {}'.format(
|
||||
loc, value.split(',')
|
||||
))
|
||||
if user_filters:
|
||||
ret.setdefault('exclude_host_filters', [])
|
||||
ret['exclude_host_filters'].extend(user_filters)
|
||||
|
||||
ret['conditional_groups'] = {'azure': True}
|
||||
ret['hostvar_expressions'] = {
|
||||
'provisioning_state': 'provisioning_state | title',
|
||||
'computer_name': 'name',
|
||||
'type': 'resource_type',
|
||||
'private_ip': 'private_ipv4_addresses[0] if private_ipv4_addresses else None',
|
||||
'public_ip': 'public_ipv4_addresses[0] if public_ipv4_addresses else None',
|
||||
'public_ip_name': 'public_ip_name if public_ip_name is defined else None',
|
||||
'public_ip_id': 'public_ip_id if public_ip_id is defined else None',
|
||||
'tags': 'tags if tags else None'
|
||||
}
|
||||
# Special functionality from script
|
||||
if source_vars.get('use_private_ip', False):
|
||||
ret['hostvar_expressions']['ansible_host'] = 'private_ipv4_addresses[0]'
|
||||
# end compatibility content
|
||||
|
||||
if inventory_source.source_regions and 'all' not in inventory_source.source_regions:
|
||||
# initialize a list for this section in inventory file
|
||||
ret.setdefault('exclude_host_filters', [])
|
||||
# make a python list of the regions we will use
|
||||
python_regions = [x.strip() for x in inventory_source.source_regions.split(',')]
|
||||
# convert that list in memory to python syntax in a string
|
||||
# now put that in jinja2 syntax operating on hostvar key "location"
|
||||
# and put that as an entry in the exclusions list
|
||||
ret['exclude_host_filters'].append("location not in {}".format(repr(python_regions)))
|
||||
return ret
|
||||
|
||||
class ec2(PluginFileInjector):
|
||||
plugin_name = 'aws_ec2'
|
||||
namespace = 'amazon'
|
||||
collection = 'aws'
|
||||
|
||||
|
||||
def _get_ec2_group_by_choices(self):
|
||||
return [
|
||||
('ami_id', _('Image ID')),
|
||||
('availability_zone', _('Availability Zone')),
|
||||
('aws_account', _('Account')),
|
||||
('instance_id', _('Instance ID')),
|
||||
('instance_state', _('Instance State')),
|
||||
('platform', _('Platform')),
|
||||
('instance_type', _('Instance Type')),
|
||||
('key_pair', _('Key Name')),
|
||||
('region', _('Region')),
|
||||
('security_group', _('Security Group')),
|
||||
('tag_keys', _('Tags')),
|
||||
('tag_none', _('Tag None')),
|
||||
('vpc_id', _('VPC ID')),
|
||||
]
|
||||
|
||||
def _compat_compose_vars(self):
|
||||
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))"
|
||||
),
|
||||
'ec2_dns_name': 'public_dns_name',
|
||||
'ec2_group_name': 'placement.group_name',
|
||||
'ec2_instance_profile': 'iam_instance_profile | default("")',
|
||||
'ec2_ip_address': 'public_ip_address',
|
||||
'ec2_kernel': 'kernel_id | default("")',
|
||||
'ec2_monitored': "monitoring.state in ['enabled', 'pending']",
|
||||
'ec2_monitoring_state': 'monitoring.state',
|
||||
'ec2_placement': 'placement.availability_zone',
|
||||
'ec2_ramdisk': 'ramdisk_id | default("")',
|
||||
'ec2_reason': 'state_transition_reason',
|
||||
'ec2_security_group_ids': "security_groups | map(attribute='group_id') | list | join(',')",
|
||||
'ec2_security_group_names': "security_groups | map(attribute='group_name') | list | join(',')",
|
||||
'ec2_tag_Name': 'tags.Name',
|
||||
'ec2_state': 'state.name',
|
||||
'ec2_state_code': 'state.code',
|
||||
'ec2_state_reason': 'state_reason.message if state_reason is defined else ""',
|
||||
'ec2_sourceDestCheck': 'source_dest_check | default(false) | lower | string', # snake_case syntax intended
|
||||
'ec2_account_id': 'owner_id',
|
||||
# vars that just need ec2_ prefix
|
||||
'ec2_ami_launch_index': 'ami_launch_index | string',
|
||||
'ec2_architecture': 'architecture',
|
||||
'ec2_client_token': 'client_token',
|
||||
'ec2_ebs_optimized': 'ebs_optimized',
|
||||
'ec2_hypervisor': 'hypervisor',
|
||||
'ec2_image_id': 'image_id',
|
||||
'ec2_instance_type': 'instance_type',
|
||||
'ec2_key_name': 'key_name',
|
||||
'ec2_launch_time': r'launch_time | regex_replace(" ", "T") | regex_replace("(\+)(\d\d):(\d)(\d)$", ".\g<2>\g<3>Z")',
|
||||
'ec2_platform': 'platform | default("")',
|
||||
'ec2_private_dns_name': 'private_dns_name',
|
||||
'ec2_private_ip_address': 'private_ip_address',
|
||||
'ec2_public_dns_name': 'public_dns_name',
|
||||
'ec2_region': 'placement.region',
|
||||
'ec2_root_device_name': 'root_device_name',
|
||||
'ec2_root_device_type': 'root_device_type',
|
||||
# many items need blank defaults because the script tended to keep a common schema
|
||||
'ec2_spot_instance_request_id': 'spot_instance_request_id | default("")',
|
||||
'ec2_subnet_id': 'subnet_id | default("")',
|
||||
'ec2_virtualization_type': 'virtualization_type',
|
||||
'ec2_vpc_id': 'vpc_id | default("")',
|
||||
# same as ec2_ip_address, the script provided this
|
||||
'ansible_host': 'public_ip_address',
|
||||
# new with https://github.com/ansible/ansible/pull/53645
|
||||
'ec2_eventsSet': 'events | default("")',
|
||||
'ec2_persistent': 'persistent | default(false)',
|
||||
'ec2_requester_id': 'requester_id | default("")'
|
||||
}
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(ec2, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
|
||||
keyed_groups = []
|
||||
group_by_hostvar = {
|
||||
'ami_id': {'prefix': '', 'separator': '', 'key': 'image_id', 'parent_group': 'images'},
|
||||
# 2 entries for zones for same groups to establish 2 parentage trees
|
||||
'availability_zone': {'prefix': '', 'separator': '', 'key': 'placement.availability_zone', 'parent_group': 'zones'},
|
||||
'aws_account': {'prefix': '', 'separator': '', 'key': 'ec2_account_id', 'parent_group': 'accounts'}, # composed var
|
||||
'instance_id': {'prefix': '', 'separator': '', 'key': 'instance_id', 'parent_group': 'instances'}, # normally turned off
|
||||
'instance_state': {'prefix': 'instance_state', 'key': 'ec2_state', 'parent_group': 'instance_states'}, # composed var
|
||||
# ec2_platform is a composed var, but group names do not match up to hostvar exactly
|
||||
'platform': {'prefix': 'platform', 'key': 'platform | default("undefined")', 'parent_group': 'platforms'},
|
||||
'instance_type': {'prefix': 'type', 'key': 'instance_type', 'parent_group': 'types'},
|
||||
'key_pair': {'prefix': 'key', 'key': 'key_name', 'parent_group': 'keys'},
|
||||
'region': {'prefix': '', 'separator': '', 'key': 'placement.region', 'parent_group': 'regions'},
|
||||
# Security requires some ninja jinja2 syntax, credit to s-hertel
|
||||
'security_group': {'prefix': 'security_group', 'key': 'security_groups | map(attribute="group_name")', 'parent_group': 'security_groups'},
|
||||
# tags cannot be parented in exactly the same way as the script due to
|
||||
# https://github.com/ansible/ansible/pull/53812
|
||||
'tag_keys': [
|
||||
{'prefix': 'tag', 'key': 'tags', 'parent_group': 'tags'},
|
||||
{'prefix': 'tag', 'key': 'tags.keys()', 'parent_group': 'tags'}
|
||||
],
|
||||
# 'tag_none': None, # grouping by no tags isn't a different thing with plugin
|
||||
# naming is redundant, like vpc_id_vpc_8c412cea, but intended
|
||||
'vpc_id': {'prefix': 'vpc_id', 'key': 'vpc_id', 'parent_group': 'vpcs'},
|
||||
}
|
||||
# -- same-ish as script here --
|
||||
group_by = [x.strip().lower() for x in inventory_source.group_by.split(',') if x.strip()]
|
||||
for choice in self._get_ec2_group_by_choices():
|
||||
value = bool((group_by and choice[0] in group_by) or (not group_by and choice[0] != 'instance_id'))
|
||||
# -- end sameness to script --
|
||||
if value:
|
||||
this_keyed_group = group_by_hostvar.get(choice[0], None)
|
||||
# If a keyed group syntax does not exist, there is nothing we can do to get this group
|
||||
if this_keyed_group is not None:
|
||||
if isinstance(this_keyed_group, list):
|
||||
keyed_groups.extend(this_keyed_group)
|
||||
else:
|
||||
keyed_groups.append(this_keyed_group)
|
||||
# special case, this parentage is only added if both zones and regions are present
|
||||
if not group_by or ('region' in group_by and 'availability_zone' in group_by):
|
||||
keyed_groups.append({'prefix': '', 'separator': '', 'key': 'placement.availability_zone', 'parent_group': '{{ placement.region }}'})
|
||||
|
||||
source_vars = inventory_source.source_vars_dict
|
||||
# This is a setting from the script, hopefully no one used it
|
||||
# if true, it replaces dashes, but not in region / loc names
|
||||
replace_dash = bool(source_vars.get('replace_dash_in_groups', True))
|
||||
# Compatibility content
|
||||
legacy_regex = {
|
||||
True: r"[^A-Za-z0-9\_]",
|
||||
False: r"[^A-Za-z0-9\_\-]" # do not replace dash, dash is allowed
|
||||
}[replace_dash]
|
||||
list_replacer = 'map("regex_replace", "{rx}", "_") | list'.format(rx=legacy_regex)
|
||||
# this option, a plugin option, will allow dashes, but not unicode
|
||||
# when set to False, unicode will be allowed, but it was not allowed by script
|
||||
# thus, we always have to use this option, and always use our custom regex
|
||||
ret['use_contrib_script_compatible_sanitization'] = True
|
||||
for grouping_data in keyed_groups:
|
||||
if grouping_data['key'] in ('placement.region', 'placement.availability_zone'):
|
||||
# us-east-2 is always us-east-2 according to ec2.py
|
||||
# no sanitization in region-ish groups for the script standards, ever ever
|
||||
continue
|
||||
if grouping_data['key'] == 'tags':
|
||||
# dict jinja2 transformation
|
||||
grouping_data['key'] = 'dict(tags.keys() | {replacer} | zip(tags.values() | {replacer}))'.format(
|
||||
replacer=list_replacer
|
||||
)
|
||||
elif grouping_data['key'] == 'tags.keys()' or grouping_data['prefix'] == 'security_group':
|
||||
# list jinja2 transformation
|
||||
grouping_data['key'] += ' | {replacer}'.format(replacer=list_replacer)
|
||||
else:
|
||||
# string transformation
|
||||
grouping_data['key'] += ' | regex_replace("{rx}", "_")'.format(rx=legacy_regex)
|
||||
# end compatibility content
|
||||
|
||||
if source_vars.get('iam_role_arn', None):
|
||||
ret['iam_role_arn'] = source_vars['iam_role_arn']
|
||||
|
||||
# This was an allowed ec2.ini option, also plugin option, so pass through
|
||||
if source_vars.get('boto_profile', None):
|
||||
ret['boto_profile'] = source_vars['boto_profile']
|
||||
|
||||
elif not replace_dash:
|
||||
# Using the plugin, but still want dashes allowed
|
||||
ret['use_contrib_script_compatible_sanitization'] = True
|
||||
|
||||
if source_vars.get('nested_groups') is False:
|
||||
for this_keyed_group in keyed_groups:
|
||||
this_keyed_group.pop('parent_group', None)
|
||||
|
||||
if keyed_groups:
|
||||
ret['keyed_groups'] = keyed_groups
|
||||
|
||||
# Instance ID not part of compat vars, because of settings.EC2_INSTANCE_ID_VAR
|
||||
compose_dict = {'ec2_id': 'instance_id'}
|
||||
inst_filters = {}
|
||||
|
||||
# Compatibility content
|
||||
compose_dict.update(self._compat_compose_vars())
|
||||
# plugin provides "aws_ec2", but not this which the script gave
|
||||
ret['groups'] = {'ec2': True}
|
||||
if source_vars.get('hostname_variable') is not None:
|
||||
hnames = []
|
||||
for expr in source_vars.get('hostname_variable').split(','):
|
||||
if expr == 'public_dns_name':
|
||||
hnames.append('dns-name')
|
||||
elif not expr.startswith('tag:') and '_' in expr:
|
||||
hnames.append(expr.replace('_', '-'))
|
||||
else:
|
||||
hnames.append(expr)
|
||||
ret['hostnames'] = hnames
|
||||
else:
|
||||
# public_ip as hostname is non-default plugin behavior, script behavior
|
||||
ret['hostnames'] = [
|
||||
'network-interface.addresses.association.public-ip',
|
||||
'dns-name',
|
||||
'private-dns-name'
|
||||
]
|
||||
# The script returned only running state by default, the plugin does not
|
||||
# https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html#options
|
||||
# options: pending | running | shutting-down | terminated | stopping | stopped
|
||||
inst_filters['instance-state-name'] = ['running']
|
||||
# end compatibility content
|
||||
|
||||
if source_vars.get('destination_variable') or source_vars.get('vpc_destination_variable'):
|
||||
for fd in ('destination_variable', 'vpc_destination_variable'):
|
||||
if source_vars.get(fd):
|
||||
compose_dict['ansible_host'] = source_vars.get(fd)
|
||||
break
|
||||
|
||||
if compose_dict:
|
||||
ret['compose'] = compose_dict
|
||||
|
||||
if inventory_source.instance_filters:
|
||||
# logic used to live in ec2.py, now it belongs to us. Yay more code?
|
||||
filter_sets = [f for f in inventory_source.instance_filters.split(',') if f]
|
||||
|
||||
for instance_filter in filter_sets:
|
||||
# AND logic not supported, unclear how to...
|
||||
instance_filter = instance_filter.strip()
|
||||
if not instance_filter or '=' not in instance_filter:
|
||||
continue
|
||||
filter_key, filter_value = [x.strip() for x in instance_filter.split('=', 1)]
|
||||
if not filter_key:
|
||||
continue
|
||||
inst_filters[filter_key] = filter_value
|
||||
|
||||
if inst_filters:
|
||||
ret['filters'] = inst_filters
|
||||
|
||||
if inventory_source.source_regions and 'all' not in inventory_source.source_regions:
|
||||
ret['regions'] = inventory_source.source_regions.split(',')
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class gce(PluginFileInjector):
|
||||
plugin_name = 'gcp_compute'
|
||||
namespace = 'google'
|
||||
collection = 'cloud'
|
||||
|
||||
def _compat_compose_vars(self):
|
||||
# missing: gce_image, gce_uuid
|
||||
# https://github.com/ansible/ansible/issues/51884
|
||||
return {
|
||||
'gce_description': 'description if description else None',
|
||||
'gce_machine_type': 'machineType',
|
||||
'gce_name': 'name',
|
||||
'gce_network': 'networkInterfaces[0].network.name',
|
||||
'gce_private_ip': 'networkInterfaces[0].networkIP',
|
||||
'gce_public_ip': 'networkInterfaces[0].accessConfigs[0].natIP | default(None)',
|
||||
'gce_status': 'status',
|
||||
'gce_subnetwork': 'networkInterfaces[0].subnetwork.name',
|
||||
'gce_tags': 'tags.get("items", [])',
|
||||
'gce_zone': 'zone',
|
||||
'gce_metadata': 'metadata.get("items", []) | items2dict(key_name="key", value_name="value")',
|
||||
# NOTE: image hostvar is enabled via retrieve_image_info option
|
||||
'gce_image': 'image',
|
||||
# We need this as long as hostnames is non-default, otherwise hosts
|
||||
# will not be addressed correctly, was returned in script
|
||||
'ansible_ssh_host': 'networkInterfaces[0].accessConfigs[0].natIP | default(networkInterfaces[0].networkIP)'
|
||||
}
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(gce, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
|
||||
# auth related items
|
||||
ret['auth_kind'] = "serviceaccount"
|
||||
|
||||
filters = []
|
||||
# TODO: implement gce group_by options
|
||||
# gce never processed the group_by field, if it had, we would selectively
|
||||
# apply those options here, but it did not, so all groups are added here
|
||||
keyed_groups = [
|
||||
# the jinja2 syntax is duplicated with compose
|
||||
# https://github.com/ansible/ansible/issues/51883
|
||||
{'prefix': 'network', 'key': 'gce_subnetwork'}, # composed var
|
||||
{'prefix': '', 'separator': '', 'key': 'gce_private_ip'}, # composed var
|
||||
{'prefix': '', 'separator': '', 'key': 'gce_public_ip'}, # composed var
|
||||
{'prefix': '', 'separator': '', 'key': 'machineType'},
|
||||
{'prefix': '', 'separator': '', 'key': 'zone'},
|
||||
{'prefix': 'tag', 'key': 'gce_tags'}, # composed var
|
||||
{'prefix': 'status', 'key': 'status | lower'},
|
||||
# NOTE: image hostvar is enabled via retrieve_image_info option
|
||||
{'prefix': '', 'separator': '', 'key': 'image'},
|
||||
]
|
||||
# This will be used as the gce instance_id, must be universal, non-compat
|
||||
compose_dict = {'gce_id': 'id'}
|
||||
|
||||
# Compatibility content
|
||||
# TODO: proper group_by and instance_filters support, irrelevant of compat mode
|
||||
# The gce.py script never sanitized any names in any way
|
||||
ret['use_contrib_script_compatible_sanitization'] = True
|
||||
# Perform extra API query to get the image hostvar
|
||||
ret['retrieve_image_info'] = True
|
||||
# Add in old hostvars aliases
|
||||
compose_dict.update(self._compat_compose_vars())
|
||||
# Non-default names to match script
|
||||
ret['hostnames'] = ['name', 'public_ip', 'private_ip']
|
||||
# end compatibility content
|
||||
|
||||
if keyed_groups:
|
||||
ret['keyed_groups'] = keyed_groups
|
||||
if filters:
|
||||
ret['filters'] = filters
|
||||
if compose_dict:
|
||||
ret['compose'] = compose_dict
|
||||
if inventory_source.source_regions and 'all' not in inventory_source.source_regions:
|
||||
ret['zones'] = inventory_source.source_regions.split(',')
|
||||
return ret
|
||||
|
||||
|
||||
class vmware(PluginFileInjector):
|
||||
plugin_name = 'vmware_vm_inventory'
|
||||
namespace = 'community'
|
||||
collection = 'vmware'
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(vmware, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
ret['strict'] = False
|
||||
# Documentation of props, see
|
||||
# https://github.com/ansible/ansible/blob/devel/docs/docsite/rst/scenario_guides/vmware_scenarios/vmware_inventory_vm_attributes.rst
|
||||
UPPERCASE_PROPS = [
|
||||
"availableField",
|
||||
"configIssue",
|
||||
"configStatus",
|
||||
"customValue", # optional
|
||||
"datastore",
|
||||
"effectiveRole",
|
||||
"guestHeartbeatStatus", # optional
|
||||
"layout", # optional
|
||||
"layoutEx", # optional
|
||||
"name",
|
||||
"network",
|
||||
"overallStatus",
|
||||
"parentVApp", # optional
|
||||
"permission",
|
||||
"recentTask",
|
||||
"resourcePool",
|
||||
"rootSnapshot",
|
||||
"snapshot", # optional
|
||||
"triggeredAlarmState",
|
||||
"value"
|
||||
]
|
||||
NESTED_PROPS = [
|
||||
"capability",
|
||||
"config",
|
||||
"guest",
|
||||
"runtime",
|
||||
"storage",
|
||||
"summary", # repeat of other properties
|
||||
]
|
||||
ret['properties'] = UPPERCASE_PROPS + NESTED_PROPS
|
||||
ret['compose'] = {'ansible_host': 'guest.ipAddress'} # default value
|
||||
ret['compose']['ansible_ssh_host'] = ret['compose']['ansible_host']
|
||||
# the ansible_uuid was unique every host, every import, from the script
|
||||
ret['compose']['ansible_uuid'] = '99999999 | random | to_uuid'
|
||||
for prop in UPPERCASE_PROPS:
|
||||
if prop == prop.lower():
|
||||
continue
|
||||
ret['compose'][prop.lower()] = prop
|
||||
ret['with_nested_properties'] = True
|
||||
# ret['property_name_format'] = 'lower_case' # only dacrystal/topic/vmware-inventory-plugin-property-format
|
||||
|
||||
# process custom options
|
||||
vmware_opts = dict(inventory_source.source_vars_dict.items())
|
||||
if inventory_source.instance_filters:
|
||||
vmware_opts.setdefault('host_filters', inventory_source.instance_filters)
|
||||
if inventory_source.group_by:
|
||||
vmware_opts.setdefault('groupby_patterns', inventory_source.group_by)
|
||||
|
||||
alias_pattern = vmware_opts.get('alias_pattern')
|
||||
if alias_pattern:
|
||||
ret.setdefault('hostnames', [])
|
||||
for alias in alias_pattern.split(','): # make best effort
|
||||
striped_alias = alias.replace('{', '').replace('}', '').strip() # make best effort
|
||||
if not striped_alias:
|
||||
continue
|
||||
ret['hostnames'].append(striped_alias)
|
||||
|
||||
host_pattern = vmware_opts.get('host_pattern') # not working in script
|
||||
if host_pattern:
|
||||
stripped_hp = host_pattern.replace('{', '').replace('}', '').strip() # make best effort
|
||||
ret['compose']['ansible_host'] = stripped_hp
|
||||
ret['compose']['ansible_ssh_host'] = stripped_hp
|
||||
|
||||
host_filters = vmware_opts.get('host_filters')
|
||||
if host_filters:
|
||||
ret.setdefault('filters', [])
|
||||
for hf in host_filters.split(','):
|
||||
striped_hf = hf.replace('{', '').replace('}', '').strip() # make best effort
|
||||
if not striped_hf:
|
||||
continue
|
||||
ret['filters'].append(striped_hf)
|
||||
else:
|
||||
# default behavior filters by power state
|
||||
ret['filters'] = ['runtime.powerState == "poweredOn"']
|
||||
|
||||
groupby_patterns = vmware_opts.get('groupby_patterns')
|
||||
ret.setdefault('keyed_groups', [])
|
||||
if groupby_patterns:
|
||||
for pattern in groupby_patterns.split(','):
|
||||
stripped_pattern = pattern.replace('{', '').replace('}', '').strip() # make best effort
|
||||
ret['keyed_groups'].append({
|
||||
'prefix': '', 'separator': '',
|
||||
'key': stripped_pattern
|
||||
})
|
||||
else:
|
||||
# default groups from script
|
||||
for entry in ('config.guestId', '"templates" if config.template else "guests"'):
|
||||
ret['keyed_groups'].append({
|
||||
'prefix': '', 'separator': '',
|
||||
'key': entry
|
||||
})
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class openstack(PluginFileInjector):
|
||||
plugin_name = 'openstack'
|
||||
namespace = 'openstack'
|
||||
collection = 'cloud'
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
def use_host_name_for_name(a_bool_maybe):
|
||||
if not isinstance(a_bool_maybe, bool):
|
||||
# Could be specified by user via "host" or "uuid"
|
||||
return a_bool_maybe
|
||||
elif a_bool_maybe:
|
||||
return 'name' # plugin default
|
||||
else:
|
||||
return 'uuid'
|
||||
|
||||
ret = super(openstack, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
ret['fail_on_errors'] = True
|
||||
ret['expand_hostvars'] = True
|
||||
ret['inventory_hostname'] = use_host_name_for_name(False)
|
||||
# Note: mucking with defaults will break import integrity
|
||||
# For the plugin, we need to use the same defaults as the old script
|
||||
# or else imports will conflict. To find script defaults you have
|
||||
# to read source code of the script.
|
||||
#
|
||||
# Script Defaults Plugin Defaults
|
||||
# 'use_hostnames': False, 'name' (True)
|
||||
# 'expand_hostvars': True, 'no' (False)
|
||||
# 'fail_on_errors': True, 'no' (False)
|
||||
#
|
||||
# These are, yet again, different from ansible_variables in script logic
|
||||
# but those are applied inconsistently
|
||||
source_vars = inventory_source.source_vars_dict
|
||||
for var_name in ['expand_hostvars', 'fail_on_errors']:
|
||||
if var_name in source_vars:
|
||||
ret[var_name] = source_vars[var_name]
|
||||
if 'use_hostnames' in source_vars:
|
||||
ret['inventory_hostname'] = use_host_name_for_name(source_vars['use_hostnames'])
|
||||
return ret
|
||||
|
||||
class rhv(PluginFileInjector):
|
||||
"""ovirt uses the custom credential templating, and that is all
|
||||
"""
|
||||
plugin_name = 'ovirt'
|
||||
initial_version = '2.9'
|
||||
namespace = 'ovirt'
|
||||
collection = 'ovirt'
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(rhv, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
ret['ovirt_insecure'] = False # Default changed from script
|
||||
# TODO: process strict option upstream
|
||||
ret['compose'] = {
|
||||
'ansible_host': '(devices.values() | list)[0][0] if devices else None'
|
||||
}
|
||||
ret['keyed_groups'] = []
|
||||
for key in ('cluster', 'status'):
|
||||
ret['keyed_groups'].append({'prefix': key, 'separator': '_', 'key': key})
|
||||
ret['keyed_groups'].append({'prefix': 'tag', 'separator': '_', 'key': 'tags'})
|
||||
ret['ovirt_hostname_preference'] = ['name', 'fqdn']
|
||||
source_vars = inventory_source.source_vars_dict
|
||||
for key, value in source_vars.items():
|
||||
if key == 'plugin':
|
||||
continue
|
||||
ret[key] = value
|
||||
return ret
|
||||
|
||||
|
||||
class satellite6(PluginFileInjector):
|
||||
plugin_name = 'foreman'
|
||||
namespace = 'theforeman'
|
||||
collection = 'foreman'
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(satellite6, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
ret['validate_certs'] = False
|
||||
|
||||
group_patterns = '[]'
|
||||
group_prefix = 'foreman_'
|
||||
want_hostcollections = False
|
||||
want_ansible_ssh_host = False
|
||||
want_facts = True
|
||||
|
||||
foreman_opts = inventory_source.source_vars_dict.copy()
|
||||
for k, v in foreman_opts.items():
|
||||
if k == 'satellite6_group_patterns' and isinstance(v, str):
|
||||
group_patterns = v
|
||||
elif k == 'satellite6_group_prefix' and isinstance(v, str):
|
||||
group_prefix = v
|
||||
elif k == 'satellite6_want_hostcollections' and isinstance(v, bool):
|
||||
want_hostcollections = v
|
||||
elif k == 'satellite6_want_ansible_ssh_host' and isinstance(v, bool):
|
||||
want_ansible_ssh_host = v
|
||||
elif k == 'satellite6_want_facts' and isinstance(v, bool):
|
||||
want_facts = v
|
||||
# add backwards support for ssl_verify
|
||||
# plugin uses new option, validate_certs, instead
|
||||
elif k == 'ssl_verify' and isinstance(v, bool):
|
||||
ret['validate_certs'] = v
|
||||
else:
|
||||
ret[k] = str(v)
|
||||
|
||||
# Compatibility content
|
||||
group_by_hostvar = {
|
||||
"environment": {"prefix": "{}environment_".format(group_prefix),
|
||||
"separator": "",
|
||||
"key": "foreman['environment_name'] | lower | regex_replace(' ', '') | "
|
||||
"regex_replace('[^A-Za-z0-9_]', '_') | regex_replace('none', '')"},
|
||||
"location": {"prefix": "{}location_".format(group_prefix),
|
||||
"separator": "",
|
||||
"key": "foreman['location_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')"},
|
||||
"organization": {"prefix": "{}organization_".format(group_prefix),
|
||||
"separator": "",
|
||||
"key": "foreman['organization_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')"},
|
||||
"lifecycle_environment": {"prefix": "{}lifecycle_environment_".format(group_prefix),
|
||||
"separator": "",
|
||||
"key": "foreman['content_facet_attributes']['lifecycle_environment_name'] | "
|
||||
"lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')"},
|
||||
"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_]', '_')"}
|
||||
}
|
||||
|
||||
ret['legacy_hostvars'] = True # convert hostvar structure to the form used by the script
|
||||
ret['want_params'] = True
|
||||
ret['group_prefix'] = group_prefix
|
||||
ret['want_hostcollections'] = want_hostcollections
|
||||
ret['want_facts'] = want_facts
|
||||
|
||||
if want_ansible_ssh_host:
|
||||
ret['compose'] = {'ansible_ssh_host': "foreman['ip6'] | default(foreman['ip'], true)"}
|
||||
ret['keyed_groups'] = [group_by_hostvar[grouping_name] for grouping_name in group_by_hostvar]
|
||||
|
||||
def form_keyed_group(group_pattern):
|
||||
"""
|
||||
Converts foreman group_pattern to
|
||||
inventory plugin keyed_group
|
||||
|
||||
e.g. {app_param}-{tier_param}-{dc_param}
|
||||
becomes
|
||||
"%s-%s-%s" | format(app_param, tier_param, dc_param)
|
||||
"""
|
||||
if type(group_pattern) is not str:
|
||||
return None
|
||||
params = re.findall('{[^}]*}', group_pattern)
|
||||
if len(params) == 0:
|
||||
return None
|
||||
|
||||
param_names = []
|
||||
for p in params:
|
||||
param_names.append(p[1:-1].strip()) # strip braces and space
|
||||
|
||||
# form keyed_group key by
|
||||
# replacing curly braces with '%s'
|
||||
# (for use with jinja's format filter)
|
||||
key = group_pattern
|
||||
for p in params:
|
||||
key = key.replace(p, '%s', 1)
|
||||
|
||||
# apply jinja filter to key
|
||||
key = '"{}" | format({})'.format(key, ', '.join(param_names))
|
||||
|
||||
keyed_group = {'key': key,
|
||||
'separator': ''}
|
||||
return keyed_group
|
||||
|
||||
try:
|
||||
group_patterns = json.loads(group_patterns)
|
||||
|
||||
if type(group_patterns) is list:
|
||||
for group_pattern in group_patterns:
|
||||
keyed_group = form_keyed_group(group_pattern)
|
||||
if keyed_group:
|
||||
ret['keyed_groups'].append(keyed_group)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Could not parse group_patterns. Expected JSON-formatted string, found: {}'
|
||||
.format(group_patterns))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class tower(PluginFileInjector):
|
||||
plugin_name = 'tower'
|
||||
namespace = 'awx'
|
||||
collection = 'awx'
|
||||
|
||||
def inventory_as_dict(self, inventory_source, private_data_dir):
|
||||
ret = super(tower, self).inventory_as_dict(inventory_source, private_data_dir)
|
||||
# Credentials injected as env vars, same as script
|
||||
try:
|
||||
# plugin can take an actual int type
|
||||
identifier = int(inventory_source.instance_filters)
|
||||
except ValueError:
|
||||
# inventory_id could be a named URL
|
||||
identifier = iri_to_uri(inventory_source.instance_filters)
|
||||
ret['inventory_id'] = identifier
|
||||
ret['include_metadata'] = True # used for license check
|
||||
return ret
|
||||
|
||||
|
||||
for cls in PluginFileInjector.__subclasses__():
|
||||
FrozenInjectors[cls.__name__] = cls
|
||||
File diff suppressed because it is too large
Load Diff
@ -55,6 +55,7 @@ class ProjectOptions(models.Model):
|
||||
('hg', _('Mercurial')),
|
||||
('svn', _('Subversion')),
|
||||
('insights', _('Red Hat Insights')),
|
||||
('archive', _('Remote Archive')),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -14,8 +14,10 @@ from django.utils.translation import ugettext_lazy as _, gettext_noop
|
||||
from django.utils.timezone import now as tz_now
|
||||
|
||||
# AWX
|
||||
from awx.main.dispatch.reaper import reap_job
|
||||
from awx.main.models import (
|
||||
AdHocCommand,
|
||||
Instance,
|
||||
InstanceGroup,
|
||||
InventorySource,
|
||||
InventoryUpdate,
|
||||
@ -515,6 +517,20 @@ class TaskManager():
|
||||
task.job_explanation = timeout_message
|
||||
task.save(update_fields=['status', 'job_explanation', 'timed_out'])
|
||||
|
||||
def reap_jobs_from_orphaned_instances(self):
|
||||
# discover jobs that are in running state but aren't on an execution node
|
||||
# that we know about; this is a fairly rare event, but it can occur if you,
|
||||
# for example, SQL backup an awx install with running jobs and restore it
|
||||
# elsewhere
|
||||
for j in UnifiedJob.objects.filter(
|
||||
status__in=['pending', 'waiting', 'running'],
|
||||
).exclude(
|
||||
execution_node__in=Instance.objects.values_list('hostname', flat=True)
|
||||
):
|
||||
if j.execution_node and not j.is_containerized:
|
||||
logger.error(f'{j.execution_node} is not a registered instance; reaping {j.log_format}')
|
||||
reap_job(j, 'failed')
|
||||
|
||||
def calculate_capacity_consumed(self, tasks):
|
||||
self.graph = InstanceGroup.objects.capacity_values(tasks=tasks, graph=self.graph)
|
||||
|
||||
@ -567,6 +583,7 @@ class TaskManager():
|
||||
self.spawn_workflow_graph_jobs(running_workflow_tasks)
|
||||
|
||||
self.timeout_approval_node()
|
||||
self.reap_jobs_from_orphaned_instances()
|
||||
|
||||
self.process_tasks(all_sorted_tasks)
|
||||
return finished_wfjs
|
||||
|
||||
@ -23,6 +23,7 @@ import fcntl
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
import urllib.parse as urlparse
|
||||
import shlex
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@ -72,7 +73,7 @@ from awx.main.utils import (update_scm_url,
|
||||
ignore_inventory_group_removal, extract_ansible_vars, schedule_task_manager,
|
||||
get_awx_version)
|
||||
from awx.main.utils.ansible import read_ansible_config
|
||||
from awx.main.utils.common import _get_ansible_version, get_custom_venv_choices
|
||||
from awx.main.utils.common import get_custom_venv_choices
|
||||
from awx.main.utils.external_logging import reconfigure_rsyslog
|
||||
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
|
||||
from awx.main.utils.reload import stop_local_services
|
||||
@ -840,12 +841,6 @@ class BaseTask(object):
|
||||
logger.error('Failed to update %s after %d retries.',
|
||||
self.model._meta.object_name, _attempt)
|
||||
|
||||
def get_ansible_version(self, instance):
|
||||
if not hasattr(self, '_ansible_version'):
|
||||
self._ansible_version = _get_ansible_version(
|
||||
ansible_path=self.get_path_to_ansible(instance, executable='ansible'))
|
||||
return self._ansible_version
|
||||
|
||||
def get_path_to(self, *args):
|
||||
'''
|
||||
Return absolute path relative to this file.
|
||||
@ -1630,21 +1625,10 @@ class RunJob(BaseTask):
|
||||
|
||||
return passwords
|
||||
|
||||
def add_ansible_venv(self, venv_path, env, isolated=False):
|
||||
super(RunJob, self).add_ansible_venv(venv_path, env, isolated=isolated)
|
||||
# Add awx/lib to PYTHONPATH.
|
||||
env['PYTHONPATH'] = env.get('PYTHONPATH', '') + self.get_path_to('..', 'lib') + ':'
|
||||
|
||||
def build_env(self, job, private_data_dir, isolated=False, private_data_files=None):
|
||||
'''
|
||||
Build environment dictionary for ansible-playbook.
|
||||
'''
|
||||
plugin_dir = self.get_path_to('..', 'plugins', 'callback')
|
||||
plugin_dirs = [plugin_dir]
|
||||
if hasattr(settings, 'AWX_ANSIBLE_CALLBACK_PLUGINS') and \
|
||||
settings.AWX_ANSIBLE_CALLBACK_PLUGINS:
|
||||
plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
|
||||
plugin_path = ':'.join(plugin_dirs)
|
||||
env = super(RunJob, self).build_env(job, private_data_dir,
|
||||
isolated=isolated,
|
||||
private_data_files=private_data_files)
|
||||
@ -1656,19 +1640,17 @@ class RunJob(BaseTask):
|
||||
env['JOB_ID'] = str(job.pk)
|
||||
env['INVENTORY_ID'] = str(job.inventory.pk)
|
||||
if job.use_fact_cache:
|
||||
library_path = env.get('ANSIBLE_LIBRARY')
|
||||
env['ANSIBLE_LIBRARY'] = ':'.join(
|
||||
filter(None, [
|
||||
library_path,
|
||||
self.get_path_to('..', 'plugins', 'library')
|
||||
])
|
||||
)
|
||||
library_source = self.get_path_to('..', 'plugins', 'library')
|
||||
library_dest = os.path.join(private_data_dir, 'library')
|
||||
copy_tree(library_source, library_dest)
|
||||
env['ANSIBLE_LIBRARY'] = library_dest
|
||||
if job.project:
|
||||
env['PROJECT_REVISION'] = job.project.scm_revision
|
||||
env['ANSIBLE_RETRY_FILES_ENABLED'] = "False"
|
||||
env['MAX_EVENT_RES'] = str(settings.MAX_EVENT_RES_DATA)
|
||||
if not isolated:
|
||||
env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_path
|
||||
if hasattr(settings, 'AWX_ANSIBLE_CALLBACK_PLUGINS') and settings.AWX_ANSIBLE_CALLBACK_PLUGINS:
|
||||
env['ANSIBLE_CALLBACK_PLUGINS'] = ':'.join(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
|
||||
env['AWX_HOST'] = settings.TOWER_URL_BASE
|
||||
|
||||
# Create a directory for ControlPath sockets that is unique to each
|
||||
@ -2043,7 +2025,6 @@ class RunProjectUpdate(BaseTask):
|
||||
# like https://github.com/ansible/ansible/issues/30064
|
||||
env['TMP'] = settings.AWX_PROOT_BASE_PATH
|
||||
env['PROJECT_UPDATE_ID'] = str(project_update.pk)
|
||||
env['ANSIBLE_CALLBACK_PLUGINS'] = self.get_path_to('..', 'plugins', 'callback')
|
||||
if settings.GALAXY_IGNORE_CERTS:
|
||||
env['ANSIBLE_GALAXY_IGNORE'] = True
|
||||
# Set up the public Galaxy server, if enabled
|
||||
@ -2105,7 +2086,7 @@ class RunProjectUpdate(BaseTask):
|
||||
scm_username = False
|
||||
elif scm_url_parts.scheme.endswith('ssh'):
|
||||
scm_password = False
|
||||
elif scm_type == 'insights':
|
||||
elif scm_type in ('insights', 'archive'):
|
||||
extra_vars['scm_username'] = scm_username
|
||||
extra_vars['scm_password'] = scm_password
|
||||
scm_url = update_scm_url(scm_type, scm_url, scm_username,
|
||||
@ -2169,7 +2150,7 @@ class RunProjectUpdate(BaseTask):
|
||||
self._write_extra_vars_file(private_data_dir, extra_vars)
|
||||
|
||||
def build_cwd(self, project_update, private_data_dir):
|
||||
return self.get_path_to('..', 'playbooks')
|
||||
return os.path.join(private_data_dir, 'project')
|
||||
|
||||
def build_playbook_path_relative_to_cwd(self, project_update, private_data_dir):
|
||||
return os.path.join('project_update.yml')
|
||||
@ -2310,6 +2291,12 @@ class RunProjectUpdate(BaseTask):
|
||||
shutil.rmtree(stage_path)
|
||||
os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections
|
||||
|
||||
# the project update playbook is not in a git repo, but uses a vendoring directory
|
||||
# to be consistent with the ansible-runner model,
|
||||
# that is moved into the runner projecct folder here
|
||||
awx_playbooks = self.get_path_to('..', 'playbooks')
|
||||
copy_tree(awx_playbooks, os.path.join(private_data_dir, 'project'))
|
||||
|
||||
@staticmethod
|
||||
def clear_project_cache(cache_dir, keep_value):
|
||||
if os.path.isdir(cache_dir):
|
||||
@ -2449,7 +2436,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
|
||||
@property
|
||||
def proot_show_paths(self):
|
||||
return [self.get_path_to('..', 'plugins', 'inventory'), settings.AWX_ANSIBLE_COLLECTIONS_PATHS]
|
||||
return [settings.AWX_ANSIBLE_COLLECTIONS_PATHS]
|
||||
|
||||
def build_private_data(self, inventory_update, private_data_dir):
|
||||
"""
|
||||
@ -2467,7 +2454,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
If no private data is needed, return None.
|
||||
"""
|
||||
if inventory_update.source in InventorySource.injectors:
|
||||
injector = InventorySource.injectors[inventory_update.source](self.get_ansible_version(inventory_update))
|
||||
injector = InventorySource.injectors[inventory_update.source]()
|
||||
return injector.build_private_data(inventory_update, private_data_dir)
|
||||
|
||||
def build_env(self, inventory_update, private_data_dir, isolated, private_data_files=None):
|
||||
@ -2495,7 +2482,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
|
||||
injector = None
|
||||
if inventory_update.source in InventorySource.injectors:
|
||||
injector = InventorySource.injectors[inventory_update.source](self.get_ansible_version(inventory_update))
|
||||
injector = InventorySource.injectors[inventory_update.source]()
|
||||
|
||||
if injector is not None:
|
||||
env = injector.build_env(inventory_update, env, private_data_dir, private_data_files)
|
||||
@ -2567,23 +2554,18 @@ class RunInventoryUpdate(BaseTask):
|
||||
args.extend(['--venv', inventory_update.ansible_virtualenv_path])
|
||||
|
||||
src = inventory_update.source
|
||||
# Add several options to the shell arguments based on the
|
||||
# inventory-source-specific setting in the AWX configuration.
|
||||
# These settings are "per-source"; it's entirely possible that
|
||||
# they will be different between cloud providers if an AWX user
|
||||
# actively uses more than one.
|
||||
if getattr(settings, '%s_ENABLED_VAR' % src.upper(), False):
|
||||
args.extend(['--enabled-var',
|
||||
getattr(settings, '%s_ENABLED_VAR' % src.upper())])
|
||||
if getattr(settings, '%s_ENABLED_VALUE' % src.upper(), False):
|
||||
args.extend(['--enabled-value',
|
||||
getattr(settings, '%s_ENABLED_VALUE' % src.upper())])
|
||||
if getattr(settings, '%s_GROUP_FILTER' % src.upper(), False):
|
||||
args.extend(['--group-filter',
|
||||
getattr(settings, '%s_GROUP_FILTER' % src.upper())])
|
||||
if getattr(settings, '%s_HOST_FILTER' % src.upper(), False):
|
||||
args.extend(['--host-filter',
|
||||
getattr(settings, '%s_HOST_FILTER' % src.upper())])
|
||||
if inventory_update.enabled_var:
|
||||
args.extend(['--enabled-var', shlex.quote(inventory_update.enabled_var)])
|
||||
args.extend(['--enabled-value', shlex.quote(inventory_update.enabled_value)])
|
||||
else:
|
||||
if getattr(settings, '%s_ENABLED_VAR' % src.upper(), False):
|
||||
args.extend(['--enabled-var',
|
||||
getattr(settings, '%s_ENABLED_VAR' % src.upper())])
|
||||
if getattr(settings, '%s_ENABLED_VALUE' % src.upper(), False):
|
||||
args.extend(['--enabled-value',
|
||||
getattr(settings, '%s_ENABLED_VALUE' % src.upper())])
|
||||
if inventory_update.host_filter:
|
||||
args.extend(['--host-filter', shlex.quote(inventory_update.host_filter)])
|
||||
if getattr(settings, '%s_EXCLUDE_EMPTY_GROUPS' % src.upper()):
|
||||
args.append('--exclude-empty-groups')
|
||||
if getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper(), False):
|
||||
@ -2613,7 +2595,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
|
||||
injector = None
|
||||
if inventory_update.source in InventorySource.injectors:
|
||||
injector = InventorySource.injectors[src](self.get_ansible_version(inventory_update))
|
||||
injector = InventorySource.injectors[src]()
|
||||
|
||||
if injector is not None:
|
||||
content = injector.inventory_contents(inventory_update, private_data_dir)
|
||||
@ -2756,7 +2738,6 @@ class RunAdHocCommand(BaseTask):
|
||||
'''
|
||||
Build environment dictionary for ansible.
|
||||
'''
|
||||
plugin_dir = self.get_path_to('..', 'plugins', 'callback')
|
||||
env = super(RunAdHocCommand, self).build_env(ad_hoc_command, private_data_dir,
|
||||
isolated=isolated,
|
||||
private_data_files=private_data_files)
|
||||
@ -2766,7 +2747,6 @@ class RunAdHocCommand(BaseTask):
|
||||
env['AD_HOC_COMMAND_ID'] = str(ad_hoc_command.pk)
|
||||
env['INVENTORY_ID'] = str(ad_hoc_command.inventory.pk)
|
||||
env['INVENTORY_HOSTVARS'] = str(True)
|
||||
env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_dir
|
||||
env['ANSIBLE_LOAD_CALLBACK_PLUGINS'] = '1'
|
||||
env['ANSIBLE_SFTP_BATCH_MODE'] = 'False'
|
||||
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
conditional_groups:
|
||||
azure: true
|
||||
default_host_filters: []
|
||||
exclude_host_filters:
|
||||
- resource_group not in ['foo_resources', 'bar_resources']
|
||||
- '"Creator" not in tags.keys()'
|
||||
- tags["Creator"] != "jmarshall"
|
||||
- '"peanutbutter" not in tags.keys()'
|
||||
- tags["peanutbutter"] != "jelly"
|
||||
- location not in ['southcentralus', 'westus']
|
||||
fail_on_template_errors: false
|
||||
hostvar_expressions:
|
||||
ansible_host: private_ipv4_addresses[0]
|
||||
computer_name: name
|
||||
private_ip: private_ipv4_addresses[0] if private_ipv4_addresses else None
|
||||
provisioning_state: provisioning_state | title
|
||||
public_ip: public_ipv4_addresses[0] if public_ipv4_addresses else None
|
||||
public_ip_id: public_ip_id if public_ip_id is defined else None
|
||||
public_ip_name: public_ip_name if public_ip_name is defined else None
|
||||
tags: tags if tags else None
|
||||
type: resource_type
|
||||
keyed_groups:
|
||||
- key: location
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: tags.keys() | list if tags else []
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: security_group
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: resource_group
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: os_disk.operating_system_type
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: dict(tags.keys() | map("regex_replace", "^(.*)$", "\1_") | list | zip(tags.values() | list)) if tags else []
|
||||
prefix: ''
|
||||
separator: ''
|
||||
plain_host_names: true
|
||||
plugin: azure.azcollection.azure_rm
|
||||
use_contrib_script_compatible_sanitization: true
|
||||
@ -1,81 +0,0 @@
|
||||
boto_profile: /tmp/my_boto_stuff
|
||||
compose:
|
||||
ansible_host: public_dns_name
|
||||
ec2_account_id: owner_id
|
||||
ec2_ami_launch_index: ami_launch_index | string
|
||||
ec2_architecture: architecture
|
||||
ec2_block_devices: dict(block_device_mappings | map(attribute='device_name') | list | zip(block_device_mappings | map(attribute='ebs.volume_id') | list))
|
||||
ec2_client_token: client_token
|
||||
ec2_dns_name: public_dns_name
|
||||
ec2_ebs_optimized: ebs_optimized
|
||||
ec2_eventsSet: events | default("")
|
||||
ec2_group_name: placement.group_name
|
||||
ec2_hypervisor: hypervisor
|
||||
ec2_id: instance_id
|
||||
ec2_image_id: image_id
|
||||
ec2_instance_profile: iam_instance_profile | default("")
|
||||
ec2_instance_type: instance_type
|
||||
ec2_ip_address: public_ip_address
|
||||
ec2_kernel: kernel_id | default("")
|
||||
ec2_key_name: key_name
|
||||
ec2_launch_time: launch_time | regex_replace(" ", "T") | regex_replace("(\+)(\d\d):(\d)(\d)$", ".\g<2>\g<3>Z")
|
||||
ec2_monitored: monitoring.state in ['enabled', 'pending']
|
||||
ec2_monitoring_state: monitoring.state
|
||||
ec2_persistent: persistent | default(false)
|
||||
ec2_placement: placement.availability_zone
|
||||
ec2_platform: platform | default("")
|
||||
ec2_private_dns_name: private_dns_name
|
||||
ec2_private_ip_address: private_ip_address
|
||||
ec2_public_dns_name: public_dns_name
|
||||
ec2_ramdisk: ramdisk_id | default("")
|
||||
ec2_reason: state_transition_reason
|
||||
ec2_region: placement.region
|
||||
ec2_requester_id: requester_id | default("")
|
||||
ec2_root_device_name: root_device_name
|
||||
ec2_root_device_type: root_device_type
|
||||
ec2_security_group_ids: security_groups | map(attribute='group_id') | list | join(',')
|
||||
ec2_security_group_names: security_groups | map(attribute='group_name') | list | join(',')
|
||||
ec2_sourceDestCheck: source_dest_check | default(false) | lower | string
|
||||
ec2_spot_instance_request_id: spot_instance_request_id | default("")
|
||||
ec2_state: state.name
|
||||
ec2_state_code: state.code
|
||||
ec2_state_reason: state_reason.message if state_reason is defined else ""
|
||||
ec2_subnet_id: subnet_id | default("")
|
||||
ec2_tag_Name: tags.Name
|
||||
ec2_virtualization_type: virtualization_type
|
||||
ec2_vpc_id: vpc_id | default("")
|
||||
filters:
|
||||
instance-state-name:
|
||||
- running
|
||||
groups:
|
||||
ec2: true
|
||||
hostnames:
|
||||
- dns-name
|
||||
iam_role_arn: arn:aws:iam::123456789012:role/test-role
|
||||
keyed_groups:
|
||||
- key: placement.availability_zone
|
||||
parent_group: zones
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: instance_type | regex_replace("[^A-Za-z0-9\_]", "_")
|
||||
parent_group: types
|
||||
prefix: type
|
||||
- key: placement.region
|
||||
parent_group: regions
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: dict(tags.keys() | map("regex_replace", "[^A-Za-z0-9\_]", "_") | list | zip(tags.values() | map("regex_replace", "[^A-Za-z0-9\_]", "_") | list))
|
||||
parent_group: tags
|
||||
prefix: tag
|
||||
- key: tags.keys() | map("regex_replace", "[^A-Za-z0-9\_]", "_") | list
|
||||
parent_group: tags
|
||||
prefix: tag
|
||||
- key: placement.availability_zone
|
||||
parent_group: '{{ placement.region }}'
|
||||
prefix: ''
|
||||
separator: ''
|
||||
plugin: amazon.aws.aws_ec2
|
||||
regions:
|
||||
- us-east-2
|
||||
- ap-south-1
|
||||
use_contrib_script_compatible_sanitization: true
|
||||
@ -1,50 +0,0 @@
|
||||
auth_kind: serviceaccount
|
||||
compose:
|
||||
ansible_ssh_host: networkInterfaces[0].accessConfigs[0].natIP | default(networkInterfaces[0].networkIP)
|
||||
gce_description: description if description else None
|
||||
gce_id: id
|
||||
gce_image: image
|
||||
gce_machine_type: machineType
|
||||
gce_metadata: metadata.get("items", []) | items2dict(key_name="key", value_name="value")
|
||||
gce_name: name
|
||||
gce_network: networkInterfaces[0].network.name
|
||||
gce_private_ip: networkInterfaces[0].networkIP
|
||||
gce_public_ip: networkInterfaces[0].accessConfigs[0].natIP | default(None)
|
||||
gce_status: status
|
||||
gce_subnetwork: networkInterfaces[0].subnetwork.name
|
||||
gce_tags: tags.get("items", [])
|
||||
gce_zone: zone
|
||||
hostnames:
|
||||
- name
|
||||
- public_ip
|
||||
- private_ip
|
||||
keyed_groups:
|
||||
- key: gce_subnetwork
|
||||
prefix: network
|
||||
- key: gce_private_ip
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: gce_public_ip
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: machineType
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: zone
|
||||
prefix: ''
|
||||
separator: ''
|
||||
- key: gce_tags
|
||||
prefix: tag
|
||||
- key: status | lower
|
||||
prefix: status
|
||||
- key: image
|
||||
prefix: ''
|
||||
separator: ''
|
||||
plugin: google.cloud.gcp_compute
|
||||
projects:
|
||||
- fooo
|
||||
retrieve_image_info: true
|
||||
use_contrib_script_compatible_sanitization: true
|
||||
zones:
|
||||
- us-east4-a
|
||||
- us-west1-b
|
||||
@ -1,7 +1,3 @@
|
||||
ansible:
|
||||
expand_hostvars: true
|
||||
fail_on_errors: true
|
||||
use_hostnames: false
|
||||
clouds:
|
||||
devstack:
|
||||
auth:
|
||||
@ -11,5 +7,5 @@ clouds:
|
||||
project_domain_name: fooo
|
||||
project_name: fooo
|
||||
username: fooo
|
||||
private: false
|
||||
private: true
|
||||
verify: false
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
expand_hostvars: true
|
||||
fail_on_errors: true
|
||||
inventory_hostname: uuid
|
||||
plugin: openstack.cloud.openstack
|
||||
@ -1,20 +0,0 @@
|
||||
base_source_var: value_of_var
|
||||
compose:
|
||||
ansible_host: (devices.values() | list)[0][0] if devices else None
|
||||
groups:
|
||||
dev: '"dev" in tags'
|
||||
keyed_groups:
|
||||
- key: cluster
|
||||
prefix: cluster
|
||||
separator: _
|
||||
- key: status
|
||||
prefix: status
|
||||
separator: _
|
||||
- key: tags
|
||||
prefix: tag
|
||||
separator: _
|
||||
ovirt_hostname_preference:
|
||||
- name
|
||||
- fqdn
|
||||
ovirt_insecure: false
|
||||
plugin: ovirt.ovirt.ovirt
|
||||
@ -1,30 +0,0 @@
|
||||
base_source_var: value_of_var
|
||||
compose:
|
||||
ansible_ssh_host: foreman['ip6'] | default(foreman['ip'], true)
|
||||
group_prefix: foo_group_prefix
|
||||
keyed_groups:
|
||||
- key: foreman['environment_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_') | regex_replace('none', '')
|
||||
prefix: foo_group_prefixenvironment_
|
||||
separator: ''
|
||||
- key: foreman['location_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')
|
||||
prefix: foo_group_prefixlocation_
|
||||
separator: ''
|
||||
- key: foreman['organization_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')
|
||||
prefix: foo_group_prefixorganization_
|
||||
separator: ''
|
||||
- key: foreman['content_facet_attributes']['lifecycle_environment_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')
|
||||
prefix: foo_group_prefixlifecycle_environment_
|
||||
separator: ''
|
||||
- key: foreman['content_facet_attributes']['content_view_name'] | lower | regex_replace(' ', '') | regex_replace('[^A-Za-z0-9_]', '_')
|
||||
prefix: foo_group_prefixcontent_view_
|
||||
separator: ''
|
||||
- key: '"%s-%s-%s" | format(app, tier, color)'
|
||||
separator: ''
|
||||
- key: '"%s-%s" | format(app, color)'
|
||||
separator: ''
|
||||
legacy_hostvars: true
|
||||
plugin: theforeman.foreman.foreman
|
||||
validate_certs: false
|
||||
want_facts: true
|
||||
want_hostcollections: true
|
||||
want_params: true
|
||||
@ -1,3 +0,0 @@
|
||||
include_metadata: true
|
||||
inventory_id: 42
|
||||
plugin: awx.awx.tower
|
||||
@ -1,55 +0,0 @@
|
||||
compose:
|
||||
ansible_host: guest.ipAddress
|
||||
ansible_ssh_host: guest.ipAddress
|
||||
ansible_uuid: 99999999 | random | to_uuid
|
||||
availablefield: availableField
|
||||
configissue: configIssue
|
||||
configstatus: configStatus
|
||||
customvalue: customValue
|
||||
effectiverole: effectiveRole
|
||||
guestheartbeatstatus: guestHeartbeatStatus
|
||||
layoutex: layoutEx
|
||||
overallstatus: overallStatus
|
||||
parentvapp: parentVApp
|
||||
recenttask: recentTask
|
||||
resourcepool: resourcePool
|
||||
rootsnapshot: rootSnapshot
|
||||
triggeredalarmstate: triggeredAlarmState
|
||||
filters:
|
||||
- config.zoo == "DC0_H0_VM0"
|
||||
hostnames:
|
||||
- config.foo
|
||||
keyed_groups:
|
||||
- key: config.asdf
|
||||
prefix: ''
|
||||
separator: ''
|
||||
plugin: community.vmware.vmware_vm_inventory
|
||||
properties:
|
||||
- availableField
|
||||
- configIssue
|
||||
- configStatus
|
||||
- customValue
|
||||
- datastore
|
||||
- effectiveRole
|
||||
- guestHeartbeatStatus
|
||||
- layout
|
||||
- layoutEx
|
||||
- name
|
||||
- network
|
||||
- overallStatus
|
||||
- parentVApp
|
||||
- permission
|
||||
- recentTask
|
||||
- resourcePool
|
||||
- rootSnapshot
|
||||
- snapshot
|
||||
- triggeredAlarmState
|
||||
- value
|
||||
- capability
|
||||
- config
|
||||
- guest
|
||||
- runtime
|
||||
- storage
|
||||
- summary
|
||||
strict: false
|
||||
with_nested_properties: true
|
||||
@ -12,7 +12,8 @@ def test_empty():
|
||||
'git': 0,
|
||||
'svn': 0,
|
||||
'hg': 0,
|
||||
'insights': 0
|
||||
'insights': 0,
|
||||
'archive': 0,
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +25,8 @@ def test_multiple(scm_type):
|
||||
'git': 0,
|
||||
'svn': 0,
|
||||
'hg': 0,
|
||||
'insights': 0
|
||||
'insights': 0,
|
||||
'archive': 0,
|
||||
}
|
||||
for i in range(random.randint(0, 10)):
|
||||
Project(scm_type=scm_type).save()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -8,8 +9,6 @@ from awx.api.versioning import reverse
|
||||
|
||||
from awx.main.models import InventorySource, Inventory, ActivityStream
|
||||
|
||||
import json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scm_inventory(inventory, project):
|
||||
@ -522,7 +521,8 @@ class TestInventorySourceCredential:
|
||||
data={
|
||||
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
|
||||
'source_project': project.pk, 'source_path': '',
|
||||
'credential': vault_credential.pk
|
||||
'credential': vault_credential.pk,
|
||||
'source_vars': 'plugin: a.b.c',
|
||||
},
|
||||
expect=400,
|
||||
user=admin_user
|
||||
@ -561,7 +561,7 @@ class TestInventorySourceCredential:
|
||||
data={
|
||||
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
|
||||
'source_project': project.pk, 'source_path': '',
|
||||
'credential': os_cred.pk
|
||||
'credential': os_cred.pk, 'source_vars': 'plugin: a.b.c',
|
||||
},
|
||||
expect=201,
|
||||
user=admin_user
|
||||
@ -636,8 +636,14 @@ class TestControlledBySCM:
|
||||
assert scm_inventory.inventory_sources.count() == 0
|
||||
|
||||
def test_adding_inv_src_ok(self, post, scm_inventory, project, admin_user):
|
||||
post(reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}),
|
||||
{'name': 'new inv src', 'source_project': project.pk, 'update_on_project_update': False, 'source': 'scm', 'overwrite_vars': True},
|
||||
post(reverse('api:inventory_inventory_sources_list',
|
||||
kwargs={'pk': scm_inventory.id}),
|
||||
{'name': 'new inv src',
|
||||
'source_project': project.pk,
|
||||
'update_on_project_update': False,
|
||||
'source': 'scm',
|
||||
'overwrite_vars': True,
|
||||
'source_vars': 'plugin: a.b.c'},
|
||||
admin_user, expect=201)
|
||||
|
||||
def test_adding_inv_src_prohibited(self, post, scm_inventory, project, admin_user):
|
||||
@ -657,7 +663,7 @@ class TestControlledBySCM:
|
||||
def test_adding_inv_src_without_proj_access_prohibited(self, post, project, inventory, rando):
|
||||
inventory.admin_role.members.add(rando)
|
||||
post(reverse('api:inventory_inventory_sources_list', kwargs={'pk': inventory.id}),
|
||||
{'name': 'new inv src', 'source_project': project.pk, 'source': 'scm', 'overwrite_vars': True},
|
||||
{'name': 'new inv src', 'source_project': project.pk, 'source': 'scm', 'overwrite_vars': True, 'source_vars': 'plugin: a.b.c'},
|
||||
rando, expect=403)
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import pytest
|
||||
from unittest import mock
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@ -256,33 +255,22 @@ class TestInventorySourceInjectors:
|
||||
are named correctly, because Ansible will reject files that do
|
||||
not have these exact names
|
||||
"""
|
||||
injector = InventorySource.injectors[source]('2.7.7')
|
||||
injector = InventorySource.injectors[source]()
|
||||
assert injector.filename == filename
|
||||
|
||||
def test_group_by_azure(self):
|
||||
injector = InventorySource.injectors['azure_rm']('2.9')
|
||||
inv_src = InventorySource(
|
||||
name='azure source', source='azure_rm',
|
||||
source_vars={'group_by_os_family': True}
|
||||
)
|
||||
group_by_on = injector.inventory_as_dict(inv_src, '/tmp/foo')
|
||||
# suspicious, yes, that is just what the script did
|
||||
expected_groups = 6
|
||||
assert len(group_by_on['keyed_groups']) == expected_groups
|
||||
inv_src.source_vars = json.dumps({'group_by_os_family': False})
|
||||
group_by_off = injector.inventory_as_dict(inv_src, '/tmp/foo')
|
||||
# much better, everyone should turn off the flag and live in the future
|
||||
assert len(group_by_off['keyed_groups']) == expected_groups - 1
|
||||
|
||||
def test_tower_plugin_named_url(self):
|
||||
injector = InventorySource.injectors['tower']('2.9')
|
||||
inv_src = InventorySource(
|
||||
name='my tower source', source='tower',
|
||||
# named URL pattern "inventory++organization"
|
||||
instance_filters='Designer hair 읰++Cosmetic_products䵆'
|
||||
)
|
||||
result = injector.inventory_as_dict(inv_src, '/tmp/foo')
|
||||
assert result['inventory_id'] == 'Designer%20hair%20%EC%9D%B0++Cosmetic_products%E4%B5%86'
|
||||
@pytest.mark.parametrize('source,proper_name', [
|
||||
('ec2', 'amazon.aws.aws_ec2'),
|
||||
('openstack', 'openstack.cloud.openstack'),
|
||||
('gce', 'google.cloud.gcp_compute'),
|
||||
('azure_rm', 'azure.azcollection.azure_rm'),
|
||||
('vmware', 'community.vmware.vmware_vm_inventory'),
|
||||
('rhv', 'ovirt.ovirt.ovirt'),
|
||||
('satellite6', 'theforeman.foreman.foreman'),
|
||||
('tower', 'awx.awx.tower'),
|
||||
])
|
||||
def test_plugin_proper_names(self, source, proper_name):
|
||||
injector = InventorySource.injectors[source]()
|
||||
assert injector.get_proper_name() == proper_name
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@ -14,69 +14,6 @@ from django.conf import settings
|
||||
|
||||
DATA = os.path.join(os.path.dirname(data.__file__), 'inventory')
|
||||
|
||||
TEST_SOURCE_FIELDS = {
|
||||
'vmware': {
|
||||
'instance_filters': '{{ config.name == "only_my_server" }},{{ somevar == "bar"}}',
|
||||
'group_by': 'fouo'
|
||||
},
|
||||
'ec2': {
|
||||
'instance_filters': 'foobaa',
|
||||
# group_by selected to capture some non-trivial cross-interactions
|
||||
'group_by': 'availability_zone,instance_type,tag_keys,region',
|
||||
'source_regions': 'us-east-2,ap-south-1'
|
||||
},
|
||||
'gce': {
|
||||
'source_regions': 'us-east4-a,us-west1-b' # surfaced as env var
|
||||
},
|
||||
'azure_rm': {
|
||||
'source_regions': 'southcentralus,westus'
|
||||
},
|
||||
'tower': {
|
||||
'instance_filters': '42'
|
||||
}
|
||||
}
|
||||
|
||||
INI_TEST_VARS = {
|
||||
'ec2': {
|
||||
'boto_profile': '/tmp/my_boto_stuff',
|
||||
'iam_role_arn': 'arn:aws:iam::123456789012:role/test-role',
|
||||
'hostname_variable': 'public_dns_name',
|
||||
'destination_variable': 'public_dns_name'
|
||||
},
|
||||
'gce': {},
|
||||
'openstack': {
|
||||
'private': False,
|
||||
'use_hostnames': False,
|
||||
'expand_hostvars': True,
|
||||
'fail_on_errors': True
|
||||
},
|
||||
'tower': {}, # there are none
|
||||
'vmware': {
|
||||
'alias_pattern': "{{ config.foo }}",
|
||||
'host_filters': '{{ config.zoo == "DC0_H0_VM0" }}',
|
||||
'groupby_patterns': "{{ config.asdf }}",
|
||||
# setting VMWARE_VALIDATE_CERTS is duplicated with env var
|
||||
},
|
||||
'azure_rm': {
|
||||
'use_private_ip': True,
|
||||
'resource_groups': 'foo_resources,bar_resources',
|
||||
'tags': 'Creator:jmarshall, peanutbutter:jelly'
|
||||
},
|
||||
'satellite6': {
|
||||
'satellite6_group_patterns': '["{app}-{tier}-{color}", "{app}-{color}"]',
|
||||
'satellite6_group_prefix': 'foo_group_prefix',
|
||||
'satellite6_want_hostcollections': True,
|
||||
'satellite6_want_ansible_ssh_host': True,
|
||||
'satellite6_want_facts': True
|
||||
},
|
||||
'rhv': { # options specific to the plugin
|
||||
'ovirt_insecure': False,
|
||||
'groups': {
|
||||
'dev': '"dev" in tags'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_fake_var(element):
|
||||
"""Given a credential type field element, makes up something acceptable.
|
||||
@ -245,25 +182,21 @@ def create_reference_data(source_dir, env, content):
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('this_kind', CLOUD_PROVIDERS)
|
||||
def test_inventory_update_injected_content(this_kind, inventory, fake_credential_factory):
|
||||
injector = InventorySource.injectors[this_kind]
|
||||
if injector.plugin_name is None:
|
||||
pytest.skip('Use of inventory plugin is not enabled for this source')
|
||||
|
||||
src_vars = dict(base_source_var='value_of_var')
|
||||
if this_kind in INI_TEST_VARS:
|
||||
src_vars.update(INI_TEST_VARS[this_kind])
|
||||
extra_kwargs = {}
|
||||
if this_kind in TEST_SOURCE_FIELDS:
|
||||
extra_kwargs.update(TEST_SOURCE_FIELDS[this_kind])
|
||||
src_vars['plugin'] = injector.get_proper_name()
|
||||
inventory_source = InventorySource.objects.create(
|
||||
inventory=inventory,
|
||||
source=this_kind,
|
||||
source_vars=src_vars,
|
||||
**extra_kwargs
|
||||
)
|
||||
inventory_source.credentials.add(fake_credential_factory(this_kind))
|
||||
inventory_update = inventory_source.create_unified_job()
|
||||
task = RunInventoryUpdate()
|
||||
|
||||
if InventorySource.injectors[this_kind].plugin_name is None:
|
||||
pytest.skip('Use of inventory plugin is not enabled for this source')
|
||||
|
||||
def substitute_run(envvars=None, **_kw):
|
||||
"""This method will replace run_pexpect
|
||||
instead of running, it will read the private data directory contents
|
||||
@ -274,6 +207,12 @@ def test_inventory_update_injected_content(this_kind, inventory, fake_credential
|
||||
assert envvars.pop('ANSIBLE_INVENTORY_ENABLED') == 'auto'
|
||||
set_files = bool(os.getenv("MAKE_INVENTORY_REFERENCE_FILES", 'false').lower()[0] not in ['f', '0'])
|
||||
env, content = read_content(private_data_dir, envvars, inventory_update)
|
||||
|
||||
# Assert inventory plugin inventory file is in private_data_dir
|
||||
inventory_filename = InventorySource.injectors[inventory_update.source]().filename
|
||||
assert len([True for k in content.keys() if k.endswith(inventory_filename)]) > 0, \
|
||||
f"'{inventory_filename}' file not found in inventory update runtime files {content.keys()}"
|
||||
|
||||
env.pop('ANSIBLE_COLLECTIONS_PATHS', None) # collection paths not relevant to this test
|
||||
base_dir = os.path.join(DATA, 'plugins')
|
||||
if not os.path.exists(base_dir):
|
||||
@ -283,6 +222,8 @@ def test_inventory_update_injected_content(this_kind, inventory, fake_credential
|
||||
create_reference_data(source_dir, env, content)
|
||||
pytest.skip('You set MAKE_INVENTORY_REFERENCE_FILES, so this created files, unset to run actual test.')
|
||||
else:
|
||||
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? '
|
||||
@ -292,9 +233,6 @@ def test_inventory_update_injected_content(this_kind, inventory, fake_credential
|
||||
expected_file_list = os.listdir(files_dir)
|
||||
except FileNotFoundError:
|
||||
expected_file_list = []
|
||||
assert set(expected_file_list) == set(content.keys()), (
|
||||
'Inventory update runtime environment does not have expected files'
|
||||
)
|
||||
for f_name in expected_file_list:
|
||||
with open(os.path.join(files_dir, f_name), 'r') as f:
|
||||
ref_content = f.read()
|
||||
@ -314,8 +252,7 @@ def test_inventory_update_injected_content(this_kind, inventory, fake_credential
|
||||
with mock.patch('awx.main.queue.CallbackQueueDispatcher.dispatch', lambda self, obj: None):
|
||||
# Also do not send websocket status updates
|
||||
with mock.patch.object(UnifiedJob, 'websocket_emit_status', mock.Mock()):
|
||||
with mock.patch.object(task, 'get_ansible_version', return_value='2.13'):
|
||||
# The point of this test is that we replace run with assertions
|
||||
with mock.patch('awx.main.tasks.ansible_runner.interface.run', substitute_run):
|
||||
# so this sets up everything for a run and then yields control over to substitute_run
|
||||
task.run(inventory_update.pk)
|
||||
# The point of this test is that we replace run with assertions
|
||||
with mock.patch('awx.main.tasks.ansible_runner.interface.run', substitute_run):
|
||||
# so this sets up everything for a run and then yields control over to substitute_run
|
||||
task.run(inventory_update.pk)
|
||||
|
||||
@ -72,23 +72,6 @@ def test_invalid_kind_clean_insights_credential():
|
||||
assert json.dumps(str(e.value)) == json.dumps(str([u'Assignment not allowed for Smart Inventory']))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('source_vars,validate_certs', [
|
||||
({'ssl_verify': True}, True),
|
||||
({'ssl_verify': False}, False),
|
||||
({'validate_certs': True}, True),
|
||||
({'validate_certs': False}, False)])
|
||||
def test_satellite_plugin_backwards_support_for_ssl_verify(source_vars, validate_certs):
|
||||
injector = InventorySource.injectors['satellite6']('2.9')
|
||||
inv_src = InventorySource(
|
||||
name='satellite source', source='satellite6',
|
||||
source_vars=source_vars
|
||||
)
|
||||
|
||||
ret = injector.inventory_as_dict(inv_src, '/tmp/foo')
|
||||
assert 'validate_certs' in ret
|
||||
assert ret['validate_certs'] in (validate_certs, str(validate_certs))
|
||||
|
||||
|
||||
class TestControlledBySCM():
|
||||
def test_clean_source_path_valid(self):
|
||||
inv_src = InventorySource(source_path='/not_real/',
|
||||
|
||||
@ -1792,16 +1792,19 @@ class TestProjectUpdateCredentials(TestJobExecution):
|
||||
dict(scm_type='git'),
|
||||
dict(scm_type='hg'),
|
||||
dict(scm_type='svn'),
|
||||
dict(scm_type='archive'),
|
||||
],
|
||||
'test_ssh_key_auth': [
|
||||
dict(scm_type='git'),
|
||||
dict(scm_type='hg'),
|
||||
dict(scm_type='svn'),
|
||||
dict(scm_type='archive'),
|
||||
],
|
||||
'test_awx_task_env': [
|
||||
dict(scm_type='git'),
|
||||
dict(scm_type='hg'),
|
||||
dict(scm_type='svn'),
|
||||
dict(scm_type='archive'),
|
||||
]
|
||||
}
|
||||
|
||||
@ -1877,13 +1880,6 @@ class TestProjectUpdateCredentials(TestJobExecution):
|
||||
assert env['FOO'] == 'BAR'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ansible_version():
|
||||
with mock.patch('awx.main.tasks._get_ansible_version', mock.MagicMock(return_value='2.10')) as _fixture:
|
||||
yield _fixture
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ansible_version")
|
||||
class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
@pytest.fixture
|
||||
def inventory_update(self):
|
||||
@ -2017,7 +2013,6 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
task = tasks.RunInventoryUpdate()
|
||||
azure_rm = CredentialType.defaults['azure_rm']()
|
||||
inventory_update.source = 'azure_rm'
|
||||
inventory_update.source_regions = 'north, south, east, west'
|
||||
|
||||
def get_cred():
|
||||
cred = Credential(
|
||||
@ -2056,7 +2051,6 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
task = tasks.RunInventoryUpdate()
|
||||
azure_rm = CredentialType.defaults['azure_rm']()
|
||||
inventory_update.source = 'azure_rm'
|
||||
inventory_update.source_regions = 'all'
|
||||
|
||||
def get_cred():
|
||||
cred = Credential(
|
||||
@ -2094,7 +2088,6 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
task = tasks.RunInventoryUpdate()
|
||||
gce = CredentialType.defaults['gce']()
|
||||
inventory_update.source = 'gce'
|
||||
inventory_update.source_regions = 'all'
|
||||
|
||||
def get_cred():
|
||||
cred = Credential(
|
||||
@ -2213,7 +2206,6 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
task = tasks.RunInventoryUpdate()
|
||||
tower = CredentialType.defaults['tower']()
|
||||
inventory_update.source = 'tower'
|
||||
inventory_update.instance_filters = '12345'
|
||||
inputs = {
|
||||
'host': 'https://tower.example.org',
|
||||
'username': 'bob',
|
||||
@ -2245,7 +2237,6 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
task = tasks.RunInventoryUpdate()
|
||||
tower = CredentialType.defaults['tower']()
|
||||
inventory_update.source = 'tower'
|
||||
inventory_update.instance_filters = '12345'
|
||||
inputs = {
|
||||
'host': 'https://tower.example.org',
|
||||
'username': 'bob',
|
||||
|
||||
@ -215,11 +215,3 @@ def test_get_custom_venv_choices():
|
||||
os.path.join(temp_dir, ''),
|
||||
os.path.join(custom_venv_1, '')
|
||||
]
|
||||
|
||||
|
||||
def test_region_sorting():
|
||||
s = [('Huey', 'China1'),
|
||||
('Dewey', 'UK1'),
|
||||
('Lewie', 'US1'),
|
||||
('All', 'All')]
|
||||
assert [x[1] for x in sorted(s, key=common.region_sorting)] == ['All', 'US1', 'China1', 'UK1']
|
||||
|
||||
@ -45,7 +45,7 @@ __all__ = [
|
||||
'get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelcase', 'memoize',
|
||||
'memoize_delete', 'get_ansible_version', 'get_licenser', 'get_awx_http_client_headers',
|
||||
'get_awx_version', 'update_scm_url', 'get_type_for_model', 'get_model_for_type',
|
||||
'copy_model_by_class', 'region_sorting', 'copy_m2m_relationships',
|
||||
'copy_model_by_class', 'copy_m2m_relationships',
|
||||
'prefetch_page_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields',
|
||||
'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd',
|
||||
'getattr_dne', 'NoDefaultProvided', 'get_current_apps', 'set_current_apps',
|
||||
@ -87,15 +87,6 @@ def to_python_boolean(value, allow_none=False):
|
||||
raise ValueError(_(u'Unable to convert "%s" to boolean') % value)
|
||||
|
||||
|
||||
def region_sorting(region):
|
||||
# python3's removal of sorted(cmp=...) is _stupid_
|
||||
if region[1].lower() == 'all':
|
||||
return ''
|
||||
elif region[1].lower().startswith('us'):
|
||||
return region[1]
|
||||
return 'ZZZ' + str(region[1])
|
||||
|
||||
|
||||
def camelcase_to_underscore(s):
|
||||
'''
|
||||
Convert CamelCase names to lowercase_with_underscore.
|
||||
@ -171,13 +162,14 @@ def memoize_delete(function_name):
|
||||
return cache.delete(function_name)
|
||||
|
||||
|
||||
def _get_ansible_version(ansible_path):
|
||||
@memoize()
|
||||
def get_ansible_version():
|
||||
'''
|
||||
Return Ansible version installed.
|
||||
Ansible path needs to be provided to account for custom virtual environments
|
||||
'''
|
||||
try:
|
||||
proc = subprocess.Popen([ansible_path, '--version'],
|
||||
proc = subprocess.Popen(['ansible', '--version'],
|
||||
stdout=subprocess.PIPE)
|
||||
result = smart_str(proc.communicate()[0])
|
||||
return result.split('\n')[0].replace('ansible', '').strip()
|
||||
@ -185,11 +177,6 @@ def _get_ansible_version(ansible_path):
|
||||
return 'unknown'
|
||||
|
||||
|
||||
@memoize()
|
||||
def get_ansible_version():
|
||||
return _get_ansible_version('ansible')
|
||||
|
||||
|
||||
def get_awx_version():
|
||||
'''
|
||||
Return AWX version as reported by setuptools.
|
||||
@ -257,7 +244,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
|
||||
# git: https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS
|
||||
# hg: http://www.selenic.com/mercurial/hg.1.html#url-paths
|
||||
# svn: http://svnbook.red-bean.com/en/1.7/svn-book.html#svn.advanced.reposurls
|
||||
if scm_type not in ('git', 'hg', 'svn', 'insights'):
|
||||
if scm_type not in ('git', 'hg', 'svn', 'insights', 'archive'):
|
||||
raise ValueError(_('Unsupported SCM type "%s"') % str(scm_type))
|
||||
if not url.strip():
|
||||
return ''
|
||||
@ -303,7 +290,8 @@ def update_scm_url(scm_type, url, username=True, password=True,
|
||||
'git': ('ssh', 'git', 'git+ssh', 'http', 'https', 'ftp', 'ftps', 'file'),
|
||||
'hg': ('http', 'https', 'ssh', 'file'),
|
||||
'svn': ('http', 'https', 'svn', 'svn+ssh', 'file'),
|
||||
'insights': ('http', 'https')
|
||||
'insights': ('http', 'https'),
|
||||
'archive': ('http', 'https'),
|
||||
}
|
||||
if parts.scheme not in scm_type_schemes.get(scm_type, ()):
|
||||
raise ValueError(_('Unsupported %s URL') % scm_type)
|
||||
@ -339,7 +327,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
|
||||
#raise ValueError('Password not supported for SSH with Mercurial.')
|
||||
netloc_password = ''
|
||||
|
||||
if netloc_username and parts.scheme != 'file' and scm_type != "insights":
|
||||
if netloc_username and parts.scheme != 'file' and scm_type not in ("insights", "archive"):
|
||||
netloc = u':'.join([urllib.parse.quote(x,safe='') for x in (netloc_username, netloc_password) if x])
|
||||
else:
|
||||
netloc = u''
|
||||
|
||||
15
awx/playbooks/action_plugins/hg_deprecation.py
Normal file
15
awx/playbooks/action_plugins/hg_deprecation.py
Normal file
@ -0,0 +1,15 @@
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
self._supports_check_mode = False
|
||||
result = super(ActionModule, self).run(tmp, task_vars)
|
||||
result['changed'] = result['failed'] = False
|
||||
result['msg'] = ''
|
||||
self._display.deprecated("Mercurial support is deprecated")
|
||||
return result
|
||||
82
awx/playbooks/action_plugins/project_archive.py
Normal file
82
awx/playbooks/action_plugins/project_archive.py
Normal file
@ -0,0 +1,82 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import zipfile
|
||||
import tarfile
|
||||
import os
|
||||
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
self._supports_check_mode = False
|
||||
|
||||
result = super(ActionModule, self).run(tmp, task_vars)
|
||||
|
||||
src = self._task.args.get("src")
|
||||
proj_path = self._task.args.get("project_path")
|
||||
force = self._task.args.get("force", False)
|
||||
|
||||
try:
|
||||
archive = zipfile.ZipFile(src)
|
||||
get_filenames = archive.namelist
|
||||
get_members = archive.infolist
|
||||
except zipfile.BadZipFile:
|
||||
archive = tarfile.open(src)
|
||||
get_filenames = archive.getnames
|
||||
get_members = archive.getmembers
|
||||
except tarfile.ReadError:
|
||||
result["failed"] = True
|
||||
result["msg"] = "{0} is not a valid archive".format(src)
|
||||
return result
|
||||
|
||||
# Most well formed archives contain a single root directory, typically named
|
||||
# project-name-1.0.0. The project contents should be inside that directory.
|
||||
start_index = 0
|
||||
root_contents = set(
|
||||
[filename.split(os.path.sep)[0] for filename in get_filenames()]
|
||||
)
|
||||
if len(root_contents) == 1:
|
||||
start_index = len(list(root_contents)[0]) + 1
|
||||
|
||||
for member in get_members():
|
||||
try:
|
||||
filename = member.filename
|
||||
except AttributeError:
|
||||
filename = member.name
|
||||
|
||||
# Skip the archive base directory
|
||||
if not filename[start_index:]:
|
||||
continue
|
||||
|
||||
dest = os.path.join(proj_path, filename[start_index:])
|
||||
|
||||
if not force and os.path.exists(dest):
|
||||
continue
|
||||
|
||||
try:
|
||||
is_dir = member.is_dir()
|
||||
except AttributeError:
|
||||
is_dir = member.isdir()
|
||||
|
||||
if is_dir:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
else:
|
||||
try:
|
||||
member_f = archive.open(member)
|
||||
except TypeError:
|
||||
member_f = tarfile.ExFileObject(archive, member)
|
||||
|
||||
with open(dest, "wb") as f:
|
||||
f.write(member_f.read())
|
||||
member_f.close()
|
||||
|
||||
archive.close()
|
||||
|
||||
result["changed"] = True
|
||||
return result
|
||||
40
awx/playbooks/library/project_archive.py
Normal file
40
awx/playbooks/library/project_archive.py
Normal file
@ -0,0 +1,40 @@
|
||||
ANSIBLE_METADATA = {
|
||||
"metadata_version": "1.0",
|
||||
"status": ["stableinterface"],
|
||||
"supported_by": "community",
|
||||
}
|
||||
|
||||
|
||||
DOCUMENTATION = """
|
||||
---
|
||||
module: project_archive
|
||||
short_description: unpack a project archive
|
||||
description:
|
||||
- Unpacks an archive that contains a project, in order to support handling versioned
|
||||
artifacts from (for example) GitHub Releases or Artifactory builds.
|
||||
- Handles projects in the archive root, or in a single base directory of the archive.
|
||||
version_added: "2.9"
|
||||
options:
|
||||
src:
|
||||
description:
|
||||
- The source archive of the project artifact
|
||||
required: true
|
||||
project_path:
|
||||
description:
|
||||
- Directory to write the project archive contents
|
||||
required: true
|
||||
force:
|
||||
description:
|
||||
- Files in the project_path will be overwritten by matching files in the archive
|
||||
default: False
|
||||
|
||||
author:
|
||||
- "Philip Douglass" @philipsd6
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- project_archive:
|
||||
src: "{{ project_path }}/.archive/project.tar.gz"
|
||||
project_path: "{{ project_path }}"
|
||||
force: "{{ scm_clean }}"
|
||||
"""
|
||||
@ -101,6 +101,50 @@
|
||||
tags:
|
||||
- update_insights
|
||||
|
||||
- block:
|
||||
- name: Ensure the project archive directory is present
|
||||
file:
|
||||
dest: "{{ project_path|quote }}/.archive"
|
||||
state: directory
|
||||
|
||||
- name: Get archive from url
|
||||
get_url:
|
||||
url: "{{ scm_url|quote }}"
|
||||
dest: "{{ project_path|quote }}/.archive/"
|
||||
url_username: "{{ scm_username|default(omit) }}"
|
||||
url_password: "{{ scm_password|default(omit) }}"
|
||||
force_basic_auth: true
|
||||
register: get_archive
|
||||
|
||||
- name: Unpack archive
|
||||
project_archive:
|
||||
src: "{{ get_archive.dest }}"
|
||||
project_path: "{{ project_path|quote }}"
|
||||
force: "{{ scm_clean }}"
|
||||
when: get_archive.changed or scm_clean
|
||||
register: unarchived
|
||||
|
||||
- name: Find previous archives
|
||||
find:
|
||||
paths: "{{ project_path|quote }}/.archive/"
|
||||
excludes:
|
||||
- "{{ get_archive.dest|basename }}"
|
||||
when: unarchived.changed
|
||||
register: previous_archive
|
||||
|
||||
- name: Remove previous archives
|
||||
file:
|
||||
path: "{{ item.path }}"
|
||||
state: absent
|
||||
loop: "{{ previous_archive.files }}"
|
||||
when: previous_archive.files|default([])
|
||||
|
||||
- name: Set scm_version to archive sha1 checksum
|
||||
set_fact:
|
||||
scm_version: "{{ get_archive.checksum_src }}"
|
||||
tags:
|
||||
- update_archive
|
||||
|
||||
- name: Repository Version
|
||||
debug:
|
||||
msg: "Repository Version {{ scm_version }}"
|
||||
@ -109,6 +153,7 @@
|
||||
- update_hg
|
||||
- update_svn
|
||||
- update_insights
|
||||
- update_archive
|
||||
|
||||
- hosts: localhost
|
||||
gather_facts: false
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
---
|
||||
- name: Mercurial support is deprecated.
|
||||
hg_deprecation:
|
||||
|
||||
- name: update project using hg
|
||||
hg:
|
||||
dest: "{{project_path|quote}}"
|
||||
|
||||
@ -8,8 +8,6 @@ from datetime import timedelta
|
||||
|
||||
# global settings
|
||||
from django.conf import global_settings
|
||||
# ugettext lazy
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# Update this module's local settings from the global settings module.
|
||||
this_module = sys.modules[__name__]
|
||||
@ -310,7 +308,7 @@ REST_FRAMEWORK = {
|
||||
'awx.api.parsers.JSONParser',
|
||||
),
|
||||
'DEFAULT_RENDERER_CLASSES': (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
'awx.api.renderers.DefaultJSONRenderer',
|
||||
'awx.api.renderers.BrowsableAPIRenderer',
|
||||
),
|
||||
'DEFAULT_METADATA_CLASS': 'awx.api.metadata.Metadata',
|
||||
@ -671,145 +669,32 @@ INV_ENV_VARIABLE_BLOCKED = ("HOME", "USER", "_", "TERM")
|
||||
# ----------------
|
||||
# -- Amazon EC2 --
|
||||
# ----------------
|
||||
|
||||
# AWS does not appear to provide pretty region names via any API, so store the
|
||||
# list of names here. The available region IDs will be pulled from boto.
|
||||
# http://docs.aws.amazon.com/general/latest/gr/rande.html#ec2_region
|
||||
EC2_REGION_NAMES = {
|
||||
'us-east-1': _('US East (Northern Virginia)'),
|
||||
'us-east-2': _('US East (Ohio)'),
|
||||
'us-west-2': _('US West (Oregon)'),
|
||||
'us-west-1': _('US West (Northern California)'),
|
||||
'ca-central-1': _('Canada (Central)'),
|
||||
'eu-central-1': _('EU (Frankfurt)'),
|
||||
'eu-west-1': _('EU (Ireland)'),
|
||||
'eu-west-2': _('EU (London)'),
|
||||
'ap-southeast-1': _('Asia Pacific (Singapore)'),
|
||||
'ap-southeast-2': _('Asia Pacific (Sydney)'),
|
||||
'ap-northeast-1': _('Asia Pacific (Tokyo)'),
|
||||
'ap-northeast-2': _('Asia Pacific (Seoul)'),
|
||||
'ap-south-1': _('Asia Pacific (Mumbai)'),
|
||||
'sa-east-1': _('South America (Sao Paulo)'),
|
||||
'us-gov-west-1': _('US West (GovCloud)'),
|
||||
'cn-north-1': _('China (Beijing)'),
|
||||
}
|
||||
|
||||
# Inventory variable name/values for determining if host is active/enabled.
|
||||
EC2_ENABLED_VAR = 'ec2_state'
|
||||
EC2_ENABLED_VALUE = 'running'
|
||||
|
||||
# Inventory variable name containing unique instance ID.
|
||||
EC2_INSTANCE_ID_VAR = 'ec2_id'
|
||||
|
||||
# Filter for allowed group/host names when importing inventory from EC2.
|
||||
EC2_GROUP_FILTER = r'^.+$'
|
||||
EC2_HOST_FILTER = r'^.+$'
|
||||
EC2_EXCLUDE_EMPTY_GROUPS = True
|
||||
|
||||
|
||||
# ------------
|
||||
# -- VMware --
|
||||
# ------------
|
||||
# Inventory variable name/values for determining whether a host is
|
||||
# active in vSphere.
|
||||
VMWARE_ENABLED_VAR = 'guest.gueststate'
|
||||
VMWARE_ENABLED_VALUE = 'running'
|
||||
|
||||
# Inventory variable name containing the unique instance ID.
|
||||
VMWARE_INSTANCE_ID_VAR = 'config.instanceUuid, config.instanceuuid'
|
||||
|
||||
# Filter for allowed group and host names when importing inventory
|
||||
# from VMware.
|
||||
VMWARE_GROUP_FILTER = r'^.+$'
|
||||
VMWARE_HOST_FILTER = r'^.+$'
|
||||
VMWARE_EXCLUDE_EMPTY_GROUPS = True
|
||||
|
||||
VMWARE_VALIDATE_CERTS = False
|
||||
|
||||
# ---------------------------
|
||||
# -- Google Compute Engine --
|
||||
# ---------------------------
|
||||
|
||||
# It's not possible to get zones in GCE without authenticating, so we
|
||||
# provide a list here.
|
||||
# Source: https://developers.google.com/compute/docs/zones
|
||||
GCE_REGION_CHOICES = [
|
||||
('us-east1-b', _('US East 1 (B)')),
|
||||
('us-east1-c', _('US East 1 (C)')),
|
||||
('us-east1-d', _('US East 1 (D)')),
|
||||
('us-east4-a', _('US East 4 (A)')),
|
||||
('us-east4-b', _('US East 4 (B)')),
|
||||
('us-east4-c', _('US East 4 (C)')),
|
||||
('us-central1-a', _('US Central (A)')),
|
||||
('us-central1-b', _('US Central (B)')),
|
||||
('us-central1-c', _('US Central (C)')),
|
||||
('us-central1-f', _('US Central (F)')),
|
||||
('us-west1-a', _('US West (A)')),
|
||||
('us-west1-b', _('US West (B)')),
|
||||
('us-west1-c', _('US West (C)')),
|
||||
('europe-west1-b', _('Europe West 1 (B)')),
|
||||
('europe-west1-c', _('Europe West 1 (C)')),
|
||||
('europe-west1-d', _('Europe West 1 (D)')),
|
||||
('europe-west2-a', _('Europe West 2 (A)')),
|
||||
('europe-west2-b', _('Europe West 2 (B)')),
|
||||
('europe-west2-c', _('Europe West 2 (C)')),
|
||||
('asia-east1-a', _('Asia East (A)')),
|
||||
('asia-east1-b', _('Asia East (B)')),
|
||||
('asia-east1-c', _('Asia East (C)')),
|
||||
('asia-southeast1-a', _('Asia Southeast (A)')),
|
||||
('asia-southeast1-b', _('Asia Southeast (B)')),
|
||||
('asia-northeast1-a', _('Asia Northeast (A)')),
|
||||
('asia-northeast1-b', _('Asia Northeast (B)')),
|
||||
('asia-northeast1-c', _('Asia Northeast (C)')),
|
||||
('australia-southeast1-a', _('Australia Southeast (A)')),
|
||||
('australia-southeast1-b', _('Australia Southeast (B)')),
|
||||
('australia-southeast1-c', _('Australia Southeast (C)')),
|
||||
]
|
||||
# Inventory variable name/value for determining whether a host is active
|
||||
# in Google Compute Engine.
|
||||
GCE_ENABLED_VAR = 'status'
|
||||
GCE_ENABLED_VALUE = 'running'
|
||||
|
||||
# Filter for allowed group and host names when importing inventory from
|
||||
# Google Compute Engine.
|
||||
GCE_GROUP_FILTER = r'^.+$'
|
||||
GCE_HOST_FILTER = r'^.+$'
|
||||
GCE_EXCLUDE_EMPTY_GROUPS = True
|
||||
GCE_INSTANCE_ID_VAR = 'gce_id'
|
||||
|
||||
# --------------------------------------
|
||||
# -- Microsoft Azure Resource Manager --
|
||||
# --------------------------------------
|
||||
# It's not possible to get zones in Azure without authenticating, so we
|
||||
# provide a list here.
|
||||
AZURE_RM_REGION_CHOICES = [
|
||||
('eastus', _('US East')),
|
||||
('eastus2', _('US East 2')),
|
||||
('centralus', _('US Central')),
|
||||
('northcentralus', _('US North Central')),
|
||||
('southcentralus', _('US South Central')),
|
||||
('westcentralus', _('US West Central')),
|
||||
('westus', _('US West')),
|
||||
('westus2', _('US West 2')),
|
||||
('canadaeast', _('Canada East')),
|
||||
('canadacentral', _('Canada Central')),
|
||||
('brazilsouth', _('Brazil South')),
|
||||
('northeurope', _('Europe North')),
|
||||
('westeurope', _('Europe West')),
|
||||
('ukwest', _('UK West')),
|
||||
('uksouth', _('UK South')),
|
||||
('eastasia', _('Asia East')),
|
||||
('southestasia', _('Asia Southeast')),
|
||||
('australiaeast', _('Australia East')),
|
||||
('australiasoutheast', _('Australia Southeast')),
|
||||
('westindia', _('India West')),
|
||||
('southindia', _('India South')),
|
||||
('japaneast', _('Japan East')),
|
||||
('japanwest', _('Japan West')),
|
||||
('koreacentral', _('Korea Central')),
|
||||
('koreasouth', _('Korea South')),
|
||||
]
|
||||
AZURE_RM_GROUP_FILTER = r'^.+$'
|
||||
AZURE_RM_HOST_FILTER = r'^.+$'
|
||||
AZURE_RM_ENABLED_VAR = 'powerstate'
|
||||
AZURE_RM_ENABLED_VALUE = 'running'
|
||||
AZURE_RM_INSTANCE_ID_VAR = 'id'
|
||||
@ -820,8 +705,6 @@ AZURE_RM_EXCLUDE_EMPTY_GROUPS = True
|
||||
# ---------------------
|
||||
OPENSTACK_ENABLED_VAR = 'status'
|
||||
OPENSTACK_ENABLED_VALUE = 'ACTIVE'
|
||||
OPENSTACK_GROUP_FILTER = r'^.+$'
|
||||
OPENSTACK_HOST_FILTER = r'^.+$'
|
||||
OPENSTACK_EXCLUDE_EMPTY_GROUPS = True
|
||||
OPENSTACK_INSTANCE_ID_VAR = 'openstack.id'
|
||||
|
||||
@ -830,8 +713,6 @@ OPENSTACK_INSTANCE_ID_VAR = 'openstack.id'
|
||||
# ---------------------
|
||||
RHV_ENABLED_VAR = 'status'
|
||||
RHV_ENABLED_VALUE = 'up'
|
||||
RHV_GROUP_FILTER = r'^.+$'
|
||||
RHV_HOST_FILTER = r'^.+$'
|
||||
RHV_EXCLUDE_EMPTY_GROUPS = True
|
||||
RHV_INSTANCE_ID_VAR = 'id'
|
||||
|
||||
@ -840,8 +721,6 @@ RHV_INSTANCE_ID_VAR = 'id'
|
||||
# ---------------------
|
||||
TOWER_ENABLED_VAR = 'remote_tower_enabled'
|
||||
TOWER_ENABLED_VALUE = 'true'
|
||||
TOWER_GROUP_FILTER = r'^.+$'
|
||||
TOWER_HOST_FILTER = r'^.+$'
|
||||
TOWER_EXCLUDE_EMPTY_GROUPS = True
|
||||
TOWER_INSTANCE_ID_VAR = 'remote_tower_id'
|
||||
|
||||
@ -850,8 +729,6 @@ TOWER_INSTANCE_ID_VAR = 'remote_tower_id'
|
||||
# ---------------------
|
||||
SATELLITE6_ENABLED_VAR = 'foreman.enabled'
|
||||
SATELLITE6_ENABLED_VALUE = 'True'
|
||||
SATELLITE6_GROUP_FILTER = r'^.+$'
|
||||
SATELLITE6_HOST_FILTER = r'^.+$'
|
||||
SATELLITE6_EXCLUDE_EMPTY_GROUPS = True
|
||||
SATELLITE6_INSTANCE_ID_VAR = 'foreman.id'
|
||||
# SATELLITE6_GROUP_PREFIX and SATELLITE6_GROUP_PATTERNS defined in source vars
|
||||
@ -861,8 +738,6 @@ SATELLITE6_INSTANCE_ID_VAR = 'foreman.id'
|
||||
# ---------------------
|
||||
#CUSTOM_ENABLED_VAR =
|
||||
#CUSTOM_ENABLED_VALUE =
|
||||
CUSTOM_GROUP_FILTER = r'^.+$'
|
||||
CUSTOM_HOST_FILTER = r'^.+$'
|
||||
CUSTOM_EXCLUDE_EMPTY_GROUPS = False
|
||||
#CUSTOM_INSTANCE_ID_VAR =
|
||||
|
||||
@ -871,8 +746,6 @@ CUSTOM_EXCLUDE_EMPTY_GROUPS = False
|
||||
# ---------------------
|
||||
#SCM_ENABLED_VAR =
|
||||
#SCM_ENABLED_VALUE =
|
||||
SCM_GROUP_FILTER = r'^.+$'
|
||||
SCM_HOST_FILTER = r'^.+$'
|
||||
SCM_EXCLUDE_EMPTY_GROUPS = False
|
||||
#SCM_INSTANCE_ID_VAR =
|
||||
|
||||
@ -916,7 +789,7 @@ ASGI_APPLICATION = "awx.main.routing.application"
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"BACKEND": "awx.main.consumers.ExpiringRedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [BROKER_URL],
|
||||
"capacity": 10000,
|
||||
|
||||
@ -24,49 +24,6 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
|
||||
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
||||
$scope.custom_virtualenvs_options = virtualEnvs;
|
||||
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'rax_regions',
|
||||
choice_name: 'rax_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'ec2_regions',
|
||||
choice_name: 'ec2_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'gce_regions',
|
||||
choice_name: 'gce_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'azure_regions',
|
||||
choice_name: 'azure_rm_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
|
||||
// Load options for group_by
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'group_by',
|
||||
variable: 'ec2_group_by',
|
||||
choice_name: 'ec2_group_by_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
|
||||
initRegionSelect();
|
||||
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'verbosity',
|
||||
@ -205,20 +162,11 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
|
||||
$scope.projectBasePath = GetBasePath('projects') + '?not__status=never updated';
|
||||
}
|
||||
|
||||
// reset fields
|
||||
$scope.group_by_choices = source === 'ec2' ? $scope.ec2_group_by : null;
|
||||
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
|
||||
$scope.source_region_choices = source === 'azure_rm' ? $scope.azure_regions : $scope[source + '_regions'];
|
||||
$scope.cloudCredentialRequired = source !== '' && source !== 'scm' && source !== 'custom' && source !== 'ec2' ? true : false;
|
||||
$scope.source_regions = null;
|
||||
$scope.credential = null;
|
||||
$scope.credential_name = null;
|
||||
$scope.group_by = null;
|
||||
$scope.group_by_choices = [];
|
||||
$scope.overwrite_vars = false;
|
||||
initRegionSelect();
|
||||
};
|
||||
// region / source options callback
|
||||
|
||||
$scope.$on('sourceTypeOptionsReady', function() {
|
||||
CreateSelect2({
|
||||
@ -227,57 +175,6 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
|
||||
});
|
||||
});
|
||||
|
||||
function initRegionSelect(){
|
||||
CreateSelect2({
|
||||
element: '#inventory_source_source_regions',
|
||||
multiple: true
|
||||
});
|
||||
|
||||
let add_new = false;
|
||||
if( _.get($scope, 'source') === 'ec2' || _.get($scope.source, 'value') === 'ec2') {
|
||||
$scope.group_by_choices = $scope.ec2_group_by;
|
||||
$scope.groupByPopOver = "<p>" + i18n._("Select which groups to create automatically. ") +
|
||||
$rootScope.BRAND_NAME + i18n._(" will create group names similar to the following examples based on the options selected:") + "</p><ul>" +
|
||||
"<li>" + i18n._("Availability Zone:") + "<strong>zones » us-east-1b</strong></li>" +
|
||||
"<li>" + i18n._("Image ID:") + "<strong>images » ami-b007ab1e</strong></li>" +
|
||||
"<li>" + i18n._("Instance ID:") + "<strong>instances » i-ca11ab1e</strong></li>" +
|
||||
"<li>" + i18n._("Instance Type:") + "<strong>types » type_m1_medium</strong></li>" +
|
||||
"<li>" + i18n._("Key Name:") + "<strong>keys » key_testing</strong></li>" +
|
||||
"<li>" + i18n._("Region:") + "<strong>regions » us-east-1</strong></li>" +
|
||||
"<li>" + i18n._("Security Group:") + "<strong>security_groups » security_group_default</strong></li>" +
|
||||
"<li>" + i18n._("Tags:") + "<strong>tags » tag_Name » tag_Name_host1</strong></li>" +
|
||||
"<li>" + i18n._("VPC ID:") + "<strong>vpcs » vpc-5ca1ab1e</strong></li>" +
|
||||
"<li>" + i18n._("Tag None:") + "<strong>tags » tag_none</strong></li>" +
|
||||
"</ul><p>" + i18n._("If blank, all groups above are created except") + "<em>" + i18n._("Instance ID") + "</em>.</p>";
|
||||
|
||||
$scope.instanceFilterPopOver = "<p>" + i18n._("Provide a comma-separated list of filter expressions. ") +
|
||||
i18n._("Hosts are imported to ") + $rootScope.BRAND_NAME + i18n._(" when ") + "<em>" + i18n._("ANY") + "</em>" + i18n._(" of the filters match.") + "</p>" +
|
||||
i18n._("Limit to hosts having a tag:") + "<br />\n" +
|
||||
"<blockquote>tag-key=TowerManaged</blockquote>\n" +
|
||||
i18n._("Limit to hosts using either key pair:") + "<br />\n" +
|
||||
"<blockquote>key-name=staging, key-name=production</blockquote>\n" +
|
||||
i18n._("Limit to hosts where the Name tag begins with ") + "<em>" + i18n._("test") + "</em>:<br />\n" +
|
||||
"<blockquote>tag:Name=test*</blockquote>\n" +
|
||||
"<p>" + i18n._("View the ") + "<a href=\"http://docs.aws.amazon.com/AWSEC2/latest/APIReference/ApiReference-query-DescribeInstances.html\" target=\"_blank\">" + i18n._("Describe Instances documentation") + "</a> " +
|
||||
i18n._("for a complete list of supported filters.") + "</p>";
|
||||
}
|
||||
if( _.get($scope, 'source') === 'vmware' || _.get($scope.source, 'value') === 'vmware') {
|
||||
add_new = true;
|
||||
$scope.group_by_choices = [];
|
||||
$scope.group_by = $scope.group_by_choices;
|
||||
$scope.groupByPopOver = i18n._("Specify which groups to create automatically. Group names will be created similar to the options selected. If blank, all groups above are created. Refer to Ansible Tower documentation for more detail.");
|
||||
$scope.instanceFilterPopOver = i18n._("Provide a comma-separated list of filter expressions. Hosts are imported when all of the filters match. Refer to Ansible Tower documentation for more detail.");
|
||||
}
|
||||
if( _.get($scope, 'source') === 'tower' || _.get($scope.source, 'value') === 'tower') {
|
||||
$scope.instanceFilterPopOver = i18n._("Provide the named URL encoded name or id of the remote Tower inventory to be imported.");
|
||||
}
|
||||
CreateSelect2({
|
||||
element: '#inventory_source_group_by',
|
||||
multiple: true,
|
||||
addNew: add_new
|
||||
});
|
||||
}
|
||||
|
||||
$scope.formCancel = function() {
|
||||
$state.go('^');
|
||||
};
|
||||
@ -289,7 +186,6 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
|
||||
name: $scope.name,
|
||||
description: $scope.description,
|
||||
inventory: inventoryData.id,
|
||||
instance_filters: $scope.instance_filters,
|
||||
source_script: $scope.inventory_script,
|
||||
credential: $scope.credential,
|
||||
overwrite: $scope.overwrite,
|
||||
@ -298,9 +194,9 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
|
||||
verbosity: $scope.verbosity.value,
|
||||
update_cache_timeout: $scope.update_cache_timeout || 0,
|
||||
custom_virtualenv: $scope.custom_virtualenv || null,
|
||||
// comma-delimited strings
|
||||
group_by: SourcesService.encodeGroupBy($scope.source, $scope.group_by),
|
||||
source_regions: _.map($scope.source_regions, 'value').join(','),
|
||||
enabled_var: $scope.enabled_var,
|
||||
enabled_value: $scope.enabled_value,
|
||||
host_filter: $scope.host_filter
|
||||
};
|
||||
|
||||
if ($scope.source) {
|
||||
|
||||
@ -34,7 +34,6 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
|
||||
{overwrite_vars: inventorySourceData.overwrite_vars},
|
||||
{update_on_launch: inventorySourceData.update_on_launch},
|
||||
{update_cache_timeout: inventorySourceData.update_cache_timeout},
|
||||
{instance_filters: inventorySourceData.instance_filters},
|
||||
{inventory_script: inventorySourceData.source_script},
|
||||
{verbosity: inventorySourceData.verbosity});
|
||||
|
||||
@ -100,56 +99,6 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
|
||||
scope: $scope,
|
||||
variable: 'source_type_options'
|
||||
});
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'rax_regions',
|
||||
choice_name: 'rax_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'ec2_regions',
|
||||
choice_name: 'ec2_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'gce_regions',
|
||||
choice_name: 'gce_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'source_regions',
|
||||
variable: 'azure_regions',
|
||||
choice_name: 'azure_rm_region_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
field: 'group_by',
|
||||
variable: 'ec2_group_by',
|
||||
choice_name: 'ec2_group_by_choices',
|
||||
options: inventorySourcesOptions
|
||||
});
|
||||
|
||||
var source = $scope.source === 'azure_rm' ? 'azure' : $scope.source;
|
||||
var regions = inventorySourceData.source_regions.split(',');
|
||||
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
|
||||
$scope.source_region_choices = $scope[source + '_regions'];
|
||||
|
||||
// the API stores azure regions as all-lowercase strings - but the azure regions received from OPTIONS are Snake_Cased
|
||||
if (source === 'azure') {
|
||||
$scope.source_regions = _.map(regions, (region) => _.find($scope[source + '_regions'], (o) => o.value.toLowerCase() === region));
|
||||
}
|
||||
// all other regions are 1-1
|
||||
else {
|
||||
$scope.source_regions = _.map(regions, (region) => _.find($scope[source + '_regions'], (o) => o.value === region));
|
||||
}
|
||||
initRegionSelect();
|
||||
|
||||
GetChoices({
|
||||
scope: $scope,
|
||||
@ -236,63 +185,6 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
|
||||
}
|
||||
}
|
||||
|
||||
function initRegionSelect() {
|
||||
CreateSelect2({
|
||||
element: '#inventory_source_source_regions',
|
||||
multiple: true
|
||||
});
|
||||
|
||||
let add_new = false;
|
||||
if( _.get($scope, 'source') === 'ec2' || _.get($scope.source, 'value') === 'ec2') {
|
||||
$scope.group_by_choices = $scope.ec2_group_by;
|
||||
let group_by = inventorySourceData.group_by.split(',');
|
||||
$scope.group_by = _.map(group_by, (item) => _.find($scope.ec2_group_by, { value: item }));
|
||||
|
||||
$scope.groupByPopOver = "<p>" + i18n._("Select which groups to create automatically. ") +
|
||||
$rootScope.BRAND_NAME + i18n._(" will create group names similar to the following examples based on the options selected:") + "</p><ul>" +
|
||||
"<li>" + i18n._("Availability Zone:") + "<strong>zones » us-east-1b</strong></li>" +
|
||||
"<li>" + i18n._("Image ID:") + "<strong>images » ami-b007ab1e</strong></li>" +
|
||||
"<li>" + i18n._("Instance ID:") + "<strong>instances » i-ca11ab1e</strong></li>" +
|
||||
"<li>" + i18n._("Instance Type:") + "<strong>types » type_m1_medium</strong></li>" +
|
||||
"<li>" + i18n._("Key Name:") + "<strong>keys » key_testing</strong></li>" +
|
||||
"<li>" + i18n._("Region:") + "<strong>regions » us-east-1</strong></li>" +
|
||||
"<li>" + i18n._("Security Group:") + "<strong>security_groups » security_group_default</strong></li>" +
|
||||
"<li>" + i18n._("Tags:") + "<strong>tags » tag_Name » tag_Name_host1</strong></li>" +
|
||||
"<li>" + i18n._("VPC ID:") + "<strong>vpcs » vpc-5ca1ab1e</strong></li>" +
|
||||
"<li>" + i18n._("Tag None:") + "<strong>tags » tag_none</strong></li>" +
|
||||
"</ul><p>" + i18n._("If blank, all groups above are created except") + "<em>" + i18n._("Instance ID") + "</em>.</p>";
|
||||
|
||||
|
||||
$scope.instanceFilterPopOver = "<p>" + i18n._("Provide a comma-separated list of filter expressions. ") +
|
||||
i18n._("Hosts are imported to ") + $rootScope.BRAND_NAME + i18n._(" when ") + "<em>" + i18n._("ANY") + "</em>" + i18n._(" of the filters match.") + "</p>" +
|
||||
i18n._("Limit to hosts having a tag:") + "<br />\n" +
|
||||
"<blockquote>tag-key=TowerManaged</blockquote>\n" +
|
||||
i18n._("Limit to hosts using either key pair:") + "<br />\n" +
|
||||
"<blockquote>key-name=staging, key-name=production</blockquote>\n" +
|
||||
i18n._("Limit to hosts where the Name tag begins with ") + "<em>" + i18n._("test") + "</em>:<br />\n" +
|
||||
"<blockquote>tag:Name=test*</blockquote>\n" +
|
||||
"<p>" + i18n._("View the ") + "<a href=\"http://docs.aws.amazon.com/AWSEC2/latest/APIReference/ApiReference-query-DescribeInstances.html\" target=\"_blank\">" + i18n._("Describe Instances documentation") + "</a> " +
|
||||
i18n._("for a complete list of supported filters.") + "</p>";
|
||||
|
||||
}
|
||||
if( _.get($scope, 'source') === 'vmware' || _.get($scope.source, 'value') === 'vmware') {
|
||||
add_new = true;
|
||||
$scope.group_by_choices = (inventorySourceData.group_by) ? inventorySourceData.group_by.split(',')
|
||||
.map((i) => ({name: i, label: i, value: i})) : [];
|
||||
$scope.group_by = $scope.group_by_choices;
|
||||
$scope.groupByPopOver = i18n._(`Specify which groups to create automatically. Group names will be created similar to the options selected. If blank, all groups above are created. Refer to Ansible Tower documentation for more detail.`);
|
||||
$scope.instanceFilterPopOver = i18n._(`Provide a comma-separated list of filter expressions. Hosts are imported when all of the filters match. Refer to Ansible Tower documentation for more detail.`);
|
||||
}
|
||||
if( _.get($scope, 'source') === 'tower' || _.get($scope.source, 'value') === 'tower') {
|
||||
$scope.instanceFilterPopOver = i18n._(`Provide the named URL encoded name or id of the remote Tower inventory to be imported.`);
|
||||
}
|
||||
CreateSelect2({
|
||||
element: '#inventory_source_group_by',
|
||||
multiple: true,
|
||||
addNew: add_new
|
||||
});
|
||||
}
|
||||
|
||||
$scope.lookupProject = function(){
|
||||
$state.go('.project', {
|
||||
project_search: {
|
||||
@ -341,12 +233,13 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
|
||||
$scope.formSave = function() {
|
||||
var params;
|
||||
|
||||
console.log($scope);
|
||||
|
||||
params = {
|
||||
id: inventorySourceData.id,
|
||||
name: $scope.name,
|
||||
description: $scope.description,
|
||||
inventory: inventoryData.id,
|
||||
instance_filters: $scope.instance_filters,
|
||||
source_script: $scope.inventory_script,
|
||||
credential: $scope.credential,
|
||||
overwrite: $scope.overwrite,
|
||||
@ -355,9 +248,9 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
|
||||
update_cache_timeout: $scope.update_cache_timeout || 0,
|
||||
verbosity: $scope.verbosity.value,
|
||||
custom_virtualenv: $scope.custom_virtualenv || null,
|
||||
// comma-delimited strings
|
||||
group_by: SourcesService.encodeGroupBy($scope.source, $scope.group_by),
|
||||
source_regions: _.map($scope.source_regions, 'value').join(',')
|
||||
enabled_var: $scope.enabled_var,
|
||||
enabled_value: $scope.enabled_value,
|
||||
host_filter: $scope.host_filter
|
||||
};
|
||||
|
||||
if ($scope.source) {
|
||||
@ -417,20 +310,10 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
|
||||
});
|
||||
}
|
||||
|
||||
// reset fields
|
||||
$scope.group_by_choices = source === 'ec2' ? $scope.ec2_group_by : null;
|
||||
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
|
||||
$scope.source_region_choices = source === 'azure_rm' ? $scope.azure_regions : $scope[source + '_regions'];
|
||||
$scope.cloudCredentialRequired = source !== '' && source !== 'scm' && source !== 'custom' && source !== 'ec2' ? true : false;
|
||||
$scope.source_regions = null;
|
||||
$scope.credential = null;
|
||||
$scope.credential_name = null;
|
||||
$scope.group_by = null;
|
||||
$scope.group_by_choices = [];
|
||||
$scope.overwrite_vars = false;
|
||||
|
||||
initRegionSelect();
|
||||
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
@ -126,46 +126,6 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
|
||||
includeInventoryFileNotFoundError: true,
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
source_regions: {
|
||||
label: i18n._('Regions'),
|
||||
type: 'select',
|
||||
ngOptions: 'source.label for source in source_region_choices track by source.value',
|
||||
multiSelect: true,
|
||||
ngShow: "source && (source.value == 'rax' || source.value == 'ec2' || source.value == 'gce' || source.value == 'azure_rm')",
|
||||
dataTitle: i18n._('Source Regions'),
|
||||
dataPlacement: 'right',
|
||||
awPopOver: "<p>" + i18n._("Click on the regions field to see a list of regions for your cloud provider. You can select multiple regions, or choose") +
|
||||
"<em>" + i18n._("All") + "</em> " + i18n._("to include all regions. Only Hosts associated with the selected regions will be updated.") + "</p>",
|
||||
dataContainer: 'body',
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
instance_filters: {
|
||||
label: i18n._("Instance Filters"),
|
||||
type: 'text',
|
||||
ngShow: "source && (source.value == 'ec2' || source.value == 'vmware' || source.value == 'tower')",
|
||||
dataTitle: i18n._('Instance Filters'),
|
||||
dataPlacement: 'right',
|
||||
awPopOverWatch: 'instanceFilterPopOver',
|
||||
awPopOver: '{{ instanceFilterPopOver }}',
|
||||
dataContainer: 'body',
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
group_by: {
|
||||
label: i18n._('Only Group By'),
|
||||
type: 'select',
|
||||
ngShow: "source && (source.value == 'ec2' || source.value == 'vmware')",
|
||||
ngOptions: 'source.label for source in group_by_choices track by source.value',
|
||||
multiSelect: true,
|
||||
dataTitle: i18n._("Only Group By"),
|
||||
dataPlacement: 'right',
|
||||
awPopOverWatch: 'groupByPopOver',
|
||||
awPopOver: '{{ groupByPopOver }}',
|
||||
dataContainer: 'body',
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
inventory_script: {
|
||||
label : i18n._("Custom Inventory Script"),
|
||||
type: 'lookup',
|
||||
@ -340,6 +300,36 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
host_filter: {
|
||||
label: i18n._("Host Filter"),
|
||||
type: 'text',
|
||||
dataTitle: i18n._('Host Filter'),
|
||||
dataPlacement: 'right',
|
||||
awPopOver: "<p>" + i18n._("Regular expression where only matching host names will be imported. The filter is applied as a post-processing step after any inventory plugin filters are applied.") + "</p>",
|
||||
dataContainer: 'body',
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
enabled_var: {
|
||||
label: i18n._("Enabled Variable"),
|
||||
type: 'text',
|
||||
dataTitle: i18n._('Enabled Variable'),
|
||||
dataPlacement: 'right',
|
||||
awPopOver: "<p>" + i18n._("Retrieve the enabled state from the given dict of host variables. The enabled variable may be specified using dot notation, e.g: 'foo.bar'") + "</p>",
|
||||
dataContainer: 'body',
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
enabled_value: {
|
||||
label: i18n._("Enabled Value"),
|
||||
type: 'text',
|
||||
dataTitle: i18n._('Enabled Value'),
|
||||
dataPlacement: 'right',
|
||||
awPopOver: "<p>" + i18n._("This field is ignored unless an Enabled Variable is set. If the enabled variable matches this value, the host will be enabled on import.") + "</p>",
|
||||
dataContainer: 'body',
|
||||
ngDisabled: '!(inventory_source_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
subForm: 'sourceSubForm'
|
||||
},
|
||||
checkbox_group: {
|
||||
label: i18n._('Update Options'),
|
||||
type: 'checkbox_group',
|
||||
|
||||
@ -116,24 +116,6 @@ export default
|
||||
.catch(this.error.bind(this))
|
||||
.finally(Wait('stop'));
|
||||
},
|
||||
encodeGroupBy(source, group_by){
|
||||
source = source && source.value ? source.value : '';
|
||||
if(source === 'ec2'){
|
||||
return _.map(group_by, 'value').join(',');
|
||||
}
|
||||
|
||||
if(source === 'vmware'){
|
||||
group_by = _.map(group_by, (i) => {return i.value;});
|
||||
$("#inventory_source_group_by").siblings(".select2").first().find(".select2-selection__choice").each(function(optionIndex, option){
|
||||
group_by.push(option.title);
|
||||
});
|
||||
group_by = (Array.isArray(group_by)) ? _.uniq(group_by).join() : "";
|
||||
return group_by;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
deleteHosts(id) {
|
||||
this.url = GetBasePath('inventory_sources') + id + '/hosts/';
|
||||
Rest.setUrl(this.url);
|
||||
|
||||
@ -23,7 +23,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
|
||||
$scope.canEditOrg = true;
|
||||
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
||||
$scope.custom_virtualenvs_options = virtualEnvs;
|
||||
|
||||
|
||||
const [ProjectModel] = resolvedModels;
|
||||
$scope.canAdd = ProjectModel.options('actions.POST');
|
||||
|
||||
@ -170,6 +170,14 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
|
||||
$scope.lookupType = 'scm_credential';
|
||||
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Revision');
|
||||
break;
|
||||
case 'archive':
|
||||
$scope.credentialLabel = "SCM " + i18n._("Credential");
|
||||
$scope.urlPopover = '<p>' + i18n._('Example URLs for Remote Archive SCM include:') + '</p>' +
|
||||
'<ul class=\"no-bullets\"><li>https://github.com/username/project/archive/v0.0.1.tar.gz</li>' +
|
||||
'<li>http://github.com/username/project/archive/v0.0.2.zip</li></ul>';
|
||||
$scope.credRequired = false;
|
||||
$scope.lookupType = 'scm_credential';
|
||||
break;
|
||||
case 'insights':
|
||||
$scope.pathRequired = false;
|
||||
$scope.scmRequired = false;
|
||||
|
||||
@ -291,6 +291,14 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
|
||||
$scope.lookupType = 'scm_credential';
|
||||
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Revision');
|
||||
break;
|
||||
case 'archive':
|
||||
$scope.credentialLabel = "SCM " + i18n._("Credential");
|
||||
$scope.urlPopover = '<p>' + i18n._('Example URLs for Remote Archive SCM include:') + '</p>' +
|
||||
'<ul class=\"no-bullets\"><li>https://github.com/username/project/archive/v0.0.1.tar.gz</li>' +
|
||||
'<li>http://github.com/username/project/archive/v0.0.2.zip</li></ul>';
|
||||
$scope.credRequired = false;
|
||||
$scope.lookupType = 'scm_credential';
|
||||
break;
|
||||
case 'insights':
|
||||
$scope.pathRequired = false;
|
||||
$scope.scmRequired = false;
|
||||
|
||||
@ -124,7 +124,7 @@ export default ['i18n', 'NotificationsList', 'TemplateList',
|
||||
scm_branch: {
|
||||
labelBind: "scmBranchLabel",
|
||||
type: 'text',
|
||||
ngShow: "scm_type && scm_type.value !== 'manual' && scm_type.value !== 'insights'",
|
||||
ngShow: "scm_type && scm_type.value !== 'manual' && scm_type.value !== 'insights' && scm_type.value !== 'archive'",
|
||||
ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
awPopOver: '<p>' + i18n._("Branch to checkout. In addition to branches, you can input tags, commit hashes, and arbitrary refs. Some commit hashes and refs may not be availble unless you also provide a custom refspec.") + '</p>',
|
||||
dataTitle: i18n._('SCM Branch'),
|
||||
|
||||
340
awx/ui/package-lock.json
generated
340
awx/ui/package-lock.json
generated
@ -4,6 +4,28 @@
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@types/glob": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
|
||||
"integrity": "sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/minimatch": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "14.0.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz",
|
||||
"integrity": "sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA==",
|
||||
"dev": true
|
||||
},
|
||||
"@uirouter/angularjs": {
|
||||
"version": "1.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@uirouter/angularjs/-/angularjs-1.0.18.tgz",
|
||||
@ -740,7 +762,8 @@
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz",
|
||||
"integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.16.2",
|
||||
@ -2299,16 +2322,215 @@
|
||||
"dev": true
|
||||
},
|
||||
"chromedriver": {
|
||||
"version": "2.40.0",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-2.40.0.tgz",
|
||||
"integrity": "sha512-ewvRQ1HMk0vpFSWYCk5hKDoEz5QMPplx5w3C6/Me+03y1imr67l3Hxl9U0jn3mu2N7+c7BoC7JtNW6HzbRAwDQ==",
|
||||
"version": "77.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-77.0.0.tgz",
|
||||
"integrity": "sha512-mZa1IVx4HD8rDaItWbnS470mmypgiWsDiu98r0NkiT4uLm3qrANl4vOU6no6vtWtLQiW5kt1POcIbjeNpsLbXA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"del": "^3.0.0",
|
||||
"del": "^4.1.1",
|
||||
"extract-zip": "^1.6.7",
|
||||
"kew": "^0.7.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"request": "^2.87.0"
|
||||
"request": "^2.88.0",
|
||||
"tcp-port-used": "^1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz",
|
||||
"integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"aws4": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz",
|
||||
"integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==",
|
||||
"dev": true
|
||||
},
|
||||
"del": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz",
|
||||
"integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/glob": "^7.1.1",
|
||||
"globby": "^6.1.0",
|
||||
"is-path-cwd": "^2.0.0",
|
||||
"is-path-in-cwd": "^2.0.0",
|
||||
"p-map": "^2.0.0",
|
||||
"pify": "^4.0.1",
|
||||
"rimraf": "^2.6.3"
|
||||
}
|
||||
},
|
||||
"extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"har-validator": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
|
||||
"integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "^6.5.5",
|
||||
"har-schema": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-path-cwd": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
|
||||
"integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-path-in-cwd": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz",
|
||||
"integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-path-inside": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"is-path-inside": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz",
|
||||
"integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"path-is-inside": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
|
||||
"integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==",
|
||||
"dev": true
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.27",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
|
||||
"integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mime-db": "1.44.0"
|
||||
}
|
||||
},
|
||||
"oauth-sign": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
|
||||
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
|
||||
"dev": true
|
||||
},
|
||||
"p-map": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
|
||||
"integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
|
||||
"dev": true
|
||||
},
|
||||
"pify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
|
||||
"dev": true
|
||||
},
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true
|
||||
},
|
||||
"request": {
|
||||
"version": "2.88.2",
|
||||
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
|
||||
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.8.0",
|
||||
"caseless": "~0.12.0",
|
||||
"combined-stream": "~1.0.6",
|
||||
"extend": "~3.0.2",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "~2.3.2",
|
||||
"har-validator": "~5.1.3",
|
||||
"http-signature": "~1.2.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"oauth-sign": "~0.9.0",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.2",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "~2.5.0",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"cipher-base": {
|
||||
@ -6458,16 +6680,6 @@
|
||||
"integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
|
||||
"dev": true
|
||||
},
|
||||
"har-validator": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz",
|
||||
"integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "^5.1.0",
|
||||
"har-schema": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"hard-source-webpack-plugin": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/hard-source-webpack-plugin/-/hard-source-webpack-plugin-0.5.18.tgz",
|
||||
@ -7349,6 +7561,12 @@
|
||||
"integrity": "sha1-x+NWzeoiWucbNtcPLnGpK6TkJZA=",
|
||||
"dev": true
|
||||
},
|
||||
"ip-regex": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz",
|
||||
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=",
|
||||
"dev": true
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz",
|
||||
@ -7658,6 +7876,12 @@
|
||||
"unc-path-regex": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"dev": true
|
||||
},
|
||||
"is-utf8": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
|
||||
@ -7676,6 +7900,17 @@
|
||||
"integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=",
|
||||
"dev": true
|
||||
},
|
||||
"is2": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is2/-/is2-2.0.1.tgz",
|
||||
"integrity": "sha512-+WaJvnaA7aJySz2q/8sLjMb2Mw14KTplHmSwcSpZ/fWJPkUmqw3YTzSWbPJ7OAwRvdYTWF2Wg+yYJ1AdP5Z8CA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"deep-is": "^0.1.3",
|
||||
"ip-regex": "^2.1.0",
|
||||
"is-url": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@ -8173,12 +8408,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"kew": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz",
|
||||
"integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=",
|
||||
"dev": true
|
||||
},
|
||||
"killable": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.0.tgz",
|
||||
@ -9826,7 +10055,8 @@
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
|
||||
"integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@ -11196,6 +11426,12 @@
|
||||
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
|
||||
"dev": true
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"public-encrypt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz",
|
||||
@ -11816,34 +12052,6 @@
|
||||
"is-finite": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"version": "2.87.0",
|
||||
"resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz",
|
||||
"integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.6.0",
|
||||
"caseless": "~0.12.0",
|
||||
"combined-stream": "~1.0.5",
|
||||
"extend": "~3.0.1",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "~2.3.1",
|
||||
"har-validator": "~5.0.3",
|
||||
"http-signature": "~1.2.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.17",
|
||||
"oauth-sign": "~0.8.2",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.1",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"tough-cookie": "~2.3.3",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@ -13171,6 +13379,33 @@
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"tcp-port-used": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.1.tgz",
|
||||
"integrity": "sha512-rwi5xJeU6utXoEIiMvVBMc9eJ2/ofzB+7nLOdnZuFTmNCLqRiQh2sMG9MqCxHU/69VC/Fwp5dV9306Qd54ll1Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "4.1.0",
|
||||
"is2": "2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.0.tgz",
|
||||
"integrity": "sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"test-exclude": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-4.2.1.tgz",
|
||||
@ -13355,6 +13590,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz",
|
||||
"integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"punycode": "^1.4.1"
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-plugin-istanbul": "^4.1.5",
|
||||
"babel-preset-env": "^1.6.0",
|
||||
"chromedriver": "^2.35.0",
|
||||
"chromedriver": "^77.0.0",
|
||||
"clean-webpack-plugin": "^0.1.16",
|
||||
"copy-webpack-plugin": "^4.0.1",
|
||||
"css-loader": "^0.28.5",
|
||||
|
||||
2875
awx/ui_next/package-lock.json
generated
2875
awx/ui_next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -49,7 +49,7 @@
|
||||
"jest-websocket-mock": "^2.0.2",
|
||||
"mock-socket": "^9.0.3",
|
||||
"prettier": "^1.18.2",
|
||||
"react-scripts": "^3.4.1"
|
||||
"react-scripts": "^3.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "PORT=3001 HTTPS=true DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start",
|
||||
|
||||
@ -7,6 +7,7 @@ import Credentials from './models/Credentials';
|
||||
import Groups from './models/Groups';
|
||||
import Hosts from './models/Hosts';
|
||||
import InstanceGroups from './models/InstanceGroups';
|
||||
import Instances from './models/Instances';
|
||||
import Inventories from './models/Inventories';
|
||||
import InventoryScripts from './models/InventoryScripts';
|
||||
import InventorySources from './models/InventorySources';
|
||||
@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates';
|
||||
import Organizations from './models/Organizations';
|
||||
import ProjectUpdates from './models/ProjectUpdates';
|
||||
import Projects from './models/Projects';
|
||||
import Root from './models/Root';
|
||||
import Roles from './models/Roles';
|
||||
import Root from './models/Root';
|
||||
import Schedules from './models/Schedules';
|
||||
import SystemJobs from './models/SystemJobs';
|
||||
import Teams from './models/Teams';
|
||||
@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials();
|
||||
const GroupsAPI = new Groups();
|
||||
const HostsAPI = new Hosts();
|
||||
const InstanceGroupsAPI = new InstanceGroups();
|
||||
const InstancesAPI = new Instances();
|
||||
const InventoriesAPI = new Inventories();
|
||||
const InventoryScriptsAPI = new InventoryScripts();
|
||||
const InventorySourcesAPI = new InventorySources();
|
||||
@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates();
|
||||
const OrganizationsAPI = new Organizations();
|
||||
const ProjectUpdatesAPI = new ProjectUpdates();
|
||||
const ProjectsAPI = new Projects();
|
||||
const RootAPI = new Root();
|
||||
const RolesAPI = new Roles();
|
||||
const RootAPI = new Root();
|
||||
const SchedulesAPI = new Schedules();
|
||||
const SystemJobsAPI = new SystemJobs();
|
||||
const TeamsAPI = new Teams();
|
||||
@ -78,6 +80,7 @@ export {
|
||||
GroupsAPI,
|
||||
HostsAPI,
|
||||
InstanceGroupsAPI,
|
||||
InstancesAPI,
|
||||
InventoriesAPI,
|
||||
InventoryScriptsAPI,
|
||||
InventorySourcesAPI,
|
||||
@ -90,8 +93,8 @@ export {
|
||||
OrganizationsAPI,
|
||||
ProjectUpdatesAPI,
|
||||
ProjectsAPI,
|
||||
RootAPI,
|
||||
RolesAPI,
|
||||
RootAPI,
|
||||
SchedulesAPI,
|
||||
SystemJobsAPI,
|
||||
TeamsAPI,
|
||||
|
||||
@ -87,6 +87,13 @@ const NotificationsMixin = parent =>
|
||||
notificationId,
|
||||
notificationType
|
||||
) {
|
||||
if (notificationType === 'approvals') {
|
||||
return this.associateNotificationTemplatesApprovals(
|
||||
resourceId,
|
||||
notificationId
|
||||
);
|
||||
}
|
||||
|
||||
if (notificationType === 'started') {
|
||||
return this.associateNotificationTemplatesStarted(
|
||||
resourceId,
|
||||
@ -126,6 +133,13 @@ const NotificationsMixin = parent =>
|
||||
notificationId,
|
||||
notificationType
|
||||
) {
|
||||
if (notificationType === 'approvals') {
|
||||
return this.disassociateNotificationTemplatesApprovals(
|
||||
resourceId,
|
||||
notificationId
|
||||
);
|
||||
}
|
||||
|
||||
if (notificationType === 'started') {
|
||||
return this.disassociateNotificationTemplatesStarted(
|
||||
resourceId,
|
||||
|
||||
@ -27,6 +27,10 @@ class CredentialTypes extends Base {
|
||||
.concat(nextResults)
|
||||
.filter(type => acceptableKinds.includes(type.kind));
|
||||
}
|
||||
|
||||
test(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/test/`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default CredentialTypes;
|
||||
|
||||
@ -6,6 +6,7 @@ class Credentials extends Base {
|
||||
this.baseUrl = '/api/v2/credentials/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readInputSources = this.readInputSources.bind(this);
|
||||
}
|
||||
|
||||
@ -15,11 +16,19 @@ class Credentials extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readInputSources(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
test(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/test/`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default Credentials;
|
||||
|
||||
@ -4,6 +4,37 @@ class InstanceGroups extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/instance_groups/';
|
||||
|
||||
this.associateInstance = this.associateInstance.bind(this);
|
||||
this.disassociateInstance = this.disassociateInstance.bind(this);
|
||||
this.readInstanceOptions = this.readInstanceOptions.bind(this);
|
||||
this.readInstances = this.readInstances.bind(this);
|
||||
this.readJobs = this.readJobs.bind(this);
|
||||
}
|
||||
|
||||
associateInstance(instanceGroupId, instanceId) {
|
||||
return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, {
|
||||
id: instanceId,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateInstance(instanceGroupId, instanceId) {
|
||||
return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, {
|
||||
id: instanceId,
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
readInstances(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/instances/`, { params });
|
||||
}
|
||||
|
||||
readInstanceOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/instances/`);
|
||||
}
|
||||
|
||||
readJobs(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/jobs/`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
awx/ui_next/src/api/models/Instances.js
Normal file
10
awx/ui_next/src/api/models/Instances.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class Instances extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/instances/';
|
||||
}
|
||||
}
|
||||
|
||||
export default Instances;
|
||||
@ -7,6 +7,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
this.baseUrl = '/api/v2/inventories/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readHosts = this.readHosts.bind(this);
|
||||
this.readHostDetail = this.readHostDetail.bind(this);
|
||||
this.readGroups = this.readGroups.bind(this);
|
||||
@ -20,6 +21,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
createHost(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ class JobTemplates extends SchedulesMixin(
|
||||
this.disassociateLabel = this.disassociateLabel.bind(this);
|
||||
this.readCredentials = this.readCredentials.bind(this);
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readWebhookKey = this.readWebhookKey.bind(this);
|
||||
}
|
||||
|
||||
@ -66,6 +67,10 @@ class JobTemplates extends SchedulesMixin(
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readScheduleList(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/schedules/`, {
|
||||
params,
|
||||
|
||||
@ -5,6 +5,10 @@ class NotificationTemplates extends Base {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/notification_templates/';
|
||||
}
|
||||
|
||||
test(id) {
|
||||
return this.http.post(`${this.baseUrl}${id}/test/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationTemplates;
|
||||
|
||||
@ -12,9 +12,42 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
|
||||
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readTeams(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/teams/`, { params });
|
||||
}
|
||||
|
||||
readTeamsOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/teams/`);
|
||||
}
|
||||
|
||||
createUser(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/users/`, data);
|
||||
}
|
||||
|
||||
readNotificationTemplatesApprovals(id, params) {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}${id}/notification_templates_approvals/`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
associateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId }
|
||||
);
|
||||
}
|
||||
|
||||
disassociateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId, disassociate: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Organizations;
|
||||
|
||||
@ -11,6 +11,7 @@ class Projects extends SchedulesMixin(
|
||||
this.baseUrl = '/api/v2/projects/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readInventories = this.readInventories.bind(this);
|
||||
this.readPlaybooks = this.readPlaybooks.bind(this);
|
||||
this.readSync = this.readSync.bind(this);
|
||||
@ -21,6 +22,10 @@ class Projects extends SchedulesMixin(
|
||||
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readInventories(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/inventories/`);
|
||||
}
|
||||
|
||||
@ -35,6 +35,10 @@ class Teams extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readUsersAccessOptions(teamId) {
|
||||
return this.http.options(`${this.baseUrl}${teamId}/users/`);
|
||||
}
|
||||
|
||||
@ -54,6 +54,10 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readSurvey(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/survey_spec/`);
|
||||
}
|
||||
@ -65,6 +69,27 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
|
||||
destroySurvey(id) {
|
||||
return this.http.delete(`${this.baseUrl}${id}/survey_spec/`);
|
||||
}
|
||||
|
||||
readNotificationTemplatesApprovals(id, params) {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}${id}/notification_templates_approvals/`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
associateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId }
|
||||
);
|
||||
}
|
||||
|
||||
disassociateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId, disassociate: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowJobTemplates;
|
||||
|
||||
@ -1,25 +1,46 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown, DropdownPosition } from '@patternfly/react-core';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownPosition,
|
||||
DropdownItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { ToolbarAddButton } from '../PaginatedDataList';
|
||||
import { toTitleCase } from '../../util/strings';
|
||||
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
||||
|
||||
function AddDropDownButton({ dropdownItems }) {
|
||||
function AddDropDownButton({ dropdownItems, i18n }) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const element = useRef(null);
|
||||
|
||||
const toggle = e => {
|
||||
if (!element || !element.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const toggle = e => {
|
||||
if (!isKebabified && (!element || !element.current.contains(e.target))) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', toggle, false);
|
||||
return () => {
|
||||
document.removeEventListener('click', toggle);
|
||||
};
|
||||
}, []);
|
||||
}, [isKebabified]);
|
||||
|
||||
if (isKebabified) {
|
||||
return (
|
||||
<Fragment>
|
||||
{dropdownItems.map(item => (
|
||||
<DropdownItem key={item.url} component={Link} to={item.url}>
|
||||
{toTitleCase(`${i18n._(t`Add`)} ${item.label}`)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={element} key="add">
|
||||
@ -52,4 +73,4 @@ AddDropDownButton.propTypes = {
|
||||
};
|
||||
|
||||
export { AddDropDownButton as _AddDropDownButton };
|
||||
export default AddDropDownButton;
|
||||
export default withI18n()(AddDropDownButton);
|
||||
|
||||
@ -11,8 +11,12 @@ import { TeamsAPI, UsersAPI } from '../../api';
|
||||
const readUsers = async queryParams =>
|
||||
UsersAPI.read(Object.assign(queryParams, { is_superuser: false }));
|
||||
|
||||
const readUsersOptions = async () => UsersAPI.readOptions();
|
||||
|
||||
const readTeams = async queryParams => TeamsAPI.read(queryParams);
|
||||
|
||||
const readTeamsOptions = async () => TeamsAPI.readOptions();
|
||||
|
||||
class AddResourceRole extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -259,6 +263,7 @@ class AddResourceRole extends React.Component {
|
||||
displayKey="username"
|
||||
onRowClick={this.handleResourceCheckboxClick}
|
||||
fetchItems={readUsers}
|
||||
fetchOptions={readUsersOptions}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
sortedColumnKey="username"
|
||||
@ -270,6 +275,7 @@ class AddResourceRole extends React.Component {
|
||||
sortColumns={teamSortColumns}
|
||||
onRowClick={this.handleResourceCheckboxClick}
|
||||
fetchItems={readTeams}
|
||||
fetchOptions={readTeamsOptions}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
/>
|
||||
|
||||
@ -29,6 +29,7 @@ function SelectResourceStep({
|
||||
selectedLabel,
|
||||
selectedResourceRows,
|
||||
fetchItems,
|
||||
fetchOptions,
|
||||
i18n,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
@ -37,7 +38,7 @@ function SelectResourceStep({
|
||||
isLoading,
|
||||
error,
|
||||
request: readResourceList,
|
||||
result: { resources, itemCount },
|
||||
result: { resources, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const queryParams = parseQueryString(
|
||||
@ -45,14 +46,28 @@ function SelectResourceStep({
|
||||
location.search
|
||||
);
|
||||
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchItems(queryParams);
|
||||
return { resources: results, itemCount: count };
|
||||
}, [location, fetchItems, sortColumns]),
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
actionsResponse,
|
||||
] = await Promise.all([fetchItems(queryParams), fetchOptions()]);
|
||||
return {
|
||||
resources: results,
|
||||
itemCount: count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location, fetchItems, fetchOptions, sortColumns]),
|
||||
{
|
||||
resources: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -84,6 +99,8 @@ function SelectResourceStep({
|
||||
onRowClick={onRowClick}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
toolbarSortColumns={sortColumns}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={selectedResourceRows.some(i => i.id === item.id)}
|
||||
|
||||
@ -35,6 +35,7 @@ describe('<SelectResourceStep />', () => {
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
fetchItems={() => {}}
|
||||
fetchOptions={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -49,6 +50,15 @@ describe('<SelectResourceStep />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
const options = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -58,6 +68,7 @@ describe('<SelectResourceStep />', () => {
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
fetchItems={handleSearch}
|
||||
fetchOptions={options}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -78,6 +89,15 @@ describe('<SelectResourceStep />', () => {
|
||||
{ id: 2, username: 'bar', url: 'item/2' },
|
||||
],
|
||||
};
|
||||
const options = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -87,6 +107,7 @@ describe('<SelectResourceStep />', () => {
|
||||
displayKey="username"
|
||||
onRowClick={handleRowClick}
|
||||
fetchItems={() => ({ data })}
|
||||
fetchOptions={options}
|
||||
selectedResourceRows={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import useSelected from '../../util/useSelected';
|
||||
|
||||
const QS_CONFIG = getQSConfig('associate', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
const QS_CONFIG = (order_by = 'name') => {
|
||||
return getQSConfig('associate', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by,
|
||||
});
|
||||
};
|
||||
|
||||
function AssociateModal({
|
||||
i18n,
|
||||
@ -21,31 +23,47 @@ function AssociateModal({
|
||||
onClose,
|
||||
onAssociate,
|
||||
fetchRequest,
|
||||
optionsRequest,
|
||||
isModalOpen = false,
|
||||
displayKey = 'name',
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const { selected, handleSelect } = useSelected([]);
|
||||
|
||||
const {
|
||||
request: fetchItems,
|
||||
result: { items, itemCount },
|
||||
result: { items, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchRequest(params);
|
||||
const params = parseQueryString(
|
||||
QS_CONFIG(displayKey),
|
||||
history.location.search
|
||||
);
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
actionsResponse,
|
||||
] = await Promise.all([fetchRequest(params), optionsRequest()]);
|
||||
|
||||
return {
|
||||
items: results,
|
||||
itemCount: count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [fetchRequest, history.location.search]),
|
||||
}, [fetchRequest, optionsRequest, history.location.search, displayKey]),
|
||||
{
|
||||
items: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -100,6 +118,7 @@ function AssociateModal({
|
||||
]}
|
||||
>
|
||||
<OptionsList
|
||||
displayKey={displayKey}
|
||||
contentError={contentError}
|
||||
deselectItem={handleSelect}
|
||||
header={header}
|
||||
@ -107,14 +126,14 @@ function AssociateModal({
|
||||
multiple
|
||||
optionCount={itemCount}
|
||||
options={items}
|
||||
qsConfig={QS_CONFIG}
|
||||
qsConfig={QS_CONFIG(displayKey)}
|
||||
readOnly={false}
|
||||
selectItem={handleSelect}
|
||||
value={selected}
|
||||
searchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name__icontains',
|
||||
key: `${displayKey}__icontains`,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
@ -129,9 +148,11 @@ function AssociateModal({
|
||||
sortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
key: `${displayKey}`,
|
||||
},
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
/>
|
||||
</Modal>
|
||||
</Fragment>
|
||||
|
||||
@ -15,6 +15,15 @@ describe('<AssociateModal />', () => {
|
||||
const onClose = jest.fn();
|
||||
const onAssociate = jest.fn().mockResolvedValue();
|
||||
const fetchRequest = jest.fn().mockReturnValue({ data: { ...mockHosts } });
|
||||
const optionsRequest = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
@ -23,6 +32,7 @@ describe('<AssociateModal />', () => {
|
||||
onClose={onClose}
|
||||
onAssociate={onAssociate}
|
||||
fetchRequest={fetchRequest}
|
||||
optionsRequest={optionsRequest}
|
||||
isModalOpen
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { string, node, number } from 'prop-types';
|
||||
import { node, number, oneOfType, shape, string } from 'prop-types';
|
||||
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from '../DetailList';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml';
|
||||
import {
|
||||
yamlToJson,
|
||||
jsonToYaml,
|
||||
isJsonObject,
|
||||
isJsonString,
|
||||
} from '../../util/yaml';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
|
||||
@ -15,7 +20,7 @@ function getValueAsMode(value, mode) {
|
||||
}
|
||||
return '---';
|
||||
}
|
||||
const modeMatches = isJson(value) === (mode === JSON_MODE);
|
||||
const modeMatches = isJsonString(value) === (mode === JSON_MODE);
|
||||
if (modeMatches) {
|
||||
return value;
|
||||
}
|
||||
@ -23,12 +28,21 @@ function getValueAsMode(value, mode) {
|
||||
}
|
||||
|
||||
function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
||||
const [currentValue, setCurrentValue] = useState(value || '---');
|
||||
const [mode, setMode] = useState(
|
||||
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
|
||||
);
|
||||
const [currentValue, setCurrentValue] = useState(
|
||||
isJsonObject(value) ? JSON.stringify(value, null, 2) : value || '---'
|
||||
);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(getValueAsMode(value, mode));
|
||||
setCurrentValue(
|
||||
getValueAsMode(
|
||||
isJsonObject(value) ? JSON.stringify(value, null, 2) : value,
|
||||
mode
|
||||
)
|
||||
);
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [value]);
|
||||
|
||||
@ -95,7 +109,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
);
|
||||
}
|
||||
VariablesDetail.propTypes = {
|
||||
value: string.isRequired,
|
||||
value: oneOfType([shape({}), string]).isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ import styled from 'styled-components';
|
||||
import { Split, SplitItem } from '@patternfly/react-core';
|
||||
import { CheckboxField, FieldTooltip } from '../FormField';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml';
|
||||
import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
|
||||
@ -30,7 +30,9 @@ function VariablesField({
|
||||
tooltip,
|
||||
}) {
|
||||
const [field, meta, helpers] = useField(name);
|
||||
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
|
||||
const [mode, setMode] = useState(
|
||||
isJsonString(field.value) ? JSON_MODE : YAML_MODE
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pf-c-form__group">
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { string, func, bool, number } from 'prop-types';
|
||||
import { Split, SplitItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml';
|
||||
import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
@ -18,11 +18,11 @@ const SplitItemRight = styled(SplitItem)`
|
||||
function VariablesInput(props) {
|
||||
const { id, label, readOnly, rows, error, onError, className } = props;
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
const defaultValue = isJson(props.value)
|
||||
const defaultValue = isJsonString(props.value)
|
||||
? formatJson(props.value)
|
||||
: props.value;
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
||||
const [mode, setMode] = useState(isJsonString(value) ? JSON_MODE : YAML_MODE);
|
||||
const isControlled = !!props.onChange;
|
||||
/* eslint-enable react/destructuring-assignment */
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
@ -9,103 +9,128 @@ import {
|
||||
ToolbarGroup,
|
||||
ToolbarItem,
|
||||
ToolbarToggleGroup,
|
||||
Dropdown,
|
||||
KebabToggle,
|
||||
} from '@patternfly/react-core';
|
||||
import { SearchIcon } from '@patternfly/react-icons';
|
||||
import ExpandCollapse from '../ExpandCollapse';
|
||||
import Search from '../Search';
|
||||
import Sort from '../Sort';
|
||||
|
||||
import { SearchColumns, SortColumns, QSConfig } from '../../types';
|
||||
import { KebabifiedProvider } from '../../contexts/Kebabified';
|
||||
|
||||
class DataListToolbar extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
itemCount,
|
||||
clearAllFilters,
|
||||
searchColumns,
|
||||
searchableKeys,
|
||||
relatedSearchableKeys,
|
||||
sortColumns,
|
||||
showSelectAll,
|
||||
isAllSelected,
|
||||
isCompact,
|
||||
onSort,
|
||||
onSearch,
|
||||
onReplaceSearch,
|
||||
onRemove,
|
||||
onCompact,
|
||||
onExpand,
|
||||
onSelectAll,
|
||||
additionalControls,
|
||||
i18n,
|
||||
qsConfig,
|
||||
pagination,
|
||||
} = this.props;
|
||||
function DataListToolbar({
|
||||
itemCount,
|
||||
clearAllFilters,
|
||||
searchColumns,
|
||||
searchableKeys,
|
||||
relatedSearchableKeys,
|
||||
sortColumns,
|
||||
showSelectAll,
|
||||
isAllSelected,
|
||||
isCompact,
|
||||
onSort,
|
||||
onSearch,
|
||||
onReplaceSearch,
|
||||
onRemove,
|
||||
onCompact,
|
||||
onExpand,
|
||||
onSelectAll,
|
||||
additionalControls,
|
||||
i18n,
|
||||
qsConfig,
|
||||
pagination,
|
||||
}) {
|
||||
const showExpandCollapse = onCompact && onExpand;
|
||||
const [kebabIsOpen, setKebabIsOpen] = useState(false);
|
||||
const [advancedSearchShown, setAdvancedSearchShown] = useState(false);
|
||||
|
||||
const showExpandCollapse = onCompact && onExpand;
|
||||
return (
|
||||
<Toolbar
|
||||
id={`${qsConfig.namespace}-list-toolbar`}
|
||||
clearAllFilters={clearAllFilters}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
{showSelectAll && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarItem>
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
aria-label={i18n._(t`Select all`)}
|
||||
id="select-all"
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
const onShowAdvancedSearch = shown => {
|
||||
setAdvancedSearchShown(shown);
|
||||
setKebabIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
id={`${qsConfig.namespace}-list-toolbar`}
|
||||
clearAllFilters={clearAllFilters}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
{showSelectAll && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarItem>
|
||||
<Search
|
||||
qsConfig={qsConfig}
|
||||
columns={[
|
||||
...searchColumns,
|
||||
{ name: i18n._(t`Advanced`), key: 'advanced' },
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
onSearch={onSearch}
|
||||
onReplaceSearch={onReplaceSearch}
|
||||
onRemove={onRemove}
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
aria-label={i18n._(t`Select all`)}
|
||||
id="select-all"
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
{showExpandCollapse && (
|
||||
<ToolbarGroup>
|
||||
<Fragment>
|
||||
<ToolbarItem>
|
||||
<ExpandCollapse
|
||||
isCompact={isCompact}
|
||||
onCompact={onCompact}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
<ToolbarItem>
|
||||
<Search
|
||||
qsConfig={qsConfig}
|
||||
columns={[
|
||||
...searchColumns,
|
||||
{ name: i18n._(t`Advanced`), key: 'advanced' },
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
onSearch={onSearch}
|
||||
onReplaceSearch={onReplaceSearch}
|
||||
onShowAdvancedSearch={onShowAdvancedSearch}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
{showExpandCollapse && (
|
||||
<ToolbarGroup>
|
||||
<Fragment>
|
||||
<ToolbarItem>
|
||||
<ExpandCollapse
|
||||
isCompact={isCompact}
|
||||
onCompact={onCompact}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
{advancedSearchShown && (
|
||||
<ToolbarItem>
|
||||
<Dropdown
|
||||
toggle={<KebabToggle onToggle={setKebabIsOpen} />}
|
||||
isOpen={kebabIsOpen}
|
||||
isPlain
|
||||
dropdownItems={additionalControls.map(control => {
|
||||
return (
|
||||
<KebabifiedProvider value={{ isKebabified: true }}>
|
||||
{control}
|
||||
</KebabifiedProvider>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
)}
|
||||
{!advancedSearchShown && (
|
||||
<ToolbarGroup>
|
||||
{additionalControls.map(control => (
|
||||
<ToolbarItem key={control.key}>{control}</ToolbarItem>
|
||||
))}
|
||||
</ToolbarGroup>
|
||||
{pagination && itemCount > 0 && (
|
||||
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
||||
)}
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{!advancedSearchShown && pagination && itemCount > 0 && (
|
||||
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
||||
)}
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
DataListToolbar.propTypes = {
|
||||
|
||||
51
awx/ui_next/src/components/DetailList/ObjectDetail.jsx
Normal file
51
awx/ui_next/src/components/DetailList/ObjectDetail.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import 'styled-components/macro';
|
||||
import React from 'react';
|
||||
import { shape, node, number } from 'prop-types';
|
||||
import { TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from './Detail';
|
||||
import CodeMirrorInput from '../CodeMirrorInput';
|
||||
|
||||
function ObjectDetail({ value, label, rows, fullHeight }) {
|
||||
return (
|
||||
<>
|
||||
<DetailName
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1"
|
||||
>
|
||||
<div className="pf-c-form__label">
|
||||
<span
|
||||
className="pf-c-form__label-text"
|
||||
css="font-weight: var(--pf-global--FontWeight--bold)"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
component={TextListItemVariants.dd}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1; margin-top: -20px"
|
||||
>
|
||||
<CodeMirrorInput
|
||||
mode="json"
|
||||
value={JSON.stringify(value)}
|
||||
readOnly
|
||||
rows={rows}
|
||||
fullHeight={fullHeight}
|
||||
css="margin-top: 10px"
|
||||
/>
|
||||
</DetailValue>
|
||||
</>
|
||||
);
|
||||
}
|
||||
ObjectDetail.propTypes = {
|
||||
value: shape.isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
ObjectDetail.defaultProps = {
|
||||
rows: null,
|
||||
};
|
||||
|
||||
export default ObjectDetail;
|
||||
@ -3,3 +3,8 @@ export { default as Detail, DetailName, DetailValue } from './Detail';
|
||||
export { default as DeletedDetail } from './DeletedDetail';
|
||||
export { default as UserDateDetail } from './UserDateDetail';
|
||||
export { default as DetailBadge } from './DetailBadge';
|
||||
/*
|
||||
NOTE: ObjectDetail cannot be imported here, as it causes circular
|
||||
dependencies in testing environment. Import it directly from
|
||||
DetailList/ObjectDetail
|
||||
*/
|
||||
|
||||
@ -16,6 +16,7 @@ function DisassociateButton({
|
||||
modalNote = '',
|
||||
modalTitle = i18n._(t`Disassociate?`),
|
||||
onDisassociate,
|
||||
verifyCannotDisassociate = true,
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@ -25,33 +26,41 @@ function DisassociateButton({
|
||||
}
|
||||
|
||||
function cannotDisassociate(item) {
|
||||
return !item.summary_fields.user_capabilities.delete;
|
||||
return !item.summary_fields?.user_capabilities?.delete;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
const itemsUnableToDisassociate = itemsToDisassociate
|
||||
.filter(cannotDisassociate)
|
||||
.map(item => item.name)
|
||||
.join(', ');
|
||||
if (verifyCannotDisassociate) {
|
||||
const itemsUnableToDisassociate = itemsToDisassociate
|
||||
.filter(cannotDisassociate)
|
||||
.map(item => item.name)
|
||||
.join(', ');
|
||||
|
||||
if (itemsToDisassociate.some(cannotDisassociate)) {
|
||||
return (
|
||||
<div>
|
||||
{i18n._(
|
||||
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (itemsToDisassociate.some(cannotDisassociate)) {
|
||||
return (
|
||||
<div>
|
||||
{i18n._(
|
||||
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsToDisassociate.length) {
|
||||
return i18n._(t`Disassociate`);
|
||||
}
|
||||
return i18n._(t`Select a row to disassociate`);
|
||||
}
|
||||
|
||||
const isDisabled =
|
||||
itemsToDisassociate.length === 0 ||
|
||||
itemsToDisassociate.some(cannotDisassociate);
|
||||
let isDisabled = false;
|
||||
if (verifyCannotDisassociate) {
|
||||
isDisabled =
|
||||
itemsToDisassociate.length === 0 ||
|
||||
itemsToDisassociate.some(cannotDisassociate);
|
||||
} else {
|
||||
isDisabled = itemsToDisassociate.length === 0;
|
||||
}
|
||||
|
||||
// NOTE: Once PF supports tooltips on disabled elements,
|
||||
// we can delete the extra <div> around the <DeleteButton> below.
|
||||
@ -61,7 +70,7 @@ function DisassociateButton({
|
||||
<Tooltip content={renderTooltip()} position="top">
|
||||
<div>
|
||||
<Button
|
||||
variant="danger"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Disassociate`)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
isDisabled={isDisabled}
|
||||
@ -102,7 +111,7 @@ function DisassociateButton({
|
||||
|
||||
{itemsToDisassociate.map(item => (
|
||||
<span key={item.id}>
|
||||
<strong>{item.name}</strong>
|
||||
<strong>{item.hostname ? item.hostname : item.name}</strong>
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -8,7 +8,18 @@ import ErrorDetail from '../ErrorDetail';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { HostsAPI } from '../../api';
|
||||
|
||||
function HostToggle({ host, onToggle, className, i18n }) {
|
||||
function HostToggle({
|
||||
i18n,
|
||||
className,
|
||||
host,
|
||||
isDisabled = false,
|
||||
onToggle,
|
||||
tooltip = i18n._(
|
||||
t`Indicates if a host is available and should be included in running
|
||||
jobs. For hosts that are part of an external inventory, this may be
|
||||
reset by the inventory sync process.`
|
||||
),
|
||||
}) {
|
||||
const [isEnabled, setIsEnabled] = useState(host.enabled);
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
@ -39,14 +50,7 @@ function HostToggle({ host, onToggle, className, i18n }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Indicates if a host is available and should be included in running
|
||||
jobs. For hosts that are part of an external inventory, this may be
|
||||
reset by the inventory sync process.`
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<Tooltip content={tooltip} position="top">
|
||||
<Switch
|
||||
className={className}
|
||||
css="display: inline-flex;"
|
||||
@ -54,7 +58,11 @@ function HostToggle({ host, onToggle, className, i18n }) {
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
isChecked={isEnabled}
|
||||
isDisabled={isLoading || !host.summary_fields.user_capabilities.edit}
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
isDisabled ||
|
||||
!host.summary_fields.user_capabilities.edit
|
||||
}
|
||||
onChange={toggleHost}
|
||||
aria-label={i18n._(t`Toggle host`)}
|
||||
/>
|
||||
|
||||
@ -19,7 +19,7 @@ const mockHost = {
|
||||
},
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
update: true,
|
||||
edit: true,
|
||||
},
|
||||
recent_jobs: [],
|
||||
},
|
||||
@ -68,6 +68,18 @@ describe('<HostToggle>', () => {
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('should be enabled', async () => {
|
||||
const wrapper = mountWithContexts(<HostToggle host={mockHost} />);
|
||||
expect(wrapper.find('Switch').prop('isDisabled')).toEqual(false);
|
||||
});
|
||||
|
||||
test('should be disabled', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<HostToggle isDisabled host={mockHost} />
|
||||
);
|
||||
expect(wrapper.find('Switch').prop('isDisabled')).toEqual(true);
|
||||
});
|
||||
|
||||
test('should show error modal', async () => {
|
||||
HostsAPI.update.mockImplementation(() => {
|
||||
throw new Error('nope');
|
||||
|
||||
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal file
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Switch, Tooltip } from '@patternfly/react-core';
|
||||
import AlertModal from '../AlertModal';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { InstancesAPI } from '../../api';
|
||||
import { useConfig } from '../../contexts/Config';
|
||||
|
||||
function InstanceToggle({
|
||||
className,
|
||||
fetchInstances,
|
||||
instance,
|
||||
onToggle,
|
||||
i18n,
|
||||
}) {
|
||||
const { me } = useConfig();
|
||||
const [isEnabled, setIsEnabled] = useState(instance.enabled);
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
const { result, isLoading, error, request: toggleInstance } = useRequest(
|
||||
useCallback(async () => {
|
||||
await InstancesAPI.update(instance.id, { enabled: !isEnabled });
|
||||
await fetchInstances();
|
||||
return !isEnabled;
|
||||
}, [instance, isEnabled, fetchInstances]),
|
||||
instance.enabled
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result !== isEnabled) {
|
||||
setIsEnabled(result);
|
||||
if (onToggle) {
|
||||
onToggle(result);
|
||||
}
|
||||
}
|
||||
}, [result, isEnabled, onToggle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Set the instance online or offline. If offline, jobs will not be assigned to this instance.`
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<Switch
|
||||
className={className}
|
||||
css="display: inline-flex;"
|
||||
id={`host-${instance.id}-toggle`}
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
isChecked={isEnabled}
|
||||
isDisabled={isLoading || !me.is_superuser}
|
||||
onChange={toggleInstance}
|
||||
aria-label={i18n._(t`Toggle instance`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{showError && error && !isLoading && (
|
||||
<AlertModal
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
isOpen={error && !isLoading}
|
||||
onClose={() => setShowError(false)}
|
||||
>
|
||||
{i18n._(t`Failed to toggle instance.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(InstanceToggle);
|
||||
@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { InstancesAPI } from '../../api';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import InstanceToggle from './InstanceToggle';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
const mockInstance = {
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
related: {
|
||||
jobs: '/api/v2/instances/1/jobs/',
|
||||
instance_groups: '/api/v2/instances/1/instance_groups/',
|
||||
},
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
hostname: 'awx',
|
||||
created: '2020-07-14T19:03:49.000054Z',
|
||||
modified: '2020-08-05T19:17:18.080033Z',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 100.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 67,
|
||||
cpu: 6,
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
};
|
||||
|
||||
describe('<InstanceToggle>', () => {
|
||||
const onToggle = jest.fn();
|
||||
const fetchInstances = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should show toggle off', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<InstanceToggle
|
||||
instance={mockInstance}
|
||||
fetchInstances={fetchInstances}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Switch').invoke('onChange')();
|
||||
});
|
||||
expect(InstancesAPI.update).toHaveBeenCalledWith(1, {
|
||||
enabled: false,
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
|
||||
expect(onToggle).toHaveBeenCalledWith(false);
|
||||
expect(fetchInstances).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should show toggle on', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<InstanceToggle
|
||||
instance={{
|
||||
...mockInstance,
|
||||
enabled: false,
|
||||
}}
|
||||
onToggle={onToggle}
|
||||
fetchInstances={fetchInstances}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Switch').invoke('onChange')();
|
||||
});
|
||||
expect(InstancesAPI.update).toHaveBeenCalledWith(1, {
|
||||
enabled: true,
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
expect(fetchInstances).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should show error modal', async () => {
|
||||
InstancesAPI.update.mockImplementation(() => {
|
||||
throw new Error('nope');
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<InstanceToggle instance={mockInstance} />
|
||||
);
|
||||
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Switch').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
const modal = wrapper.find('AlertModal');
|
||||
expect(modal).toHaveLength(1);
|
||||
expect(modal.prop('isOpen')).toEqual(true);
|
||||
|
||||
act(() => {
|
||||
modal.invoke('onClose')();
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InstanceToggle';
|
||||
@ -38,7 +38,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
const [selected, setSelected] = useState([]);
|
||||
const location = useLocation();
|
||||
const {
|
||||
result: { results, count, actions, relatedSearchFields },
|
||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchJobs,
|
||||
@ -53,10 +53,12 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
return {
|
||||
results: response.data.results,
|
||||
count: response.data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
},
|
||||
[location] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@ -64,8 +66,8 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
@ -138,11 +140,6 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
}
|
||||
};
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
|
||||
@ -52,7 +52,7 @@ function CredentialsStep({ i18n }) {
|
||||
}, [fetchTypes]);
|
||||
|
||||
const {
|
||||
result: { credentials, count, actions, relatedSearchFields },
|
||||
result: { credentials, count, relatedSearchableKeys, searchableKeys },
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
request: fetchCredentials,
|
||||
@ -72,13 +72,15 @@ function CredentialsStep({ i18n }) {
|
||||
return {
|
||||
credentials: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [selectedType, history.location.search]),
|
||||
{ credentials: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{ credentials: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -104,11 +106,6 @@ function CredentialsStep({ i18n }) {
|
||||
/>
|
||||
);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{types && types.length > 0 && (
|
||||
|
||||
@ -9,7 +9,6 @@ import useRequest from '../../../util/useRequest';
|
||||
import OptionsList from '../../OptionsList';
|
||||
import ContentLoading from '../../ContentLoading';
|
||||
import ContentError from '../../ContentError';
|
||||
import { required } from '../../../util/validators';
|
||||
|
||||
const QS_CONFIG = getQSConfig('inventory', {
|
||||
page: 1,
|
||||
@ -20,14 +19,13 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
function InventoryStep({ i18n }) {
|
||||
const [field, , helpers] = useField({
|
||||
name: 'inventory',
|
||||
validate: required(null, i18n),
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
result: { inventories, count, actions, relatedSearchFields },
|
||||
result: { inventories, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchInventories,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -39,17 +37,19 @@ function InventoryStep({ i18n }) {
|
||||
return {
|
||||
inventories: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location]),
|
||||
{
|
||||
count: 0,
|
||||
inventories: [],
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -57,11 +57,6 @@ function InventoryStep({ i18n }) {
|
||||
fetchInventories();
|
||||
}, [fetchInventories]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
@ -9,7 +9,11 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
|
||||
const [stepErrors, setStepErrors] = useState({});
|
||||
|
||||
const validate = values => {
|
||||
if (!config.ask_inventory_on_launch) {
|
||||
if (
|
||||
!config.ask_inventory_on_launch ||
|
||||
(['workflow_job', 'workflow_job_template'].includes(resource.type) &&
|
||||
!resource.inventory)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const errors = {};
|
||||
|
||||
@ -22,7 +22,7 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
|
||||
const location = useLocation();
|
||||
const {
|
||||
error,
|
||||
result: { applications, itemCount, actions, relatedSearchFields },
|
||||
result: { applications, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchApplications,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -40,23 +40,24 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
|
||||
return {
|
||||
applications: results,
|
||||
itemCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse?.data?.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
{ applications: [], itemCount: 0, actions: {}, relatedSearchFields: [] }
|
||||
{
|
||||
applications: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
}, [fetchApplications]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup fieldId="application" label={label}>
|
||||
<Lookup
|
||||
|
||||
@ -35,7 +35,7 @@ function CredentialLookup({
|
||||
tooltip,
|
||||
}) {
|
||||
const {
|
||||
result: { count, credentials, actions, relatedSearchFields },
|
||||
result: { count, credentials, relatedSearchableKeys, searchableKeys },
|
||||
error,
|
||||
request: fetchCredentials,
|
||||
} = useRequest(
|
||||
@ -64,10 +64,12 @@ function CredentialLookup({
|
||||
return {
|
||||
count: data.count,
|
||||
credentials: data.results,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data?.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data?.actions?.GET[key]?.filterable),
|
||||
};
|
||||
}, [
|
||||
credentialTypeId,
|
||||
@ -78,8 +80,8 @@ function CredentialLookup({
|
||||
{
|
||||
count: 0,
|
||||
credentials: [],
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -87,11 +89,6 @@ function CredentialLookup({
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
// TODO: replace credential type search with REST-based grabbing of cred types
|
||||
|
||||
return (
|
||||
|
||||
@ -30,7 +30,7 @@ function InstanceGroupsLookup(props) {
|
||||
} = props;
|
||||
|
||||
const {
|
||||
result: { instanceGroups, count, actions, relatedSearchFields },
|
||||
result: { instanceGroups, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchInstanceGroups,
|
||||
error,
|
||||
isLoading,
|
||||
@ -44,24 +44,26 @@ function InstanceGroupsLookup(props) {
|
||||
return {
|
||||
instanceGroups: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location]),
|
||||
{ instanceGroups: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{
|
||||
instanceGroups: [],
|
||||
count: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstanceGroups();
|
||||
}, [fetchInstanceGroups]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
className={className}
|
||||
|
||||
@ -19,7 +19,7 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
|
||||
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||
const {
|
||||
result: { inventories, count, actions, relatedSearchFields },
|
||||
result: { inventories, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchInventories,
|
||||
error,
|
||||
isLoading,
|
||||
@ -33,24 +33,21 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||
return {
|
||||
inventories: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location]),
|
||||
{ inventories: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{ inventories: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventories();
|
||||
}, [fetchInventories]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Lookup
|
||||
|
||||
@ -49,7 +49,12 @@ function MultiCredentialsLookup(props) {
|
||||
}, [fetchTypes]);
|
||||
|
||||
const {
|
||||
result: { credentials, credentialsCount, actions, relatedSearchFields },
|
||||
result: {
|
||||
credentials,
|
||||
credentialsCount,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
request: fetchCredentials,
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
@ -69,17 +74,19 @@ function MultiCredentialsLookup(props) {
|
||||
return {
|
||||
credentials: results,
|
||||
credentialsCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [selectedType, history.location]),
|
||||
{
|
||||
credentials: [],
|
||||
credentialsCount: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -104,11 +111,6 @@ function MultiCredentialsLookup(props) {
|
||||
|
||||
const isVault = selectedType?.kind === 'vault';
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<Lookup
|
||||
id="multiCredential"
|
||||
|
||||
@ -29,21 +29,32 @@ function OrganizationLookup({
|
||||
history,
|
||||
}) {
|
||||
const {
|
||||
result: { itemCount, organizations },
|
||||
result: { itemCount, organizations, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
request: fetchOrganizations,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { data } = await OrganizationsAPI.read(params);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
OrganizationsAPI.read(params),
|
||||
OrganizationsAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
organizations: data.results,
|
||||
itemCount: data.count,
|
||||
organizations: response.data.results,
|
||||
itemCount: response.data.count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location.search]),
|
||||
{
|
||||
organizations: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -98,6 +109,8 @@ function OrganizationLookup({
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
|
||||
@ -32,7 +32,7 @@ function ProjectLookup({
|
||||
history,
|
||||
}) {
|
||||
const {
|
||||
result: { projects, count, actions, relatedSearchFields },
|
||||
result: { projects, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchProjects,
|
||||
error,
|
||||
isLoading,
|
||||
@ -49,17 +49,19 @@ function ProjectLookup({
|
||||
return {
|
||||
count: data.count,
|
||||
projects: data.results,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location.search, autocomplete]),
|
||||
{
|
||||
count: 0,
|
||||
projects: [],
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -67,11 +69,6 @@ function ProjectLookup({
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="project"
|
||||
@ -108,6 +105,7 @@ function ProjectLookup({
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`archive`, i18n._(t`Remote Archive`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
|
||||
@ -17,20 +17,29 @@ const QS_CONFIG = getQSConfig('notification', {
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
function NotificationList({
|
||||
apiModel,
|
||||
canToggleNotifications,
|
||||
id,
|
||||
i18n,
|
||||
showApprovalsToggle,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [isToggleLoading, setIsToggleLoading] = useState(false);
|
||||
const [loadingToggleIds, setLoadingToggleIds] = useState([]);
|
||||
const [toggleError, setToggleError] = useState(null);
|
||||
|
||||
const {
|
||||
result: fetchNotificationsResult,
|
||||
result: fetchNotificationsResults,
|
||||
result: {
|
||||
notifications,
|
||||
itemCount,
|
||||
approvalsTemplateIds,
|
||||
startedTemplateIds,
|
||||
successTemplateIds,
|
||||
errorTemplateIds,
|
||||
typeLabels,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
@ -43,15 +52,13 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
{
|
||||
data: { results: notificationsResults, count: notificationsCount },
|
||||
},
|
||||
{
|
||||
data: { actions },
|
||||
},
|
||||
actionsResponse,
|
||||
] = await Promise.all([
|
||||
NotificationTemplatesAPI.read(params),
|
||||
NotificationTemplatesAPI.readOptions(),
|
||||
]);
|
||||
|
||||
const labels = actions.GET.notification_type.choices.reduce(
|
||||
const labels = actionsResponse.data.actions.GET.notification_type.choices.reduce(
|
||||
(map, notifType) => ({ ...map, [notifType[0]]: notifType[1] }),
|
||||
{}
|
||||
);
|
||||
@ -71,22 +78,47 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
apiModel.readNotificationTemplatesError(id, idMatchParams),
|
||||
]);
|
||||
|
||||
return {
|
||||
const rtnObj = {
|
||||
notifications: notificationsResults,
|
||||
itemCount: notificationsCount,
|
||||
startedTemplateIds: startedTemplates.results.map(st => st.id),
|
||||
successTemplateIds: successTemplates.results.map(su => su.id),
|
||||
errorTemplateIds: errorTemplates.results.map(e => e.id),
|
||||
typeLabels: labels,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [apiModel, id, location]),
|
||||
|
||||
if (showApprovalsToggle) {
|
||||
const {
|
||||
data: approvalsTemplates,
|
||||
} = await apiModel.readNotificationTemplatesApprovals(
|
||||
id,
|
||||
idMatchParams
|
||||
);
|
||||
rtnObj.approvalsTemplateIds = approvalsTemplates.results.map(
|
||||
st => st.id
|
||||
);
|
||||
} else {
|
||||
rtnObj.approvalsTemplateIds = [];
|
||||
}
|
||||
|
||||
return rtnObj;
|
||||
}, [apiModel, id, location, showApprovalsToggle]),
|
||||
{
|
||||
notifications: [],
|
||||
itemCount: 0,
|
||||
approvalsTemplateIds: [],
|
||||
startedTemplateIds: [],
|
||||
successTemplateIds: [],
|
||||
errorTemplateIds: [],
|
||||
typeLabels: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -99,7 +131,7 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
isCurrentlyOn,
|
||||
status
|
||||
) => {
|
||||
setIsToggleLoading(true);
|
||||
setLoadingToggleIds(loadingToggleIds.concat([notificationId]));
|
||||
try {
|
||||
if (isCurrentlyOn) {
|
||||
await apiModel.disassociateNotificationTemplate(
|
||||
@ -108,8 +140,8 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
status
|
||||
);
|
||||
setValue({
|
||||
...fetchNotificationsResult,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResult[
|
||||
...fetchNotificationsResults,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResults[
|
||||
`${status}TemplateIds`
|
||||
].filter(i => i !== notificationId),
|
||||
});
|
||||
@ -120,8 +152,8 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
status
|
||||
);
|
||||
setValue({
|
||||
...fetchNotificationsResult,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResult[
|
||||
...fetchNotificationsResults,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResults[
|
||||
`${status}TemplateIds`
|
||||
].concat(notificationId),
|
||||
});
|
||||
@ -129,7 +161,9 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
} catch (err) {
|
||||
setToggleError(err);
|
||||
} finally {
|
||||
setIsToggleLoading(false);
|
||||
setLoadingToggleIds(
|
||||
loadingToggleIds.filter(item => item !== notificationId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -179,17 +213,24 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderItem={notification => (
|
||||
<NotificationListItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
detailUrl={`/notifications/${notification.id}`}
|
||||
canToggleNotifications={canToggleNotifications && !isToggleLoading}
|
||||
canToggleNotifications={
|
||||
canToggleNotifications &&
|
||||
!loadingToggleIds.includes(notification.id)
|
||||
}
|
||||
toggleNotification={handleNotificationToggle}
|
||||
approvalsTurnedOn={approvalsTemplateIds.includes(notification.id)}
|
||||
errorTurnedOn={errorTemplateIds.includes(notification.id)}
|
||||
startedTurnedOn={startedTemplateIds.includes(notification.id)}
|
||||
successTurnedOn={successTemplateIds.includes(notification.id)}
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle={showApprovalsToggle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -197,7 +238,7 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
<AlertModal
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
isOpen={!isToggleLoading}
|
||||
isOpen={loadingToggleIds.length === 0}
|
||||
onClose={() => setToggleError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to toggle notification.`)}
|
||||
@ -212,6 +253,11 @@ NotificationList.propTypes = {
|
||||
apiModel: shape({}).isRequired,
|
||||
id: number.isRequired,
|
||||
canToggleNotifications: bool.isRequired,
|
||||
showApprovalsToggle: bool,
|
||||
};
|
||||
|
||||
NotificationList.defaultProps = {
|
||||
showApprovalsToggle: false,
|
||||
};
|
||||
|
||||
export default withI18n()(NotificationList);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user