mirror of
https://github.com/ansible/awx.git
synced 2026-02-22 21:46:00 -03:30
Merge branch 'hardening' into devel
This commit is contained in:
2
Makefile
2
Makefile
@@ -400,7 +400,7 @@ flake8_collection:
|
|||||||
test_collection_all: prepare_collection_venv test_collection flake8_collection
|
test_collection_all: prepare_collection_venv test_collection flake8_collection
|
||||||
|
|
||||||
build_collection:
|
build_collection:
|
||||||
ansible-playbook -i localhost, awx_collection/template_galaxy.yml -e collection_package=$(COLLECTION_PACKAGE) -e namespace_name=$(COLLECTION_NAMESPACE) -e package_version=$(VERSION)
|
ansible-playbook -i localhost, awx_collection/template_galaxy.yml -e collection_package=$(COLLECTION_PACKAGE) -e collection_namespace=$(COLLECTION_NAMESPACE) -e collection_version=$(VERSION)
|
||||||
ansible-galaxy collection build awx_collection --output-path=awx_collection
|
ansible-galaxy collection build awx_collection --output-path=awx_collection
|
||||||
|
|
||||||
test_unit:
|
test_unit:
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ def find_commands(management_dir):
|
|||||||
return commands
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
def oauth2_getattribute(self, attr):
|
||||||
|
# Custom method to override
|
||||||
|
# oauth2_provider.settings.OAuth2ProviderSettings.__getattribute__
|
||||||
|
from django.conf import settings
|
||||||
|
val = settings.OAUTH2_PROVIDER.get(attr)
|
||||||
|
if val is None:
|
||||||
|
val = object.__getattribute__(self, attr)
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def prepare_env():
|
def prepare_env():
|
||||||
# Update the default settings environment variable based on current mode.
|
# Update the default settings environment variable based on current mode.
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings.%s' % MODE)
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings.%s' % MODE)
|
||||||
@@ -93,6 +103,12 @@ def prepare_env():
|
|||||||
# Monkeypatch Django find_commands to also work with .pyc files.
|
# Monkeypatch Django find_commands to also work with .pyc files.
|
||||||
import django.core.management
|
import django.core.management
|
||||||
django.core.management.find_commands = find_commands
|
django.core.management.find_commands = find_commands
|
||||||
|
|
||||||
|
# Monkeypatch Oauth2 toolkit settings class to check for settings
|
||||||
|
# in django.conf settings each time, not just once during import
|
||||||
|
import oauth2_provider.settings
|
||||||
|
oauth2_provider.settings.OAuth2ProviderSettings.__getattribute__ = oauth2_getattribute
|
||||||
|
|
||||||
# Use the AWX_TEST_DATABASE_* environment variables to specify the test
|
# Use the AWX_TEST_DATABASE_* environment variables to specify the test
|
||||||
# database settings to use when management command is run as an external
|
# database settings to use when management command is run as an external
|
||||||
# program via unit tests.
|
# program via unit tests.
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class FieldLookupBackend(BaseFilterBackend):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
RESERVED_NAMES = ('page', 'page_size', 'format', 'order', 'order_by',
|
RESERVED_NAMES = ('page', 'page_size', 'format', 'order', 'order_by',
|
||||||
'search', 'type', 'host_filter', 'count_disabled',)
|
'search', 'type', 'host_filter', 'count_disabled', 'no_truncate')
|
||||||
|
|
||||||
SUPPORTED_LOOKUPS = ('exact', 'iexact', 'contains', 'icontains',
|
SUPPORTED_LOOKUPS = ('exact', 'iexact', 'contains', 'icontains',
|
||||||
'startswith', 'istartswith', 'endswith', 'iendswith',
|
'startswith', 'istartswith', 'endswith', 'iendswith',
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ from polymorphic.models import PolymorphicModel
|
|||||||
from awx.main.access import get_user_capabilities
|
from awx.main.access import get_user_capabilities
|
||||||
from awx.main.constants import (
|
from awx.main.constants import (
|
||||||
SCHEDULEABLE_PROVIDERS,
|
SCHEDULEABLE_PROVIDERS,
|
||||||
ANSI_SGR_PATTERN,
|
|
||||||
ACTIVE_STATES,
|
ACTIVE_STATES,
|
||||||
CENSOR_VALUE,
|
CENSOR_VALUE,
|
||||||
)
|
)
|
||||||
@@ -70,7 +69,8 @@ from awx.main.utils import (
|
|||||||
get_type_for_model, get_model_for_type,
|
get_type_for_model, get_model_for_type,
|
||||||
camelcase_to_underscore, getattrd, parse_yaml_or_json,
|
camelcase_to_underscore, getattrd, parse_yaml_or_json,
|
||||||
has_model_field_prefetched, extract_ansible_vars, encrypt_dict,
|
has_model_field_prefetched, extract_ansible_vars, encrypt_dict,
|
||||||
prefetch_page_capabilities, get_external_account)
|
prefetch_page_capabilities, get_external_account, truncate_stdout,
|
||||||
|
)
|
||||||
from awx.main.utils.filters import SmartFilter
|
from awx.main.utils.filters import SmartFilter
|
||||||
from awx.main.redact import UriCleaner, REPLACE_STR
|
from awx.main.redact import UriCleaner, REPLACE_STR
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ SUMMARIZABLE_FK_FIELDS = {
|
|||||||
'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
|
'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
|
||||||
'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
|
'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
|
||||||
'webhook_credential': DEFAULT_SUMMARY_FIELDS,
|
'webhook_credential': DEFAULT_SUMMARY_FIELDS,
|
||||||
|
'approved_or_denied_by': ('id', 'username', 'first_name', 'last_name'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3501,6 +3502,8 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer):
|
|||||||
kwargs={'pk': obj.workflow_approval_template.pk})
|
kwargs={'pk': obj.workflow_approval_template.pk})
|
||||||
res['approve'] = self.reverse('api:workflow_approval_approve', kwargs={'pk': obj.pk})
|
res['approve'] = self.reverse('api:workflow_approval_approve', kwargs={'pk': obj.pk})
|
||||||
res['deny'] = self.reverse('api:workflow_approval_deny', kwargs={'pk': obj.pk})
|
res['deny'] = self.reverse('api:workflow_approval_deny', kwargs={'pk': obj.pk})
|
||||||
|
if obj.approved_or_denied_by:
|
||||||
|
res['approved_or_denied_by'] = self.reverse('api:user_detail', kwargs={'pk': obj.approved_or_denied_by.pk})
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@@ -3851,25 +3854,17 @@ class JobEventSerializer(BaseSerializer):
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
ret = super(JobEventSerializer, self).to_representation(obj)
|
data = super(JobEventSerializer, self).to_representation(obj)
|
||||||
# Show full stdout for event detail view, truncate only for list view.
|
|
||||||
if hasattr(self.context.get('view', None), 'retrieve'):
|
|
||||||
return ret
|
|
||||||
# Show full stdout for playbook_on_* events.
|
# Show full stdout for playbook_on_* events.
|
||||||
if obj and obj.event.startswith('playbook_on'):
|
if obj and obj.event.startswith('playbook_on'):
|
||||||
return ret
|
return data
|
||||||
|
# If the view logic says to not trunctate (request was to the detail view or a param was used)
|
||||||
|
if self.context.get('no_truncate', False):
|
||||||
|
return data
|
||||||
max_bytes = settings.EVENT_STDOUT_MAX_BYTES_DISPLAY
|
max_bytes = settings.EVENT_STDOUT_MAX_BYTES_DISPLAY
|
||||||
if max_bytes > 0 and 'stdout' in ret and len(ret['stdout']) >= max_bytes:
|
if 'stdout' in data:
|
||||||
ret['stdout'] = ret['stdout'][:(max_bytes - 1)] + u'\u2026'
|
data['stdout'] = truncate_stdout(data['stdout'], max_bytes)
|
||||||
set_count = 0
|
return data
|
||||||
reset_count = 0
|
|
||||||
for m in ANSI_SGR_PATTERN.finditer(ret['stdout']):
|
|
||||||
if m.string[m.start():m.end()] == u'\u001b[0m':
|
|
||||||
reset_count += 1
|
|
||||||
else:
|
|
||||||
set_count += 1
|
|
||||||
ret['stdout'] += u'\u001b[0m' * (set_count - reset_count)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class JobEventWebSocketSerializer(JobEventSerializer):
|
class JobEventWebSocketSerializer(JobEventSerializer):
|
||||||
@@ -3964,22 +3959,14 @@ class AdHocCommandEventSerializer(BaseSerializer):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
ret = super(AdHocCommandEventSerializer, self).to_representation(obj)
|
data = super(AdHocCommandEventSerializer, self).to_representation(obj)
|
||||||
# Show full stdout for event detail view, truncate only for list view.
|
# If the view logic says to not trunctate (request was to the detail view or a param was used)
|
||||||
if hasattr(self.context.get('view', None), 'retrieve'):
|
if self.context.get('no_truncate', False):
|
||||||
return ret
|
return data
|
||||||
max_bytes = settings.EVENT_STDOUT_MAX_BYTES_DISPLAY
|
max_bytes = settings.EVENT_STDOUT_MAX_BYTES_DISPLAY
|
||||||
if max_bytes > 0 and 'stdout' in ret and len(ret['stdout']) >= max_bytes:
|
if 'stdout' in data:
|
||||||
ret['stdout'] = ret['stdout'][:(max_bytes - 1)] + u'\u2026'
|
data['stdout'] = truncate_stdout(data['stdout'], max_bytes)
|
||||||
set_count = 0
|
return data
|
||||||
reset_count = 0
|
|
||||||
for m in ANSI_SGR_PATTERN.finditer(ret['stdout']):
|
|
||||||
if m.string[m.start():m.end()] == u'\u001b[0m':
|
|
||||||
reset_count += 1
|
|
||||||
else:
|
|
||||||
set_count += 1
|
|
||||||
ret['stdout'] += u'\u001b[0m' * (set_count - reset_count)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class AdHocCommandEventWebSocketSerializer(AdHocCommandEventSerializer):
|
class AdHocCommandEventWebSocketSerializer(AdHocCommandEventSerializer):
|
||||||
|
|||||||
@@ -3768,12 +3768,23 @@ class JobEventList(ListAPIView):
|
|||||||
serializer_class = serializers.JobEventSerializer
|
serializer_class = serializers.JobEventSerializer
|
||||||
search_fields = ('stdout',)
|
search_fields = ('stdout',)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
if self.request.query_params.get('no_truncate'):
|
||||||
|
context.update(no_truncate=True)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class JobEventDetail(RetrieveAPIView):
|
class JobEventDetail(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.JobEvent
|
model = models.JobEvent
|
||||||
serializer_class = serializers.JobEventSerializer
|
serializer_class = serializers.JobEventSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
context.update(no_truncate=True)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class JobEventChildrenList(SubListAPIView):
|
class JobEventChildrenList(SubListAPIView):
|
||||||
|
|
||||||
@@ -4002,12 +4013,23 @@ class AdHocCommandEventList(ListAPIView):
|
|||||||
serializer_class = serializers.AdHocCommandEventSerializer
|
serializer_class = serializers.AdHocCommandEventSerializer
|
||||||
search_fields = ('stdout',)
|
search_fields = ('stdout',)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
if self.request.query_params.get('no_truncate'):
|
||||||
|
context.update(no_truncate=True)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class AdHocCommandEventDetail(RetrieveAPIView):
|
class AdHocCommandEventDetail(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.AdHocCommandEvent
|
model = models.AdHocCommandEvent
|
||||||
serializer_class = serializers.AdHocCommandEventSerializer
|
serializer_class = serializers.AdHocCommandEventSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
context.update(no_truncate=True)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class BaseAdHocCommandEventsList(SubListAPIView):
|
class BaseAdHocCommandEventsList(SubListAPIView):
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,16 @@ class InventoryUpdateEventsList(SubListAPIView):
|
|||||||
|
|
||||||
class InventoryScriptList(ListCreateAPIView):
|
class InventoryScriptList(ListCreateAPIView):
|
||||||
|
|
||||||
|
deprecated = True
|
||||||
|
|
||||||
model = CustomInventoryScript
|
model = CustomInventoryScript
|
||||||
serializer_class = CustomInventoryScriptSerializer
|
serializer_class = CustomInventoryScriptSerializer
|
||||||
|
|
||||||
|
|
||||||
class InventoryScriptDetail(RetrieveUpdateDestroyAPIView):
|
class InventoryScriptDetail(RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
|
deprecated = True
|
||||||
|
|
||||||
model = CustomInventoryScript
|
model = CustomInventoryScript
|
||||||
serializer_class = CustomInventoryScriptSerializer
|
serializer_class = CustomInventoryScriptSerializer
|
||||||
|
|
||||||
@@ -92,6 +96,8 @@ class InventoryScriptDetail(RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
class InventoryScriptObjectRolesList(SubListAPIView):
|
class InventoryScriptObjectRolesList(SubListAPIView):
|
||||||
|
|
||||||
|
deprecated = True
|
||||||
|
|
||||||
model = Role
|
model = Role
|
||||||
serializer_class = RoleSerializer
|
serializer_class = RoleSerializer
|
||||||
parent_model = CustomInventoryScript
|
parent_model = CustomInventoryScript
|
||||||
@@ -105,6 +111,8 @@ class InventoryScriptObjectRolesList(SubListAPIView):
|
|||||||
|
|
||||||
class InventoryScriptCopy(CopyAPIView):
|
class InventoryScriptCopy(CopyAPIView):
|
||||||
|
|
||||||
|
deprecated = True
|
||||||
|
|
||||||
model = CustomInventoryScript
|
model = CustomInventoryScript
|
||||||
copy_return_serializer_class = CustomInventoryScriptSerializer
|
copy_return_serializer_class = CustomInventoryScriptSerializer
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ register(
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
default=False,
|
default=False,
|
||||||
label=_('Enabled external log aggregation auditing'),
|
label=_('Enabled external log aggregation auditing'),
|
||||||
help_text=_('When enabled, all external logs emitted by Tower will also be written to /var/log/tower/external.log'),
|
help_text=_('When enabled, all external logs emitted by Tower will also be written to /var/log/tower/external.log. This is an experimental setting intended to be used for debugging external log aggregation issues (and may be subject to change in the future).'), # noqa
|
||||||
category=_('Logging'),
|
category=_('Logging'),
|
||||||
category_slug='logging',
|
category_slug='logging',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 2.2.4 on 2019-10-11 15:40
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('main', '0096_v360_container_groups'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workflowapproval',
|
||||||
|
name='approved_or_denied_by',
|
||||||
|
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'workflowapproval', 'model_name': 'workflowapproval', 'app_label': 'main'}(class)s_approved+", to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -119,10 +119,11 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
tzinfo = r._dtstart.tzinfo
|
tzinfo = r._dtstart.tzinfo
|
||||||
if tzinfo is utc:
|
if tzinfo is utc:
|
||||||
return 'UTC'
|
return 'UTC'
|
||||||
fname = tzinfo._filename
|
fname = getattr(tzinfo, '_filename', None)
|
||||||
for zone in all_zones:
|
if fname:
|
||||||
if fname.endswith(zone):
|
for zone in all_zones:
|
||||||
return zone
|
if fname.endswith(zone):
|
||||||
|
return zone
|
||||||
logger.warn('Could not detect valid zoneinfo for {}'.format(self.rrule))
|
logger.warn('Could not detect valid zoneinfo for {}'.format(self.rrule))
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
#from django import settings as tower_settings
|
#from django import settings as tower_settings
|
||||||
|
|
||||||
|
# Django-CRUM
|
||||||
|
from crum import get_current_user
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
from awx.main.models import (prevent_search, accepts_json, UnifiedJobTemplate,
|
from awx.main.models import (prevent_search, accepts_json, UnifiedJobTemplate,
|
||||||
@@ -690,6 +693,14 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text=_("Shows when an approval node (with a timeout assigned to it) has timed out.")
|
help_text=_("Shows when an approval node (with a timeout assigned to it) has timed out.")
|
||||||
)
|
)
|
||||||
|
approved_or_denied_by = models.ForeignKey(
|
||||||
|
'auth.User',
|
||||||
|
related_name='%s(class)s_approved+',
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
editable=False,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -711,6 +722,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
|
|
||||||
def approve(self, request=None):
|
def approve(self, request=None):
|
||||||
self.status = 'successful'
|
self.status = 'successful'
|
||||||
|
self.approved_or_denied_by = get_current_user()
|
||||||
self.save()
|
self.save()
|
||||||
self.send_approval_notification('approved')
|
self.send_approval_notification('approved')
|
||||||
self.websocket_emit_status(self.status)
|
self.websocket_emit_status(self.status)
|
||||||
@@ -719,6 +731,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
|
|
||||||
def deny(self, request=None):
|
def deny(self, request=None):
|
||||||
self.status = 'failed'
|
self.status = 'failed'
|
||||||
|
self.approved_or_denied_by = get_current_user()
|
||||||
self.save()
|
self.save()
|
||||||
self.send_approval_notification('denied')
|
self.send_approval_notification('denied')
|
||||||
self.websocket_emit_status(self.status)
|
self.websocket_emit_status(self.status)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import stat
|
|||||||
import time
|
import time
|
||||||
import yaml
|
import yaml
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import logging
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -11,6 +12,8 @@ from django.utils.functional import cached_property
|
|||||||
|
|
||||||
from awx.main.utils.common import parse_yaml_or_json
|
from awx.main.utils.common import parse_yaml_or_json
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.main.scheduler')
|
||||||
|
|
||||||
|
|
||||||
class PodManager(object):
|
class PodManager(object):
|
||||||
|
|
||||||
@@ -21,32 +24,33 @@ class PodManager(object):
|
|||||||
if not self.credential.kubernetes:
|
if not self.credential.kubernetes:
|
||||||
raise RuntimeError('Pod deployment cannot occur without a Kubernetes credential')
|
raise RuntimeError('Pod deployment cannot occur without a Kubernetes credential')
|
||||||
|
|
||||||
|
|
||||||
self.kube_api.create_namespaced_pod(body=self.pod_definition,
|
self.kube_api.create_namespaced_pod(body=self.pod_definition,
|
||||||
namespace=self.namespace,
|
namespace=self.namespace,
|
||||||
_request_timeout=settings.AWX_CONTAINER_GROUP_DEFAULT_LAUNCH_TIMEOUT)
|
_request_timeout=settings.AWX_CONTAINER_GROUP_K8S_API_TIMEOUT)
|
||||||
|
|
||||||
# We don't do any fancy timeout logic here because it is handled
|
num_retries = settings.AWX_CONTAINER_GROUP_POD_LAUNCH_RETRIES
|
||||||
# at a higher level in the job spawning process. See
|
for retry_attempt in range(num_retries - 1):
|
||||||
# settings.AWX_ISOLATED_LAUNCH_TIMEOUT and settings.AWX_ISOLATED_CONNECTION_TIMEOUT
|
logger.debug(f"Checking for pod {self.pod_name}. Attempt {retry_attempt + 1} of {num_retries}")
|
||||||
while True:
|
|
||||||
pod = self.kube_api.read_namespaced_pod(name=self.pod_name,
|
pod = self.kube_api.read_namespaced_pod(name=self.pod_name,
|
||||||
namespace=self.namespace,
|
namespace=self.namespace,
|
||||||
_request_timeout=settings.AWX_CONTAINER_GROUP_DEFAULT_LAUNCH_TIMEOUT)
|
_request_timeout=settings.AWX_CONTAINER_GROUP_K8S_API_TIMEOUT)
|
||||||
if pod.status.phase != 'Pending':
|
if pod.status.phase != 'Pending':
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
else:
|
||||||
|
logger.debug(f"Pod {self.pod_name} is Pending.")
|
||||||
|
time.sleep(settings.AWX_CONTAINER_GROUP_POD_LAUNCH_RETRY_DELAY)
|
||||||
|
continue
|
||||||
|
|
||||||
if pod.status.phase == 'Running':
|
if pod.status.phase == 'Running':
|
||||||
|
logger.debug(f"Pod {self.pod_name} is online.")
|
||||||
return pod
|
return pod
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Unhandled Pod phase: {pod.status.phase}")
|
logger.warn(f"Pod {self.pod_name} did not start. Status is {pod.status.phase}.")
|
||||||
|
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
return self.kube_api.delete_namespaced_pod(name=self.pod_name,
|
return self.kube_api.delete_namespaced_pod(name=self.pod_name,
|
||||||
namespace=self.namespace,
|
namespace=self.namespace,
|
||||||
_request_timeout=settings.AWX_CONTAINER_GROUP_DEFAULT_LAUNCH_TIMEOUT)
|
_request_timeout=settings.AWX_CONTAINER_GROUP_K8S_API_TIMEOUT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def namespace(self):
|
def namespace(self):
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
|
# Kubernetes
|
||||||
|
from kubernetes.client.rest import ApiException
|
||||||
|
|
||||||
# Django-CRUM
|
# Django-CRUM
|
||||||
from crum import impersonate
|
from crum import impersonate
|
||||||
|
|
||||||
@@ -73,6 +76,7 @@ from awx.main.utils import (get_ssh_version, update_scm_url,
|
|||||||
ignore_inventory_computed_fields,
|
ignore_inventory_computed_fields,
|
||||||
ignore_inventory_group_removal, extract_ansible_vars, schedule_task_manager,
|
ignore_inventory_group_removal, extract_ansible_vars, schedule_task_manager,
|
||||||
get_awx_version)
|
get_awx_version)
|
||||||
|
from awx.main.utils.ansible import read_ansible_config
|
||||||
from awx.main.utils.common import get_ansible_version, _get_ansible_version, get_custom_venv_choices
|
from awx.main.utils.common import get_ansible_version, _get_ansible_version, get_custom_venv_choices
|
||||||
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
|
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
|
||||||
from awx.main.utils.reload import stop_local_services
|
from awx.main.utils.reload import stop_local_services
|
||||||
@@ -1183,6 +1187,18 @@ class BaseTask(object):
|
|||||||
'''
|
'''
|
||||||
Run the job/task and capture its output.
|
Run the job/task and capture its output.
|
||||||
'''
|
'''
|
||||||
|
self.instance = self.model.objects.get(pk=pk)
|
||||||
|
containerized = self.instance.is_containerized
|
||||||
|
pod_manager = None
|
||||||
|
if containerized:
|
||||||
|
# Here we are trying to launch a pod before transitioning the job into a running
|
||||||
|
# state. For some scenarios (like waiting for resources to become available) we do this
|
||||||
|
# rather than marking the job as error or failed. This is not always desirable. Cases
|
||||||
|
# such as invalid authentication should surface as an error.
|
||||||
|
pod_manager = self.deploy_container_group_pod(self.instance)
|
||||||
|
if not pod_manager:
|
||||||
|
return
|
||||||
|
|
||||||
# self.instance because of the update_model pattern and when it's used in callback handlers
|
# self.instance because of the update_model pattern and when it's used in callback handlers
|
||||||
self.instance = self.update_model(pk, status='running',
|
self.instance = self.update_model(pk, status='running',
|
||||||
start_args='') # blank field to remove encrypted passwords
|
start_args='') # blank field to remove encrypted passwords
|
||||||
@@ -1208,7 +1224,6 @@ class BaseTask(object):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
isolated = self.instance.is_isolated()
|
isolated = self.instance.is_isolated()
|
||||||
containerized = self.instance.is_containerized
|
|
||||||
self.instance.send_notification_templates("running")
|
self.instance.send_notification_templates("running")
|
||||||
private_data_dir = self.build_private_data_dir(self.instance)
|
private_data_dir = self.build_private_data_dir(self.instance)
|
||||||
self.pre_run_hook(self.instance, private_data_dir)
|
self.pre_run_hook(self.instance, private_data_dir)
|
||||||
@@ -1287,6 +1302,10 @@ class BaseTask(object):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if containerized:
|
||||||
|
# We don't want HOME passed through to container groups.
|
||||||
|
params['envvars'].pop('HOME')
|
||||||
|
|
||||||
if isinstance(self.instance, AdHocCommand):
|
if isinstance(self.instance, AdHocCommand):
|
||||||
params['module'] = self.build_module_name(self.instance)
|
params['module'] = self.build_module_name(self.instance)
|
||||||
params['module_args'] = self.build_module_args(self.instance)
|
params['module_args'] = self.build_module_args(self.instance)
|
||||||
@@ -1316,16 +1335,6 @@ class BaseTask(object):
|
|||||||
params.pop('inventory'),
|
params.pop('inventory'),
|
||||||
os.path.join(private_data_dir, 'inventory')
|
os.path.join(private_data_dir, 'inventory')
|
||||||
)
|
)
|
||||||
pod_manager = None
|
|
||||||
if containerized:
|
|
||||||
from awx.main.scheduler.kubernetes import PodManager # Avoid circular import
|
|
||||||
params['envvars'].pop('HOME')
|
|
||||||
pod_manager = PodManager(self.instance)
|
|
||||||
self.cleanup_paths.append(pod_manager.kube_config)
|
|
||||||
pod_manager.deploy()
|
|
||||||
self.instance.execution_node = pod_manager.pod_name
|
|
||||||
self.instance.save(update_fields=['execution_node'])
|
|
||||||
|
|
||||||
|
|
||||||
ansible_runner.utils.dump_artifacts(params)
|
ansible_runner.utils.dump_artifacts(params)
|
||||||
isolated_manager_instance = isolated_manager.IsolatedManager(
|
isolated_manager_instance = isolated_manager.IsolatedManager(
|
||||||
@@ -1385,6 +1394,42 @@ class BaseTask(object):
|
|||||||
raise AwxTaskError.TaskError(self.instance, rc)
|
raise AwxTaskError.TaskError(self.instance, rc)
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_container_group_pod(self, task):
|
||||||
|
from awx.main.scheduler.kubernetes import PodManager # Avoid circular import
|
||||||
|
pod_manager = PodManager(self.instance)
|
||||||
|
self.cleanup_paths.append(pod_manager.kube_config)
|
||||||
|
try:
|
||||||
|
log_name = task.log_format
|
||||||
|
logger.debug(f"Launching pod for {log_name}.")
|
||||||
|
pod_manager.deploy()
|
||||||
|
except (ApiException, Exception) as exc:
|
||||||
|
if isinstance(exc, ApiException) and exc.status == 403:
|
||||||
|
try:
|
||||||
|
if 'exceeded quota' in json.loads(exc.body)['message']:
|
||||||
|
# If the k8s cluster does not have capacity, we move the
|
||||||
|
# job back into pending and wait until the next run of
|
||||||
|
# the task manager. This does not exactly play well with
|
||||||
|
# our current instance group precendence logic, since it
|
||||||
|
# will just sit here forever if kubernetes returns this
|
||||||
|
# error.
|
||||||
|
logger.warn(exc.body)
|
||||||
|
logger.warn(f"Could not launch pod for {log_name}. Exceeded quota.")
|
||||||
|
self.update_model(task.pk, status='pending')
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Unable to handle response from Kubernetes API for {log_name}.")
|
||||||
|
|
||||||
|
logger.exception(f"Error when launching pod for {log_name}")
|
||||||
|
self.update_model(task.pk, status='error', result_traceback=traceback.format_exc())
|
||||||
|
return
|
||||||
|
|
||||||
|
self.update_model(task.pk, execution_node=pod_manager.pod_name)
|
||||||
|
return pod_manager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@task()
|
@task()
|
||||||
class RunJob(BaseTask):
|
class RunJob(BaseTask):
|
||||||
'''
|
'''
|
||||||
@@ -1529,14 +1574,22 @@ class RunJob(BaseTask):
|
|||||||
if authorize:
|
if authorize:
|
||||||
env['ANSIBLE_NET_AUTH_PASS'] = network_cred.get_input('authorize_password', default='')
|
env['ANSIBLE_NET_AUTH_PASS'] = network_cred.get_input('authorize_password', default='')
|
||||||
|
|
||||||
for env_key, folder, default in (
|
path_vars = (
|
||||||
('ANSIBLE_COLLECTIONS_PATHS', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
|
('ANSIBLE_COLLECTIONS_PATHS', 'collections_paths', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
|
||||||
('ANSIBLE_ROLES_PATH', 'requirements_roles', '~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles')):
|
('ANSIBLE_ROLES_PATH', 'roles_path', 'requirements_roles', '~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles'))
|
||||||
|
|
||||||
|
config_values = read_ansible_config(job.project.get_project_path(), list(map(lambda x: x[1], path_vars)))
|
||||||
|
|
||||||
|
for env_key, config_setting, folder, default in path_vars:
|
||||||
paths = default.split(':')
|
paths = default.split(':')
|
||||||
if env_key in env:
|
if env_key in env:
|
||||||
for path in env[env_key].split(':'):
|
for path in env[env_key].split(':'):
|
||||||
if path not in paths:
|
if path not in paths:
|
||||||
paths = [env[env_key]] + paths
|
paths = [env[env_key]] + paths
|
||||||
|
elif config_setting in config_values:
|
||||||
|
for path in config_values[config_setting].split(':'):
|
||||||
|
if path not in paths:
|
||||||
|
paths = [config_values[config_setting]] + paths
|
||||||
paths = [os.path.join(private_data_dir, folder)] + paths
|
paths = [os.path.join(private_data_dir, folder)] + paths
|
||||||
env[env_key] = os.pathsep.join(paths)
|
env[env_key] = os.pathsep.join(paths)
|
||||||
|
|
||||||
@@ -1790,7 +1843,10 @@ class RunJob(BaseTask):
|
|||||||
|
|
||||||
if job.is_containerized:
|
if job.is_containerized:
|
||||||
from awx.main.scheduler.kubernetes import PodManager # prevent circular import
|
from awx.main.scheduler.kubernetes import PodManager # prevent circular import
|
||||||
PodManager(job).delete()
|
pm = PodManager(job)
|
||||||
|
logger.debug(f"Deleting pod {pm.pod_name}")
|
||||||
|
pm.delete()
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
inventory = job.inventory
|
inventory = job.inventory
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ class TestGenericRun():
|
|||||||
|
|
||||||
task = tasks.RunJob()
|
task = tasks.RunJob()
|
||||||
task.update_model = mock.Mock(return_value=job)
|
task.update_model = mock.Mock(return_value=job)
|
||||||
|
task.model.objects.get = mock.Mock(return_value=job)
|
||||||
task.build_private_data_files = mock.Mock(side_effect=OSError())
|
task.build_private_data_files = mock.Mock(side_effect=OSError())
|
||||||
|
|
||||||
with mock.patch('awx.main.tasks.copy_tree'):
|
with mock.patch('awx.main.tasks.copy_tree'):
|
||||||
@@ -387,6 +388,7 @@ class TestGenericRun():
|
|||||||
|
|
||||||
task = tasks.RunJob()
|
task = tasks.RunJob()
|
||||||
task.update_model = mock.Mock(wraps=update_model_wrapper)
|
task.update_model = mock.Mock(wraps=update_model_wrapper)
|
||||||
|
task.model.objects.get = mock.Mock(return_value=job)
|
||||||
task.build_private_data_files = mock.Mock()
|
task.build_private_data_files = mock.Mock()
|
||||||
|
|
||||||
with mock.patch('awx.main.tasks.copy_tree'):
|
with mock.patch('awx.main.tasks.copy_tree'):
|
||||||
@@ -578,6 +580,7 @@ class TestAdhocRun(TestJobExecution):
|
|||||||
|
|
||||||
task = tasks.RunAdHocCommand()
|
task = tasks.RunAdHocCommand()
|
||||||
task.update_model = mock.Mock(wraps=adhoc_update_model_wrapper)
|
task.update_model = mock.Mock(wraps=adhoc_update_model_wrapper)
|
||||||
|
task.model.objects.get = mock.Mock(return_value=adhoc_job)
|
||||||
task.build_inventory = mock.Mock()
|
task.build_inventory = mock.Mock()
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
import codecs
|
import codecs
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.main.utils.ansible')
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['skip_directory', 'could_be_playbook', 'could_be_inventory']
|
__all__ = ['skip_directory', 'could_be_playbook', 'could_be_inventory']
|
||||||
|
|
||||||
@@ -97,3 +101,20 @@ def could_be_inventory(project_path, dir_path, filename):
|
|||||||
except IOError:
|
except IOError:
|
||||||
return None
|
return None
|
||||||
return inventory_rel_path
|
return inventory_rel_path
|
||||||
|
|
||||||
|
|
||||||
|
def read_ansible_config(project_path, variables_of_interest):
|
||||||
|
fnames = ['/etc/ansible/ansible.cfg']
|
||||||
|
if project_path:
|
||||||
|
fnames.insert(0, os.path.join(project_path, 'ansible.cfg'))
|
||||||
|
values = {}
|
||||||
|
try:
|
||||||
|
parser = ConfigParser()
|
||||||
|
parser.read(fnames)
|
||||||
|
if 'defaults' in parser:
|
||||||
|
for var in variables_of_interest:
|
||||||
|
if var in parser['defaults']:
|
||||||
|
values[var] = parser['defaults'][var]
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Failed to read ansible configuration(s) {}'.format(fnames))
|
||||||
|
return values
|
||||||
|
|||||||
@@ -38,18 +38,22 @@ from django.apps import apps
|
|||||||
|
|
||||||
logger = logging.getLogger('awx.main.utils')
|
logger = logging.getLogger('awx.main.utils')
|
||||||
|
|
||||||
__all__ = ['get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelcase', 'memoize', 'memoize_delete',
|
__all__ = [
|
||||||
'get_ansible_version', 'get_ssh_version', 'get_licenser', 'get_awx_version', 'update_scm_url',
|
'get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelcase', 'memoize',
|
||||||
'get_type_for_model', 'get_model_for_type', 'copy_model_by_class', 'region_sorting',
|
'memoize_delete', 'get_ansible_version', 'get_ssh_version', 'get_licenser',
|
||||||
'copy_m2m_relationships', 'prefetch_page_capabilities', 'to_python_boolean',
|
'get_awx_version', 'update_scm_url', 'get_type_for_model', 'get_model_for_type',
|
||||||
'ignore_inventory_computed_fields', 'ignore_inventory_group_removal',
|
'copy_model_by_class', 'region_sorting', 'copy_m2m_relationships',
|
||||||
'_inventory_updates', 'get_pk_from_dict', 'getattrd', 'getattr_dne', 'NoDefaultProvided',
|
'prefetch_page_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields',
|
||||||
'get_current_apps', 'set_current_apps',
|
'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd',
|
||||||
'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity',
|
'getattr_dne', 'NoDefaultProvided', 'get_current_apps', 'set_current_apps',
|
||||||
'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict',
|
'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity',
|
||||||
'NullablePromptPseudoField', 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
|
'get_cpu_capacity', 'get_mem_capacity', 'wrap_args_with_proot', 'build_proot_temp_dir',
|
||||||
'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices', 'get_external_account',
|
'check_proot_installed', 'model_to_dict', 'NullablePromptPseudoField',
|
||||||
'task_manager_bulk_reschedule', 'schedule_task_manager', 'classproperty', 'create_temporary_fifo']
|
'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
|
||||||
|
'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError',
|
||||||
|
'get_custom_venv_choices', 'get_external_account', 'task_manager_bulk_reschedule',
|
||||||
|
'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_object_or_400(klass, *args, **kwargs):
|
def get_object_or_400(klass, *args, **kwargs):
|
||||||
@@ -1088,3 +1092,19 @@ def create_temporary_fifo(data):
|
|||||||
).start()
|
).start()
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_stdout(stdout, size):
|
||||||
|
from awx.main.constants import ANSI_SGR_PATTERN
|
||||||
|
|
||||||
|
if size <= 0 or len(stdout) <= size:
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
stdout = stdout[:(size - 1)] + u'\u2026'
|
||||||
|
set_count, reset_count = 0, 0
|
||||||
|
for m in ANSI_SGR_PATTERN.finditer(stdout):
|
||||||
|
if m.group() == u'\u001b[0m':
|
||||||
|
reset_count += 1
|
||||||
|
else:
|
||||||
|
set_count += 1
|
||||||
|
|
||||||
|
return stdout + u'\u001b[0m' * (set_count - reset_count)
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ import uuid
|
|||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from jinja2 import Environment
|
from jinja2 import Environment
|
||||||
from six import integer_types, PY3
|
|
||||||
from six.moves import configparser
|
from ansible.module_utils.six import integer_types, PY3
|
||||||
|
from ansible.module_utils.six.moves import configparser
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import argparse
|
import argparse
|
||||||
@@ -152,7 +153,7 @@ class VMWareInventory(object):
|
|||||||
try:
|
try:
|
||||||
text = str(text)
|
text = str(text)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
text = text.encode('ascii', 'ignore')
|
text = text.encode('utf-8')
|
||||||
print('%s %s' % (datetime.datetime.now(), text))
|
print('%s %s' % (datetime.datetime.now(), text))
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
@@ -186,14 +187,14 @@ class VMWareInventory(object):
|
|||||||
|
|
||||||
def write_to_cache(self, data):
|
def write_to_cache(self, data):
|
||||||
''' Dump inventory to json file '''
|
''' Dump inventory to json file '''
|
||||||
with open(self.cache_path_cache, 'wb') as f:
|
with open(self.cache_path_cache, 'w') as f:
|
||||||
f.write(json.dumps(data))
|
f.write(json.dumps(data, indent=2))
|
||||||
|
|
||||||
def get_inventory_from_cache(self):
|
def get_inventory_from_cache(self):
|
||||||
''' Read in jsonified inventory '''
|
''' Read in jsonified inventory '''
|
||||||
|
|
||||||
jdata = None
|
jdata = None
|
||||||
with open(self.cache_path_cache, 'rb') as f:
|
with open(self.cache_path_cache, 'r') as f:
|
||||||
jdata = f.read()
|
jdata = f.read()
|
||||||
return json.loads(jdata)
|
return json.loads(jdata)
|
||||||
|
|
||||||
@@ -343,10 +344,22 @@ class VMWareInventory(object):
|
|||||||
'pwd': self.password,
|
'pwd': self.password,
|
||||||
'port': int(self.port)}
|
'port': int(self.port)}
|
||||||
|
|
||||||
if hasattr(ssl, 'SSLContext') and not self.validate_certs:
|
if self.validate_certs and hasattr(ssl, 'SSLContext'):
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
|
||||||
|
context.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
context.check_hostname = True
|
||||||
|
kwargs['sslContext'] = context
|
||||||
|
elif self.validate_certs and not hasattr(ssl, 'SSLContext'):
|
||||||
|
sys.exit('pyVim does not support changing verification mode with python < 2.7.9. Either update '
|
||||||
|
'python or use validate_certs=false.')
|
||||||
|
elif not self.validate_certs and hasattr(ssl, 'SSLContext'):
|
||||||
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
|
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
context.check_hostname = False
|
||||||
kwargs['sslContext'] = context
|
kwargs['sslContext'] = context
|
||||||
|
elif not self.validate_certs and not hasattr(ssl, 'SSLContext'):
|
||||||
|
# Python 2.7.9 < or RHEL/CentOS 7.4 <
|
||||||
|
pass
|
||||||
|
|
||||||
return self._get_instances(kwargs)
|
return self._get_instances(kwargs)
|
||||||
|
|
||||||
@@ -390,7 +403,7 @@ class VMWareInventory(object):
|
|||||||
instances = [x for x in instances if x.name == self.args.host]
|
instances = [x for x in instances if x.name == self.args.host]
|
||||||
|
|
||||||
instance_tuples = []
|
instance_tuples = []
|
||||||
for instance in sorted(instances):
|
for instance in instances:
|
||||||
if self.guest_props:
|
if self.guest_props:
|
||||||
ifacts = self.facts_from_proplist(instance)
|
ifacts = self.facts_from_proplist(instance)
|
||||||
else:
|
else:
|
||||||
@@ -614,7 +627,14 @@ class VMWareInventory(object):
|
|||||||
lastref = lastref[x]
|
lastref = lastref[x]
|
||||||
else:
|
else:
|
||||||
lastref[x] = val
|
lastref[x] = val
|
||||||
|
if self.args.debug:
|
||||||
|
self.debugl("For %s" % vm.name)
|
||||||
|
for key in list(rdata.keys()):
|
||||||
|
if isinstance(rdata[key], dict):
|
||||||
|
for ikey in list(rdata[key].keys()):
|
||||||
|
self.debugl("Property '%s.%s' has value '%s'" % (key, ikey, rdata[key][ikey]))
|
||||||
|
else:
|
||||||
|
self.debugl("Property '%s' has value '%s'" % (key, rdata[key]))
|
||||||
return rdata
|
return rdata
|
||||||
|
|
||||||
def facts_from_vobj(self, vobj, level=0):
|
def facts_from_vobj(self, vobj, level=0):
|
||||||
@@ -685,7 +705,7 @@ class VMWareInventory(object):
|
|||||||
if vobj.isalnum():
|
if vobj.isalnum():
|
||||||
rdata = vobj
|
rdata = vobj
|
||||||
else:
|
else:
|
||||||
rdata = vobj.decode('ascii', 'ignore')
|
rdata = vobj.encode('utf-8').decode('utf-8')
|
||||||
elif issubclass(type(vobj), bool) or isinstance(vobj, bool):
|
elif issubclass(type(vobj), bool) or isinstance(vobj, bool):
|
||||||
rdata = vobj
|
rdata = vobj
|
||||||
elif issubclass(type(vobj), integer_types) or isinstance(vobj, integer_types):
|
elif issubclass(type(vobj), integer_types) or isinstance(vobj, integer_types):
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AWX_CONTAINER_GROUP_DEFAULT_LAUNCH_TIMEOUT = 10
|
AWX_CONTAINER_GROUP_K8S_API_TIMEOUT = 10
|
||||||
|
AWX_CONTAINER_GROUP_POD_LAUNCH_RETRIES = 100
|
||||||
|
AWX_CONTAINER_GROUP_POD_LAUNCH_RETRY_DELAY = 5
|
||||||
AWX_CONTAINER_GROUP_DEFAULT_NAMESPACE = 'default'
|
AWX_CONTAINER_GROUP_DEFAULT_NAMESPACE = 'default'
|
||||||
AWX_CONTAINER_GROUP_DEFAULT_IMAGE = 'ansible/ansible-runner'
|
AWX_CONTAINER_GROUP_DEFAULT_IMAGE = 'ansible/ansible-runner'
|
||||||
|
|
||||||
|
|||||||
@@ -372,9 +372,7 @@ table, tbody {
|
|||||||
|
|
||||||
.List-noItems {
|
.List-noItems {
|
||||||
margin-top: 52px;
|
margin-top: 52px;
|
||||||
display: flex;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -383,7 +381,7 @@ table, tbody {
|
|||||||
color: @list-no-items-txt;
|
color: @list-no-items-txt;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 10px;
|
padding: 80px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body > .List-noItems {
|
.modal-body > .List-noItems {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
height: calc(~"100vh - 80px");
|
height: calc(~"100vh - 80px");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px){
|
@media screen and (min-width: 768px){
|
||||||
.NetworkingExtraVars .modal-dialog{
|
.NetworkingExtraVars .modal-dialog{
|
||||||
width: 700px;
|
width: 700px;
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ function AtTabController ($state) {
|
|||||||
group.register(scope);
|
group.register(scope);
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.go = () => {
|
vm.handleClick = () => {
|
||||||
if (scope.state._disabled || scope.state._active) {
|
if (scope.state._disabled || scope.state._active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope.state._go) {
|
if (scope.state._go) {
|
||||||
$state.go(scope.state._go, scope.state._params, { reload: true });
|
$state.go(scope.state._go, scope.state._params, { reload: true });
|
||||||
} else {
|
return;
|
||||||
group.clearActive();
|
}
|
||||||
scope.state._active = true;
|
group.clearActive();
|
||||||
|
scope.state._active = true;
|
||||||
|
if (scope.state._onClickActivate) {
|
||||||
|
scope.state._onClickActivate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
ng-attr-disabled="{{ state._disabled || undefined }}"
|
ng-attr-disabled="{{ state._disabled || undefined }}"
|
||||||
ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }"
|
ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }"
|
||||||
ng-hide="{{ state._hide }}"
|
ng-hide="{{ state._hide }}"
|
||||||
ng-click="state._go && vm.go();">
|
ng-click="vm.handleClick();">
|
||||||
<ng-transclude></ng-transclude>
|
<ng-transclude></ng-transclude>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div ui-view="credentials"></div>
|
<div ui-view="credentials"></div>
|
||||||
<a class="containerGroups-messageBar-link"href="https://docs.ansible.com/ansible-tower/latest/html/userguide/instance_groups.html" target="_blank" style="color: white">
|
<a class="containerGroups-messageBar-link" href="https://docs.ansible.com/ansible-tower/latest/html/administration/external_execution_envs.html#container-group-considerations" target="_blank" style="color: white">
|
||||||
<div class="Section-messageBar">
|
<div class="Section-messageBar">
|
||||||
<i class="Section-messageBar-warning fa fa-warning"></i>
|
<i class="Section-messageBar-warning fa fa-warning"></i>
|
||||||
<span class="Section-messageBar-text">This feature is tech preview, and is subject to change in a future release. Click here for documentation.</span>
|
<span class="Section-messageBar-text">This feature is tech preview, and is subject to change in a future release. Click here for documentation.</span>
|
||||||
@@ -21,13 +21,15 @@
|
|||||||
{{ vm.form.extraVars.toggleLabel }}
|
{{ vm.form.extraVars.toggleLabel }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div ng-class="{'ContainerGroups-codeMirror': vm.form.extraVars.isOpen }">
|
<div ng-disabled="{{vm.form.extraVars.disabled}}" ng-class="{'ContainerGroups-codeMirror': vm.form.extraVars.isOpen }">
|
||||||
<at-switch on-toggle="vm.toggle(instance)" switch-on="vm.form.extraVars.isOpen"
|
<at-switch on-toggle="vm.toggle(instance)" switch-on="vm.form.extraVars.isOpen"
|
||||||
switch-disabled="vm.rowAction.toggle._disabled"></at-switch>
|
switch-disabled="vm.switchDisabled"></at-switch>
|
||||||
</div>
|
</div>
|
||||||
<at-code-mirror
|
<at-code-mirror
|
||||||
|
ng-disabled="{{vm.form.extraVars.disabled}}"
|
||||||
ng-if="vm.form.extraVars.isOpen"
|
ng-if="vm.form.extraVars.isOpen"
|
||||||
col="4" tab="3"
|
col="4" tab="3"
|
||||||
|
ng-class="{'containerGroups-codeMirror-disabled': vm.form.extraVars.disabled}"
|
||||||
class="Form-formGroup--fullWidth"
|
class="Form-formGroup--fullWidth"
|
||||||
variables="vm.form.extraVars.value"
|
variables="vm.form.extraVars.value"
|
||||||
label="{{ vm.form.extraVars.label }}"
|
label="{{ vm.form.extraVars.label }}"
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
|
|||||||
instanceGroup,
|
instanceGroup,
|
||||||
credential
|
credential
|
||||||
} = models;
|
} = models;
|
||||||
|
let canEdit = false;
|
||||||
|
if (instanceGroup.has('options', 'actions.PUT')) {
|
||||||
|
canEdit = instanceGroup.model.OPTIONS.actions.PUT;
|
||||||
|
}
|
||||||
if (!instanceGroup.get('is_containerized')) {
|
if (!instanceGroup.get('is_containerized')) {
|
||||||
return $state.go(
|
return $state.go(
|
||||||
'instanceGroups.edit',
|
'instanceGroups.edit',
|
||||||
@@ -21,6 +24,8 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
|
|||||||
vm.lookUpTitle = strings.get('container.LOOK_UP_TITLE');
|
vm.lookUpTitle = strings.get('container.LOOK_UP_TITLE');
|
||||||
|
|
||||||
vm.form = instanceGroup.createFormSchema('post');
|
vm.form = instanceGroup.createFormSchema('post');
|
||||||
|
vm.switchDisabled = false;
|
||||||
|
vm.form.disabled = !instanceGroup.has('options', 'actions.PUT');
|
||||||
vm.form.name.required = true;
|
vm.form.name.required = true;
|
||||||
vm.form.credential = {
|
vm.form.credential = {
|
||||||
type: 'field',
|
type: 'field',
|
||||||
@@ -48,14 +53,23 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
|
|||||||
_go: 'instanceGroups.containerGroupJobs',
|
_go: 'instanceGroups.containerGroupJobs',
|
||||||
_params: { instance_group_id: instanceGroup.get('id') }
|
_params: { instance_group_id: instanceGroup.get('id') }
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
vm.form.extraVars = {
|
|
||||||
label: strings.get('container.POD_SPEC_LABEL'),
|
|
||||||
value: EditContainerGroupDataset.data.pod_spec_override || instanceGroup.model.OPTIONS.actions.PUT.pod_spec_override.default,
|
|
||||||
name: 'extraVars',
|
|
||||||
toggleLabel: strings.get('container.POD_SPEC_TOGGLE')
|
|
||||||
};
|
};
|
||||||
|
if (!canEdit) {
|
||||||
|
vm.form.extraVars = {
|
||||||
|
label: strings.get('container.POD_SPEC_LABEL'),
|
||||||
|
value: EditContainerGroupDataset.data.pod_spec_override || "---",
|
||||||
|
name: 'extraVars',
|
||||||
|
disabled: true
|
||||||
|
};
|
||||||
|
vm.switchDisabled = true;
|
||||||
|
} else {
|
||||||
|
vm.form.extraVars = {
|
||||||
|
label: strings.get('container.POD_SPEC_LABEL'),
|
||||||
|
value: EditContainerGroupDataset.data.pod_spec_override || instanceGroup.model.OPTIONS.actions.PUT.pod_spec_override.default,
|
||||||
|
name: 'extraVars',
|
||||||
|
toggleLabel: strings.get('container.POD_SPEC_TOGGLE')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeVars (str) {
|
function sanitizeVars (str) {
|
||||||
// Quick function to test if the host vars are a json-object-string,
|
// Quick function to test if the host vars are a json-object-string,
|
||||||
@@ -90,7 +104,7 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const podSpecValue = sanitizeVars(EditContainerGroupDataset.data.pod_spec_override);
|
const podSpecValue = sanitizeVars(EditContainerGroupDataset.data.pod_spec_override);
|
||||||
const defaultPodSpecValue = sanitizeVars(instanceGroup.model.OPTIONS.actions.PUT.pod_spec_override.default);
|
const defaultPodSpecValue = canEdit ? sanitizeVars(instanceGroup.model.OPTIONS.actions.PUT.pod_spec_override.default) : '---';
|
||||||
|
|
||||||
if ((podSpecValue !== '---') && podSpecValue && podSpecValue.trim() !== defaultPodSpecValue.trim()) {
|
if ((podSpecValue !== '---') && podSpecValue && podSpecValue.trim() !== defaultPodSpecValue.trim()) {
|
||||||
vm.form.extraVars.isOpen = true;
|
vm.form.extraVars.isOpen = true;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
.at-Row-container{
|
.at-Row-container{
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerGroups-messageBar-link:hover{
|
.containerGroups-messageBar-link:hover{
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="at-Row-actions" >
|
<div class="at-Row-actions" >
|
||||||
<capacity-bar ng-show="!instance_group.credential" label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_USED_CAPACITY') }}" capacity="instance_group.consumed_capacity" total-capacity="instance_group.capacity"></capacity-bar>
|
<capacity-bar ng-show="!instance_group.credential" label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_USED_CAPACITY') }}" capacity="instance_group.consumed_capacity" total-capacity="instance_group.capacity"></capacity-bar>
|
||||||
<at-row-action ng-class="{'at-Row-actions-noCredential': !instance_group.credential}" icon="fa-trash" ng-click="vm.deleteInstanceGroup(instance_group)" ng-if="vm.rowAction.trash(instance_group)">
|
<at-row-action icon="fa-trash" ng-click="vm.deleteInstanceGroup(instance_group)" ng-if="vm.rowAction.trash(instance_group)">
|
||||||
</at-row-action>
|
</at-row-action>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,10 +20,11 @@
|
|||||||
flex: 1 0;
|
flex: 1 0;
|
||||||
height: @height;
|
height: @height;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
@media screen and (max-width: @breakpoint){
|
@media screen and (max-width: @breakpoint){
|
||||||
margin-right: 0px;
|
height: inherit;
|
||||||
height: inherit;
|
margin-right: 0px;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ export default [ 'ProcessErrors', 'CredentialTypeModel', 'TemplatesStrings', '$f
|
|||||||
modal.show($filter('sanitize')(vm.promptDataClone.templateName));
|
modal.show($filter('sanitize')(vm.promptDataClone.templateName));
|
||||||
vm.promptData.triggerModalOpen = false;
|
vm.promptData.triggerModalOpen = false;
|
||||||
|
|
||||||
|
vm._savedPromptData = {
|
||||||
|
1: _.cloneDeep(vm.promptDataClone)
|
||||||
|
};
|
||||||
|
Object.keys(vm.steps).forEach(step => {
|
||||||
|
if (!vm.steps[step].tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vm.steps[step].tab._onClickActivate = () => {
|
||||||
|
if (vm._savedPromptData[vm.steps[step].tab.order]) {
|
||||||
|
vm.promptDataClone = vm._savedPromptData[vm.steps[step].tab.order];
|
||||||
|
}
|
||||||
|
Object.keys(vm.steps).forEach(tabStep => {
|
||||||
|
if (!vm.steps[tabStep].tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (vm.steps[tabStep].tab.order < vm.steps[step].tab.order) {
|
||||||
|
vm.steps[tabStep].tab._disabled = false;
|
||||||
|
vm.steps[tabStep].tab._active = false;
|
||||||
|
} else if (vm.steps[tabStep].tab.order === vm.steps[step].tab.order) {
|
||||||
|
vm.steps[tabStep].tab._disabled = false;
|
||||||
|
vm.steps[tabStep].tab._active = true;
|
||||||
|
} else {
|
||||||
|
vm.steps[tabStep].tab._disabled = true;
|
||||||
|
vm.steps[tabStep].tab._active = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scope.$broadcast('promptTabChange', { step });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
modal.onClose = () => {
|
modal.onClose = () => {
|
||||||
scope.$emit('launchModalOpen', false);
|
scope.$emit('launchModalOpen', false);
|
||||||
};
|
};
|
||||||
@@ -214,19 +244,39 @@ export default [ 'ProcessErrors', 'CredentialTypeModel', 'TemplatesStrings', '$f
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nextStep;
|
||||||
Object.keys(vm.steps).forEach(step => {
|
Object.keys(vm.steps).forEach(step => {
|
||||||
if(vm.steps[step].tab) {
|
if (!vm.steps[step].tab) {
|
||||||
if(vm.steps[step].tab.order === currentTab.order) {
|
return;
|
||||||
vm.steps[step].tab._active = false;
|
}
|
||||||
vm.steps[step].tab._disabled = true;
|
if (vm.steps[step].tab.order === currentTab.order + 1) {
|
||||||
} else if(vm.steps[step].tab.order === currentTab.order + 1) {
|
nextStep = step;
|
||||||
activeTab = currentTab;
|
|
||||||
vm.steps[step].tab._active = true;
|
|
||||||
vm.steps[step].tab._disabled = false;
|
|
||||||
scope.$broadcast('promptTabChange', { step });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!nextStep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the current promptData state in case we need to revert
|
||||||
|
vm._savedPromptData[currentTab.order] = _.cloneDeep(vm.promptDataClone);
|
||||||
|
Object.keys(vm.steps).forEach(tabStep => {
|
||||||
|
if (!vm.steps[tabStep].tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (vm.steps[tabStep].tab.order < vm.steps[nextStep].tab.order) {
|
||||||
|
vm.steps[tabStep].tab._disabled = false;
|
||||||
|
vm.steps[tabStep].tab._active = false;
|
||||||
|
} else if (vm.steps[tabStep].tab.order === vm.steps[nextStep].tab.order) {
|
||||||
|
vm.steps[tabStep].tab._disabled = false;
|
||||||
|
vm.steps[tabStep].tab._active = true;
|
||||||
|
} else {
|
||||||
|
vm.steps[tabStep].tab._disabled = true;
|
||||||
|
vm.steps[tabStep].tab._active = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scope.$broadcast('promptTabChange', { step: nextStep });
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.keypress = (event) => {
|
vm.keypress = (event) => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
read-only-prompts="vm.readOnlyPrompts">
|
read-only-prompts="vm.readOnlyPrompts">
|
||||||
</prompt-credential>
|
</prompt-credential>
|
||||||
</div>
|
</div>
|
||||||
<div ng-if="vm.steps.other_prompts.includeStep" ng-show="vm.steps.other_prompts.tab._active" id="prompt_other_prompts_step">
|
<div ng-if="vm.steps.other_prompts.includeStep && vm.steps.other_prompts.tab._active" id="prompt_other_prompts_step">
|
||||||
<prompt-other-prompts
|
<prompt-other-prompts
|
||||||
prompt-data="vm.promptDataClone"
|
prompt-data="vm.promptDataClone"
|
||||||
other-prompts-form="vm.forms.otherPrompts"
|
other-prompts-form="vm.forms.otherPrompts"
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ EXAMPLES = '''
|
|||||||
|
|
||||||
# Example for using tower_inventory.yml file
|
# Example for using tower_inventory.yml file
|
||||||
|
|
||||||
plugin: tower
|
plugin: awx.awx.tower
|
||||||
host: your_ansible_tower_server_network_address
|
host: your_ansible_tower_server_network_address
|
||||||
username: your_ansible_tower_username
|
username: your_ansible_tower_username
|
||||||
password: your_ansible_tower_password
|
password: your_ansible_tower_password
|
||||||
@@ -116,7 +116,7 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
class InventoryModule(BaseInventoryPlugin):
|
class InventoryModule(BaseInventoryPlugin):
|
||||||
NAME = 'tower'
|
NAME = 'awx.awx.tower' # REPLACE
|
||||||
# Stays backward compatible with tower inventory script.
|
# Stays backward compatible with tower inventory script.
|
||||||
# If the user supplies '@tower_inventory' as path, the plugin will read from environment variables.
|
# If the user supplies '@tower_inventory' as path, the plugin will read from environment variables.
|
||||||
no_config_file_supplied = False
|
no_config_file_supplied = False
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
regexp: '^extends_documentation_fragment: awx.awx.auth$'
|
regexp: '^extends_documentation_fragment: awx.awx.auth$'
|
||||||
replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth'
|
replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth'
|
||||||
with_items: "{{ module_files.files }}"
|
with_items: "{{ module_files.files }}"
|
||||||
|
|
||||||
|
- name: Change files to support desired namespace and package names
|
||||||
|
replace:
|
||||||
|
path: "{{ playbook_dir }}/plugins/inventory/tower.py"
|
||||||
|
regexp: "^ NAME = 'awx.awx.tower' # REPLACE$"
|
||||||
|
replace: " NAME = '{{ collection_namespace }}.{{ collection_package }}.tower' # REPLACE"
|
||||||
when:
|
when:
|
||||||
- (collection_package != 'awx') or (collection_namespace != 'awx')
|
- (collection_package != 'awx') or (collection_namespace != 'awx')
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class AssociationMixin(object):
|
|||||||
def __init__(self, connection, resource):
|
def __init__(self, connection, resource):
|
||||||
self.conn = connection
|
self.conn = connection
|
||||||
self.resource = {
|
self.resource = {
|
||||||
|
'approval_notification': 'notification_templates',
|
||||||
'start_notification': 'notification_templates',
|
'start_notification': 'notification_templates',
|
||||||
'success_notification': 'notification_templates',
|
'success_notification': 'notification_templates',
|
||||||
'failure_notification': 'notification_templates',
|
'failure_notification': 'notification_templates',
|
||||||
@@ -299,11 +300,21 @@ JobTemplateNotificationDisAssociation.targets.update({
|
|||||||
class WorkflowJobTemplateNotificationAssociation(NotificationAssociateMixin, CustomAction):
|
class WorkflowJobTemplateNotificationAssociation(NotificationAssociateMixin, CustomAction):
|
||||||
resource = 'workflow_job_templates'
|
resource = 'workflow_job_templates'
|
||||||
action = 'associate'
|
action = 'associate'
|
||||||
|
targets = NotificationAssociateMixin.targets.copy()
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobTemplateNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
|
class WorkflowJobTemplateNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
|
||||||
resource = 'workflow_job_templates'
|
resource = 'workflow_job_templates'
|
||||||
action = 'disassociate'
|
action = 'disassociate'
|
||||||
|
targets = NotificationAssociateMixin.targets.copy()
|
||||||
|
|
||||||
|
|
||||||
|
WorkflowJobTemplateNotificationAssociation.targets.update({
|
||||||
|
'approval_notification': ['notification_templates_approvals', 'notification_template'],
|
||||||
|
})
|
||||||
|
WorkflowJobTemplateNotificationDisAssociation.targets.update({
|
||||||
|
'approval_notification': ['notification_templates_approvals', 'notification_template'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ProjectNotificationAssociation(NotificationAssociateMixin, CustomAction):
|
class ProjectNotificationAssociation(NotificationAssociateMixin, CustomAction):
|
||||||
@@ -329,11 +340,21 @@ class InventorySourceNotificationDisAssociation(NotificationAssociateMixin, Cust
|
|||||||
class OrganizationNotificationAssociation(NotificationAssociateMixin, CustomAction):
|
class OrganizationNotificationAssociation(NotificationAssociateMixin, CustomAction):
|
||||||
resource = 'organizations'
|
resource = 'organizations'
|
||||||
action = 'associate'
|
action = 'associate'
|
||||||
|
targets = NotificationAssociateMixin.targets.copy()
|
||||||
|
|
||||||
|
|
||||||
class OrganizationNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
|
class OrganizationNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
|
||||||
resource = 'organizations'
|
resource = 'organizations'
|
||||||
action = 'disassociate'
|
action = 'disassociate'
|
||||||
|
targets = NotificationAssociateMixin.targets.copy()
|
||||||
|
|
||||||
|
|
||||||
|
OrganizationNotificationAssociation.targets.update({
|
||||||
|
'approval_notification': ['notification_templates_approvals', 'notification_template'],
|
||||||
|
})
|
||||||
|
OrganizationNotificationDisAssociation.targets.update({
|
||||||
|
'approval_notification': ['notification_templates_approvals', 'notification_template'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class SettingsList(CustomAction):
|
class SettingsList(CustomAction):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
AWX Command Line Interface
|
AWX Command Line Interface
|
||||||
==========================
|
==========================
|
||||||
|
|
||||||
awx is the official command-line client for AWX. It:
|
`awx` is the official command-line client for AWX. It:
|
||||||
|
|
||||||
* Uses naming and structure consistent with the AWX HTTP API
|
* Uses naming and structure consistent with the AWX HTTP API
|
||||||
* Provides consistent output formats with optional machine-parsable formats
|
* Provides consistent output formats with optional machine-parsable formats
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class ResourceOptionsParser(object):
|
|||||||
|
|
||||||
def get_allowed_options(self):
|
def get_allowed_options(self):
|
||||||
self.allowed_options = self.page.connection.options(
|
self.allowed_options = self.page.connection.options(
|
||||||
self.page.endpoint + '1'
|
self.page.endpoint + '1/'
|
||||||
).headers.get('Allow', '').split(', ')
|
).headers.get('Allow', '').split(', ')
|
||||||
|
|
||||||
def build_list_actions(self):
|
def build_list_actions(self):
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def monitor_workflow(response, session, print_stdout=True, timeout=None,
|
|||||||
|
|
||||||
def monitor(response, session, print_stdout=True, timeout=None, interval=.25):
|
def monitor(response, session, print_stdout=True, timeout=None, interval=.25):
|
||||||
get = response.url.get
|
get = response.url.get
|
||||||
payload = {'order_by': 'start_line'}
|
payload = {'order_by': 'start_line', 'no_truncate': True}
|
||||||
if response.type == 'job':
|
if response.type == 'job':
|
||||||
events = response.related.job_events.get
|
events = response.related.job_events.get
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user