Merge branch 'downstream' into devel

This commit is contained in:
Ryan Petrello
2019-10-29 11:25:26 -04:00
93 changed files with 1589 additions and 596 deletions

View File

@@ -4338,13 +4338,30 @@ class NotificationTemplateSerializer(BaseSerializer):
error_list = []
collected_messages = []
def check_messages(messages):
for message_type in messages:
if message_type not in ('message', 'body'):
error_list.append(_("Message type '{}' invalid, must be either 'message' or 'body'").format(message_type))
continue
message = messages[message_type]
if message is None:
continue
if not isinstance(message, str):
error_list.append(_("Expected string for '{}', found {}, ").format(message_type, type(message)))
continue
if message_type == 'message':
if '\n' in message:
error_list.append(_("Messages cannot contain newlines (found newline in {} event)".format(event)))
continue
collected_messages.append(message)
# Validate structure / content types
if not isinstance(messages, dict):
error_list.append(_("Expected dict for 'messages' field, found {}".format(type(messages))))
else:
for event in messages:
if event not in ['started', 'success', 'error']:
error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', or 'error'").format(event))
if event not in ('started', 'success', 'error', 'workflow_approval'):
error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', 'error', or 'workflow_approval'").format(event))
continue
event_messages = messages[event]
if event_messages is None:
@@ -4352,21 +4369,21 @@ class NotificationTemplateSerializer(BaseSerializer):
if not isinstance(event_messages, dict):
error_list.append(_("Expected dict for event '{}', found {}").format(event, type(event_messages)))
continue
for message_type in event_messages:
if message_type not in ['message', 'body']:
error_list.append(_("Message type '{}' invalid, must be either 'message' or 'body'").format(message_type))
continue
message = event_messages[message_type]
if message is None:
continue
if not isinstance(message, str):
error_list.append(_("Expected string for '{}', found {}, ").format(message_type, type(message)))
continue
if message_type == 'message':
if '\n' in message:
error_list.append(_("Messages cannot contain newlines (found newline in {} event)".format(event)))
if event == 'workflow_approval':
for subevent in event_messages:
if subevent not in ('running', 'approved', 'timed_out', 'denied'):
error_list.append(_("Workflow Approval event '{}' invalid, must be one of "
"'running', 'approved', 'timed_out', or 'denied'").format(subevent))
continue
collected_messages.append(message)
subevent_messages = event_messages[subevent]
if subevent_messages is None:
continue
if not isinstance(subevent_messages, dict):
error_list.append(_("Expected dict for workflow approval event '{}', found {}").format(subevent, type(subevent_messages)))
continue
check_messages(subevent_messages)
else:
check_messages(event_messages)
# Subclass to return name of undefined field
class DescriptiveUndefined(StrictUndefined):
@@ -4497,8 +4514,18 @@ class NotificationSerializer(BaseSerializer):
'notification_type', 'recipients', 'subject', 'body')
def get_body(self, obj):
if obj.notification_type == 'webhook' and 'body' in obj.body:
return obj.body['body']
if obj.notification_type in ('webhook', 'pagerduty'):
if isinstance(obj.body, dict):
if 'body' in obj.body:
return obj.body['body']
elif isinstance(obj.body, str):
# attempt to load json string
try:
potential_body = json.loads(obj.body)
if isinstance(potential_body, dict):
return potential_body
except json.JSONDecodeError:
pass
return obj.body
def get_related(self, obj):
@@ -4774,6 +4801,18 @@ class InstanceGroupSerializer(BaseSerializer):
raise serializers.ValidationError(_('Isolated instances may not be added or removed from instances groups via the API.'))
if self.instance and self.instance.controller_id is not None:
raise serializers.ValidationError(_('Isolated instance group membership may not be managed via the API.'))
if value and self.instance and self.instance.is_containerized:
raise serializers.ValidationError(_('Containerized instances may not be managed via the API'))
return value
def validate_policy_instance_percentage(self, value):
if value and self.instance and self.instance.is_containerized:
raise serializers.ValidationError(_('Containerized instances may not be managed via the API'))
return value
def validate_policy_instance_minimum(self, value):
if value and self.instance and self.instance.is_containerized:
raise serializers.ValidationError(_('Containerized instances may not be managed via the API'))
return value
def validate_name(self, value):

View File

@@ -102,7 +102,7 @@ from awx.main.scheduler.dag_workflow import WorkflowDAG
from awx.api.views.mixin import (
ControlledByScmMixin, InstanceGroupMembershipMixin,
OrganizationCountsMixin, RelatedJobsPreventDeleteMixin,
UnifiedJobDeletionMixin,
UnifiedJobDeletionMixin, NoTruncateMixin,
)
from awx.api.views.organization import ( # noqa
OrganizationList,
@@ -383,6 +383,13 @@ class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAP
serializer_class = serializers.InstanceGroupSerializer
permission_classes = (InstanceGroupTowerPermission,)
def update_raw_data(self, data):
if self.get_object().is_containerized:
data.pop('policy_instance_percentage', None)
data.pop('policy_instance_minimum', None)
data.pop('policy_instance_list', None)
return super(InstanceGroupDetail, self).update_raw_data(data)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.controller is not None:
@@ -2136,12 +2143,21 @@ class InventorySourceHostsList(HostRelatedSearchMixin, SubListDestroyAPIView):
def perform_list_destroy(self, instance_list):
inv_source = self.get_parent_object()
with ignore_inventory_computed_fields():
# Activity stream doesn't record disassociation here anyway
# no signals-related reason to not bulk-delete
models.Host.groups.through.objects.filter(
host__inventory_sources=inv_source
).delete()
r = super(InventorySourceHostsList, self).perform_list_destroy(instance_list)
if not settings.ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC:
from awx.main.signals import disable_activity_stream
with disable_activity_stream():
# job host summary deletion necessary to avoid deadlock
models.JobHostSummary.objects.filter(host__inventory_sources=inv_source).update(host=None)
models.Host.objects.filter(inventory_sources=inv_source).delete()
r = super(InventorySourceHostsList, self).perform_list_destroy([])
else:
# Advance delete of group-host memberships to prevent deadlock
# Activity stream doesn't record disassociation here anyway
# no signals-related reason to not bulk-delete
models.Host.groups.through.objects.filter(
host__inventory_sources=inv_source
).delete()
r = super(InventorySourceHostsList, self).perform_list_destroy(instance_list)
update_inventory_computed_fields.delay(inv_source.inventory_id, True)
return r
@@ -2157,11 +2173,18 @@ class InventorySourceGroupsList(SubListDestroyAPIView):
def perform_list_destroy(self, instance_list):
inv_source = self.get_parent_object()
with ignore_inventory_computed_fields():
# Same arguments for bulk delete as with host list
models.Group.hosts.through.objects.filter(
group__inventory_sources=inv_source
).delete()
r = super(InventorySourceGroupsList, self).perform_list_destroy(instance_list)
if not settings.ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC:
from awx.main.signals import disable_activity_stream
with disable_activity_stream():
models.Group.objects.filter(inventory_sources=inv_source).delete()
r = super(InventorySourceGroupsList, self).perform_list_destroy([])
else:
# Advance delete of group-host memberships to prevent deadlock
# Same arguments for bulk delete as with host list
models.Group.hosts.through.objects.filter(
group__inventory_sources=inv_source
).delete()
r = super(InventorySourceGroupsList, self).perform_list_destroy(instance_list)
update_inventory_computed_fields.delay(inv_source.inventory_id, True)
return r
@@ -3762,18 +3785,12 @@ class JobHostSummaryDetail(RetrieveAPIView):
serializer_class = serializers.JobHostSummarySerializer
class JobEventList(ListAPIView):
class JobEventList(NoTruncateMixin, ListAPIView):
model = models.JobEvent
serializer_class = serializers.JobEventSerializer
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):
@@ -3786,7 +3803,7 @@ class JobEventDetail(RetrieveAPIView):
return context
class JobEventChildrenList(SubListAPIView):
class JobEventChildrenList(NoTruncateMixin, SubListAPIView):
model = models.JobEvent
serializer_class = serializers.JobEventSerializer
@@ -3811,7 +3828,7 @@ class JobEventHostsList(HostRelatedSearchMixin, SubListAPIView):
name = _('Job Event Hosts List')
class BaseJobEventsList(SubListAPIView):
class BaseJobEventsList(NoTruncateMixin, SubListAPIView):
model = models.JobEvent
serializer_class = serializers.JobEventSerializer
@@ -4007,18 +4024,12 @@ class AdHocCommandRelaunch(GenericAPIView):
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
class AdHocCommandEventList(ListAPIView):
class AdHocCommandEventList(NoTruncateMixin, ListAPIView):
model = models.AdHocCommandEvent
serializer_class = serializers.AdHocCommandEventSerializer
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):
@@ -4031,7 +4042,7 @@ class AdHocCommandEventDetail(RetrieveAPIView):
return context
class BaseAdHocCommandEventsList(SubListAPIView):
class BaseAdHocCommandEventsList(NoTruncateMixin, SubListAPIView):
model = models.AdHocCommandEvent
serializer_class = serializers.AdHocCommandEventSerializer
@@ -4297,8 +4308,15 @@ class NotificationTemplateTest(GenericAPIView):
def post(self, request, *args, **kwargs):
obj = self.get_object()
notification = obj.generate_notification("Tower Notification Test {} {}".format(obj.id, settings.TOWER_URL_BASE),
{"body": "Ansible Tower Test Notification {} {}".format(obj.id, settings.TOWER_URL_BASE)})
msg = "Tower Notification Test {} {}".format(obj.id, settings.TOWER_URL_BASE)
if obj.notification_type in ('email', 'pagerduty'):
body = "Ansible Tower Test Notification {} {}".format(obj.id, settings.TOWER_URL_BASE)
elif obj.notification_type == 'webhook':
body = '{{"body": "Ansible Tower Test Notification {} {}"}}'.format(obj.id, settings.TOWER_URL_BASE)
else:
body = {"body": "Ansible Tower Test Notification {} {}".format(obj.id, settings.TOWER_URL_BASE)}
notification = obj.generate_notification(msg, body)
if not notification:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
else:

View File

@@ -270,3 +270,11 @@ class ControlledByScmMixin(object):
obj = super(ControlledByScmMixin, self).get_parent_object()
self._reset_inv_src_rev(obj)
return obj
class NoTruncateMixin(object):
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

View File

@@ -1,6 +1,5 @@
from hashlib import sha1
import hmac
import json
import logging
import urllib.parse
@@ -151,13 +150,13 @@ class WebhookReceiverBase(APIView):
'webhook_credential': obj.webhook_credential,
'webhook_guid': event_guid,
},
'extra_vars': json.dumps({
'extra_vars': {
'tower_webhook_event_type': event_type,
'tower_webhook_event_guid': event_guid,
'tower_webhook_event_ref': event_ref,
'tower_webhook_status_api': status_api,
'tower_webhook_payload': request.data,
})
}
}
new_job = obj.create_unified_job(**kwargs)

View File

@@ -465,7 +465,7 @@ class BaseAccess(object):
else:
relationship = 'members'
return access_method(obj, parent_obj, relationship, skip_sub_obj_read_check=True, data={})
except (ParseError, ObjectDoesNotExist):
except (ParseError, ObjectDoesNotExist, PermissionDenied):
return False
return False
@@ -1660,26 +1660,19 @@ class JobAccess(BaseAccess):
except JobLaunchConfig.DoesNotExist:
config = None
if obj.job_template and (self.user not in obj.job_template.execute_role):
return False
# Check if JT execute access (and related prompts) is sufficient
if obj.job_template is not None:
if config is None:
prompts_access = False
elif not config.has_user_prompts(obj.job_template):
prompts_access = True
elif obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data):
prompts_access = False
if self.save_messages:
self.messages['detail'] = _('Job was launched with secret prompts provided by another user.')
else:
prompts_access = (
JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}) and
not config.has_unprompted(obj.job_template)
)
jt_access = self.user in obj.job_template.execute_role
if prompts_access and jt_access:
if config and obj.job_template:
if not config.has_user_prompts(obj.job_template):
return True
elif not jt_access:
return False
elif obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data):
# never allowed, not even for org admins
raise PermissionDenied(_('Job was launched with secret prompts provided by another user.'))
elif not config.has_unprompted(obj.job_template):
if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
return True
org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role
project_access = obj.project is None or self.user in obj.project.admin_role
@@ -2098,23 +2091,20 @@ class WorkflowJobAccess(BaseAccess):
self.messages['detail'] = _('Workflow Job was launched with unknown prompts.')
return False
# execute permission to WFJT is mandatory for any relaunch
if self.user not in template.execute_role:
return False
# Check if access to prompts to prevent relaunch
if config.prompts_dict():
if obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data):
if self.save_messages:
self.messages['detail'] = _('Job was launched with secret prompts provided by another user.')
return False
raise PermissionDenied(_("Job was launched with secret prompts provided by another user."))
if not JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
if self.save_messages:
self.messages['detail'] = _('Job was launched with prompts you lack access to.')
return False
raise PermissionDenied(_('Job was launched with prompts you lack access to.'))
if config.has_unprompted(template):
if self.save_messages:
self.messages['detail'] = _('Job was launched with prompts no longer accepted.')
return False
raise PermissionDenied(_('Job was launched with prompts no longer accepted.'))
# execute permission to WFJT is mandatory for any relaunch
return (self.user in template.execute_role)
return True # passed config checks
def can_recreate(self, obj):
node_qs = obj.workflow_job_nodes.all().prefetch_related('inventory', 'credentials', 'unified_job_template')

View File

@@ -513,6 +513,16 @@ register(
category_slug='jobs'
)
register(
'PUBLIC_GALAXY_ENABLED',
field_class=fields.BooleanField,
default=True,
label=_('Allow Access to Public Galaxy'),
help_text=_('Allow or deny access to the public Ansible Galaxy during project updates.'),
category=_('Jobs'),
category_slug='jobs'
)
register(
'STDOUT_MAX_BYTES_DISPLAY',
field_class=fields.IntegerField,

View File

@@ -4,6 +4,7 @@ import importlib
import sys
import traceback
from kubernetes.config import kube_config
from awx.main.tasks import dispatch_startup, inform_cluster_of_shutdown
@@ -107,6 +108,14 @@ class TaskWorker(BaseWorker):
for callback in body.get('errbacks', []) or []:
callback['uuid'] = body['uuid']
self.perform_work(callback)
finally:
# It's frustrating that we have to do this, but the python k8s
# client leaves behind cacert files in /tmp, so we must clean up
# the tmpdir per-dispatcher process every time a new task comes in
try:
kube_config._cleanup_temp_files()
except Exception:
logger.exception('failed to cleanup k8s client tmp files')
for callback in body.get('callbacks', []) or []:
callback['uuid'] = body['uuid']

View File

@@ -6,6 +6,7 @@ import stat
import tempfile
import time
import logging
import yaml
from django.conf import settings
import ansible_runner
@@ -48,10 +49,17 @@ class IsolatedManager(object):
def build_inventory(self, hosts):
if self.instance and self.instance.is_containerized:
inventory = {'all': {'hosts': {}}}
fd, path = tempfile.mkstemp(
prefix='.kubeconfig', dir=self.private_data_dir
)
with open(path, 'wb') as temp:
temp.write(yaml.dump(self.pod_manager.kube_config).encode())
temp.flush()
os.chmod(temp.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
for host in hosts:
inventory['all']['hosts'][host] = {
"ansible_connection": "kubectl",
"ansible_kubectl_config": self.pod_manager.kube_config
"ansible_kubectl_config": path,
}
else:
inventory = '\n'.join([
@@ -143,6 +151,8 @@ class IsolatedManager(object):
'- /artifacts/job_events/*-partial.json.tmp',
# don't rsync the ssh_key FIFO
'- /env/ssh_key',
# don't rsync kube config files
'- .kubeconfig*'
]
for filename, data in (

View File

@@ -295,7 +295,10 @@ class PrimordialModel(HasEditsMixin, CreatedModifiedModel):
def __init__(self, *args, **kwargs):
r = super(PrimordialModel, self).__init__(*args, **kwargs)
self._prior_values_store = self._get_fields_snapshot()
if self.pk:
self._prior_values_store = self._get_fields_snapshot()
else:
self._prior_values_store = {}
return r
def save(self, *args, **kwargs):

View File

@@ -73,7 +73,7 @@ class NotificationTemplate(CommonModelNameNotUnique):
notification_configuration = prevent_search(JSONField(blank=False))
def default_messages():
return {'started': None, 'success': None, 'error': None}
return {'started': None, 'success': None, 'error': None, 'workflow_approval': None}
messages = JSONField(
null=True,
@@ -92,25 +92,6 @@ class NotificationTemplate(CommonModelNameNotUnique):
def get_message(self, condition):
return self.messages.get(condition, {})
def build_notification_message(self, event_type, context):
env = sandbox.ImmutableSandboxedEnvironment()
templates = self.get_message(event_type)
msg_template = templates.get('message', {})
try:
notification_subject = env.from_string(msg_template).render(**context)
except (TemplateSyntaxError, UndefinedError, SecurityError):
notification_subject = ''
msg_body = templates.get('body', {})
try:
notification_body = env.from_string(msg_body).render(**context)
except (TemplateSyntaxError, UndefinedError, SecurityError):
notification_body = ''
return (notification_subject, notification_body)
def get_absolute_url(self, request=None):
return reverse('api:notification_template_detail', kwargs={'pk': self.pk}, request=request)
@@ -128,19 +109,34 @@ class NotificationTemplate(CommonModelNameNotUnique):
old_messages = old_nt.messages
new_messages = self.messages
def merge_messages(local_old_messages, local_new_messages, local_event):
if local_new_messages.get(local_event, {}) and local_old_messages.get(local_event, {}):
local_old_event_msgs = local_old_messages[local_event]
local_new_event_msgs = local_new_messages[local_event]
for msg_type in ['message', 'body']:
if msg_type not in local_new_event_msgs and local_old_event_msgs.get(msg_type, None):
local_new_event_msgs[msg_type] = local_old_event_msgs[msg_type]
if old_messages is not None and new_messages is not None:
for event in ['started', 'success', 'error']:
for event in ('started', 'success', 'error', 'workflow_approval'):
if not new_messages.get(event, {}) and old_messages.get(event, {}):
new_messages[event] = old_messages[event]
continue
if new_messages.get(event, {}) and old_messages.get(event, {}):
old_event_msgs = old_messages[event]
new_event_msgs = new_messages[event]
for msg_type in ['message', 'body']:
if msg_type not in new_event_msgs and old_event_msgs.get(msg_type, None):
new_event_msgs[msg_type] = old_event_msgs[msg_type]
if event == 'workflow_approval' and old_messages.get('workflow_approval', None):
new_messages.setdefault('workflow_approval', {})
for subevent in ('running', 'approved', 'timed_out', 'denied'):
old_wfa_messages = old_messages['workflow_approval']
new_wfa_messages = new_messages['workflow_approval']
if not new_wfa_messages.get(subevent, {}) and old_wfa_messages.get(subevent, {}):
new_wfa_messages[subevent] = old_wfa_messages[subevent]
continue
if old_wfa_messages:
merge_messages(old_wfa_messages, new_wfa_messages, subevent)
else:
merge_messages(old_messages, new_messages, event)
new_messages.setdefault(event, None)
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
self.notification_class.init_parameters):
if self.notification_configuration[field].startswith("$encrypted$"):
@@ -169,12 +165,12 @@ class NotificationTemplate(CommonModelNameNotUnique):
def recipients(self):
return self.notification_configuration[self.notification_class.recipient_parameter]
def generate_notification(self, subject, message):
def generate_notification(self, msg, body):
notification = Notification(notification_template=self,
notification_type=self.notification_type,
recipients=smart_str(self.recipients),
subject=subject,
body=message)
subject=msg,
body=body)
notification.save()
return notification
@@ -370,7 +366,7 @@ class JobNotificationMixin(object):
'verbosity': 0},
'job_friendly_name': 'Job',
'url': 'https://towerhost/#/jobs/playbook/1010',
'job_summary_dict': """{'url': 'https://towerhost/$/jobs/playbook/13',
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
'traceback': '',
'status': 'running',
'started': '2019-08-07T21:46:38.362630+00:00',
@@ -389,14 +385,14 @@ class JobNotificationMixin(object):
return context
def context(self, serialized_job):
"""Returns a context that can be used for rendering notification messages.
Context contains whitelisted content retrieved from a serialized job object
"""Returns a dictionary that can be used for rendering notification messages.
The context will contain whitelisted content retrieved from a serialized job object
(see JobNotificationMixin.JOB_FIELDS_WHITELIST), the job's friendly name,
and a url to the job run."""
context = {'job': {},
'job_friendly_name': self.get_notification_friendly_name(),
'url': self.get_ui_url(),
'job_summary_dict': json.dumps(self.notification_data(), indent=4)}
'job_metadata': json.dumps(self.notification_data(), indent=4)}
def build_context(node, fields, whitelisted_fields):
for safe_field in whitelisted_fields:
@@ -434,32 +430,33 @@ class JobNotificationMixin(object):
context = self.context(job_serialization)
msg_template = body_template = None
msg = body = ''
# Use custom template if available
if nt.messages:
templates = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {}
msg_template = templates.get('message', {})
body_template = templates.get('body', {})
template = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {}
msg_template = template.get('message', None)
body_template = template.get('body', None)
# If custom template not provided, look up default template
default_template = nt.notification_class.default_messages[self.STATUS_TO_TEMPLATE_TYPE[status]]
if not msg_template:
msg_template = default_template.get('message', None)
if not body_template:
body_template = default_template.get('body', None)
if msg_template:
try:
notification_subject = env.from_string(msg_template).render(**context)
msg = env.from_string(msg_template).render(**context)
except (TemplateSyntaxError, UndefinedError, SecurityError):
notification_subject = ''
else:
notification_subject = u"{} #{} '{}' {}: {}".format(self.get_notification_friendly_name(),
self.id,
self.name,
status,
self.get_ui_url())
notification_body = self.notification_data()
notification_body['friendly_name'] = self.get_notification_friendly_name()
msg = ''
if body_template:
try:
notification_body['body'] = env.from_string(body_template).render(**context)
body = env.from_string(body_template).render(**context)
except (TemplateSyntaxError, UndefinedError, SecurityError):
notification_body['body'] = ''
body = ''
return (notification_subject, notification_body)
return (msg, body)
def send_notification_templates(self, status):
from awx.main.tasks import send_notifications # avoid circular import
@@ -475,16 +472,13 @@ class JobNotificationMixin(object):
return
for nt in set(notification_templates.get(self.STATUS_TO_TEMPLATE_TYPE[status], [])):
try:
(notification_subject, notification_body) = self.build_notification_message(nt, status)
except AttributeError:
raise NotImplementedError("build_notification_message() does not exist" % status)
(msg, body) = self.build_notification_message(nt, status)
# Use kwargs to force late-binding
# https://stackoverflow.com/a/3431699/10669572
def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body):
def send_it(local_nt=nt, local_msg=msg, local_body=body):
def _func():
send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id],
send_notifications.delay([local_nt.generate_notification(local_msg, local_body).id],
job_id=self.id)
return _func
connection.on_commit(send_it())

View File

@@ -2,6 +2,7 @@
# All Rights Reserved.
# Python
import json
import logging
from copy import copy
from urllib.parse import urljoin
@@ -16,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist
# Django-CRUM
from crum import get_current_user
from jinja2 import sandbox
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
# AWX
from awx.api.versioning import reverse
from awx.main.models import (prevent_search, accepts_json, UnifiedJobTemplate,
@@ -763,22 +767,45 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
connection.on_commit(send_it())
def build_approval_notification_message(self, nt, approval_status):
subject = []
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
subject.append(('The approval node "{}"').format(self.workflow_approval_template.name))
if approval_status == 'running':
subject.append(('needs review. This node can be viewed at: {}').format(workflow_url))
if approval_status == 'approved':
subject.append(('was approved. {}').format(workflow_url))
if approval_status == 'timed_out':
subject.append(('has timed out. {}').format(workflow_url))
elif approval_status == 'denied':
subject.append(('was denied. {}').format(workflow_url))
subject = " ".join(subject)
body = self.notification_data()
body['body'] = subject
env = sandbox.ImmutableSandboxedEnvironment()
return subject, body
context = self.context(approval_status)
msg_template = body_template = None
msg = body = ''
# Use custom template if available
if nt.messages and nt.messages.get('workflow_approval', None):
template = nt.messages['workflow_approval'].get(approval_status, {})
msg_template = template.get('message', None)
body_template = template.get('body', None)
# If custom template not provided, look up default template
default_template = nt.notification_class.default_messages['workflow_approval'][approval_status]
if not msg_template:
msg_template = default_template.get('message', None)
if not body_template:
body_template = default_template.get('body', None)
if msg_template:
try:
msg = env.from_string(msg_template).render(**context)
except (TemplateSyntaxError, UndefinedError, SecurityError):
msg = ''
if body_template:
try:
body = env.from_string(body_template).render(**context)
except (TemplateSyntaxError, UndefinedError, SecurityError):
body = ''
return (msg, body)
def context(self, approval_status):
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
return {'approval_status': approval_status,
'approval_node_name': self.workflow_approval_template.name,
'workflow_url': workflow_url,
'job_metadata': json.dumps(self.notification_data(), indent=4)}
@property
def workflow_job_template(self):

View File

@@ -1,21 +1,10 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import json
from django.utils.encoding import smart_text
from django.core.mail.backends.base import BaseEmailBackend
from django.utils.translation import ugettext_lazy as _
class AWXBaseEmailBackend(BaseEmailBackend):
def format_body(self, body):
if "body" in body:
body_actual = body['body']
else:
body_actual = smart_text(_("{} #{} had status {}, view details at {}\n\n").format(
body['friendly_name'], body['id'], body['status'], body['url'])
)
body_actual += json.dumps(body, indent=4)
return body_actual
return body

View File

@@ -0,0 +1,20 @@
# Copyright (c) 2019 Ansible, Inc.
# All Rights Reserved.
class CustomNotificationBase(object):
DEFAULT_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
DEFAULT_BODY = "{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}"
default_messages = {"started": {"message": DEFAULT_MSG, "body": None},
"success": {"message": DEFAULT_MSG, "body": None},
"error": {"message": DEFAULT_MSG, "body": None},
"workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. '
'This node can be viewed at: {{ workflow_url }}',
"body": None},
"approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}',
"body": None},
"timed_out": {"message": 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}',
"body": None},
"denied": {"message": 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}',
"body": None}}}

View File

@@ -1,14 +1,15 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import json
from django.utils.encoding import smart_text
from django.core.mail.backends.smtp import EmailBackend
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.custom_notification_base import CustomNotificationBase
DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG
DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY
class CustomEmailBackend(EmailBackend):
class CustomEmailBackend(EmailBackend, CustomNotificationBase):
init_parameters = {"host": {"label": "Host", "type": "string"},
"port": {"label": "Port", "type": "int"},
@@ -19,22 +20,17 @@ class CustomEmailBackend(EmailBackend):
"sender": {"label": "Sender Email", "type": "string"},
"recipients": {"label": "Recipient List", "type": "list"},
"timeout": {"label": "Timeout", "type": "int", "default": 30}}
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
DEFAULT_BODY = smart_text(_("{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_summary_dict }}"))
default_messages = {"started": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY},
"success": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY},
"error": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}}
recipient_parameter = "recipients"
sender_parameter = "sender"
default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"approved": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}}
def format_body(self, body):
if "body" in body:
body_actual = body['body']
else:
body_actual = smart_text(_("{} #{} had status {}, view details at {}\n\n").format(
body['friendly_name'], body['id'], body['status'], body['url'])
)
body_actual += json.dumps(body, indent=4)
return body_actual
# leave body unchanged (expect a string)
return body

View File

@@ -8,24 +8,21 @@ import dateutil.parser as dp
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.grafana_backend')
class GrafanaBackend(AWXBaseEmailBackend):
class GrafanaBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"grafana_url": {"label": "Grafana URL", "type": "string"},
"grafana_key": {"label": "Grafana API Key", "type": "password"}}
recipient_parameter = "grafana_url"
sender_parameter = None
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, grafana_key,dashboardId=None, panelId=None, annotation_tags=None, grafana_no_verify_ssl=False, isRegion=True,
fail_silently=False, **kwargs):
super(GrafanaBackend, self).__init__(fail_silently=fail_silently)

View File

@@ -7,12 +7,14 @@ import requests
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.hipchat_backend')
class HipChatBackend(AWXBaseEmailBackend):
class HipChatBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"token": {"label": "Token", "type": "password"},
"rooms": {"label": "Destination Rooms", "type": "list"},
@@ -23,11 +25,6 @@ class HipChatBackend(AWXBaseEmailBackend):
recipient_parameter = "rooms"
sender_parameter = "message_from"
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, token, color, api_url, notify, fail_silently=False, **kwargs):
super(HipChatBackend, self).__init__(fail_silently=fail_silently)
self.token = token

View File

@@ -9,12 +9,14 @@ import irc.client
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.irc_backend')
class IrcBackend(AWXBaseEmailBackend):
class IrcBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"server": {"label": "IRC Server Address", "type": "string"},
"port": {"label": "IRC Server Port", "type": "int"},
@@ -25,11 +27,6 @@ class IrcBackend(AWXBaseEmailBackend):
recipient_parameter = "targets"
sender_parameter = None
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, server, port, nickname, password, use_ssl, fail_silently=False, **kwargs):
super(IrcBackend, self).__init__(fail_silently=fail_silently)
self.server = server

View File

@@ -7,23 +7,20 @@ import json
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.mattermost_backend')
class MattermostBackend(AWXBaseEmailBackend):
class MattermostBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"mattermost_url": {"label": "Target URL", "type": "string"},
"mattermost_no_verify_ssl": {"label": "Verify SSL", "type": "bool"}}
recipient_parameter = "mattermost_url"
sender_parameter = None
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, mattermost_no_verify_ssl=False, mattermost_channel=None, mattermost_username=None,
mattermost_icon_url=None, fail_silently=False, **kwargs):
super(MattermostBackend, self).__init__(fail_silently=fail_silently)

View File

@@ -1,17 +1,23 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import json
import logging
import pygerduty
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY
DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG
logger = logging.getLogger('awx.main.notifications.pagerduty_backend')
class PagerDutyBackend(AWXBaseEmailBackend):
class PagerDutyBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"subdomain": {"label": "Pagerduty subdomain", "type": "string"},
"token": {"label": "API Token", "type": "password"},
@@ -20,11 +26,14 @@ class PagerDutyBackend(AWXBaseEmailBackend):
recipient_parameter = "service_key"
sender_parameter = "client_name"
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
DEFAULT_BODY = "{{ job_summary_dict }}"
default_messages = {"started": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY},
"success": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY},
"error": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}}
DEFAULT_BODY = "{{ job_metadata }}"
default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"approved": {"message": DEFAULT_MSG,"body": DEFAULT_BODY},
"timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}}
def __init__(self, subdomain, token, fail_silently=False, **kwargs):
super(PagerDutyBackend, self).__init__(fail_silently=fail_silently)
@@ -32,6 +41,16 @@ class PagerDutyBackend(AWXBaseEmailBackend):
self.token = token
def format_body(self, body):
# cast to dict if possible # TODO: is it true that this can be a dict or str?
try:
potential_body = json.loads(body)
if isinstance(potential_body, dict):
body = potential_body
except json.JSONDecodeError:
pass
# but it's okay if this is also just a string
return body
def send_messages(self, messages):

View File

@@ -7,22 +7,20 @@ import json
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.rocketchat_backend')
class RocketChatBackend(AWXBaseEmailBackend):
class RocketChatBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"rocketchat_url": {"label": "Target URL", "type": "string"},
"rocketchat_no_verify_ssl": {"label": "Verify SSL", "type": "bool"}}
recipient_parameter = "rocketchat_url"
sender_parameter = None
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, rocketchat_no_verify_ssl=False, rocketchat_username=None, rocketchat_icon_url=None, fail_silently=False, **kwargs):
super(RocketChatBackend, self).__init__(fail_silently=fail_silently)

View File

@@ -6,24 +6,21 @@ from slackclient import SlackClient
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.slack_backend')
WEBSOCKET_TIMEOUT = 30
class SlackBackend(AWXBaseEmailBackend):
class SlackBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"token": {"label": "Token", "type": "password"},
"channels": {"label": "Destination Channels", "type": "list"}}
recipient_parameter = "channels"
sender_parameter = None
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, token, hex_color="", fail_silently=False, **kwargs):
super(SlackBackend, self).__init__(fail_silently=fail_silently)
self.token = token

View File

@@ -7,12 +7,14 @@ from twilio.rest import Client
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.twilio_backend')
class TwilioBackend(AWXBaseEmailBackend):
class TwilioBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"account_sid": {"label": "Account SID", "type": "string"},
"account_token": {"label": "Account Token", "type": "password"},
@@ -21,11 +23,6 @@ class TwilioBackend(AWXBaseEmailBackend):
recipient_parameter = "to_numbers"
sender_parameter = "from_number"
DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
default_messages = {"started": {"message": DEFAULT_SUBJECT},
"success": {"message": DEFAULT_SUBJECT},
"error": {"message": DEFAULT_SUBJECT}}
def __init__(self, account_sid, account_token, fail_silently=False, **kwargs):
super(TwilioBackend, self).__init__(fail_silently=fail_silently)
self.account_sid = account_sid

View File

@@ -7,13 +7,15 @@ import requests
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.utils import get_awx_version
from awx.main.notifications.custom_notification_base import CustomNotificationBase
logger = logging.getLogger('awx.main.notifications.webhook_backend')
class WebhookBackend(AWXBaseEmailBackend):
class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
init_parameters = {"url": {"label": "Target URL", "type": "string"},
"http_method": {"label": "HTTP Method", "type": "string", "default": "POST"},
@@ -24,10 +26,16 @@ class WebhookBackend(AWXBaseEmailBackend):
recipient_parameter = "url"
sender_parameter = None
DEFAULT_BODY = "{{ job_summary_dict }}"
DEFAULT_BODY = "{{ job_metadata }}"
default_messages = {"started": {"body": DEFAULT_BODY},
"success": {"body": DEFAULT_BODY},
"error": {"body": DEFAULT_BODY}}
"error": {"body": DEFAULT_BODY},
"workflow_approval": {
"running": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" needs review. '
'This node can be viewed at: {{ workflow_url }}"}'},
"approved": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was approved. {{ workflow_url }}"}'},
"timed_out": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" has timed out. {{ workflow_url }}"}'},
"denied": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was denied. {{ workflow_url }}"}'}}}
def __init__(self, http_method, headers, disable_ssl_verification=False, fail_silently=False, username=None, password=None, **kwargs):
self.http_method = http_method
@@ -38,15 +46,13 @@ class WebhookBackend(AWXBaseEmailBackend):
super(WebhookBackend, self).__init__(fail_silently=fail_silently)
def format_body(self, body):
# If `body` has body field, attempt to use this as the main body,
# otherwise, leave it as a sub-field
if isinstance(body, dict) and 'body' in body and isinstance(body['body'], str):
try:
potential_body = json.loads(body['body'])
if isinstance(potential_body, dict):
body = potential_body
except json.JSONDecodeError:
pass
# expect body to be a string representing a dict
try:
potential_body = json.loads(body)
if isinstance(potential_body, dict):
body = potential_body
except json.JSONDecodeError:
body = {}
return body
def send_messages(self, messages):

View File

@@ -12,10 +12,12 @@ class UriCleaner(object):
@staticmethod
def remove_sensitive(cleartext):
# exclude_list contains the items that will _not_ be redacted
exclude_list = [settings.PUBLIC_GALAXY_SERVER['url']]
if settings.PRIMARY_GALAXY_URL:
exclude_list = [settings.PRIMARY_GALAXY_URL] + [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS]
else:
exclude_list = [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS]
exclude_list += [settings.PRIMARY_GALAXY_URL]
if settings.FALLBACK_GALAXY_SERVERS:
exclude_list += [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS]
redactedtext = cleartext
text_index = 0
while True:

View File

@@ -1,9 +1,5 @@
import collections
import os
import stat
import time
import yaml
import tempfile
import logging
from base64 import b64encode
@@ -88,8 +84,17 @@ class PodManager(object):
@cached_property
def kube_api(self):
my_client = config.new_client_from_config(config_file=self.kube_config)
return client.CoreV1Api(api_client=my_client)
# this feels a little janky, but it's what k8s' own code does
# internally when it reads kube config files from disk:
# https://github.com/kubernetes-client/python-base/blob/0b208334ef0247aad9afcaae8003954423b61a0d/config/kube_config.py#L643
loader = config.kube_config.KubeConfigLoader(
config_dict=self.kube_config
)
cfg = type.__call__(client.Configuration)
loader.load_and_set(cfg)
return client.CoreV1Api(api_client=client.ApiClient(
configuration=cfg
))
@property
def pod_name(self):
@@ -174,10 +179,4 @@ def generate_tmp_kube_config(credential, namespace):
).decode() # decode the base64 data into a str
else:
config["clusters"][0]["cluster"]["insecure-skip-tls-verify"] = True
fd, path = tempfile.mkstemp(prefix='kubeconfig')
with open(path, 'wb') as temp:
temp.write(yaml.dump(config).encode())
temp.flush()
os.chmod(temp.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
return path
return config

View File

@@ -252,19 +252,25 @@ class TaskManager():
logger.debug('Submitting isolated {} to queue {} controlled by {}.'.format(
task.log_format, task.execution_node, controller_node))
elif rampart_group.is_containerized:
# find one real, non-containerized instance with capacity to
# act as the controller for k8s API interaction
match = None
for group in InstanceGroup.objects.all():
if group.is_containerized or group.controller_id:
continue
match = group.find_largest_idle_instance()
if match:
break
task.instance_group = rampart_group
if not task.supports_isolation():
if task.supports_isolation():
task.controller_node = match.hostname
else:
# project updates and inventory updates don't *actually* run in pods,
# so just pick *any* non-isolated, non-containerized host and use it
for group in InstanceGroup.objects.all():
if group.is_containerized or group.controller_id:
continue
match = group.find_largest_idle_instance()
if match:
task.execution_node = match.hostname
logger.debug('Submitting containerized {} to queue {}.'.format(
task.log_format, task.execution_node))
break
# as the execution node
task.execution_node = match.hostname
logger.debug('Submitting containerized {} to queue {}.'.format(
task.log_format, task.execution_node))
else:
task.instance_group = rampart_group
if instance is not None:

View File

@@ -1423,7 +1423,6 @@ class BaseTask(object):
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}.")
@@ -1452,7 +1451,7 @@ class BaseTask(object):
self.update_model(task.pk, execution_node=pod_manager.pod_name)
return pod_manager
@@ -1959,9 +1958,15 @@ class RunProjectUpdate(BaseTask):
env['PROJECT_UPDATE_ID'] = str(project_update.pk)
env['ANSIBLE_CALLBACK_PLUGINS'] = self.get_path_to('..', 'plugins', 'callback')
env['ANSIBLE_GALAXY_IGNORE'] = True
# Set up the fallback server, which is the normal Ansible Galaxy by default
galaxy_servers = list(settings.FALLBACK_GALAXY_SERVERS)
# If private galaxy URL is non-blank, that means this feature is enabled
# Set up the public Galaxy server, if enabled
if settings.PUBLIC_GALAXY_ENABLED:
galaxy_servers = [settings.PUBLIC_GALAXY_SERVER]
else:
galaxy_servers = []
# Set up fallback Galaxy servers, if configured
if settings.FALLBACK_GALAXY_SERVERS:
galaxy_servers = settings.FALLBACK_GALAXY_SERVERS + galaxy_servers
# Set up the primary Galaxy server, if configured
if settings.PRIMARY_GALAXY_URL:
galaxy_servers = [{'id': 'primary_galaxy'}] + galaxy_servers
for key in GALAXY_SERVER_FIELDS:
@@ -2354,6 +2359,27 @@ class RunInventoryUpdate(BaseTask):
env[str(env_k)] = str(inventory_update.source_vars_dict[env_k])
elif inventory_update.source == 'file':
raise NotImplementedError('Cannot update file sources through the task system.')
if inventory_update.source == 'scm' and inventory_update.source_project_update:
env_key = 'ANSIBLE_COLLECTIONS_PATHS'
config_setting = 'collections_paths'
folder = 'requirements_collections'
default = '~/.ansible/collections:/usr/share/ansible/collections'
config_values = read_ansible_config(os.path.join(private_data_dir, 'project'), [config_setting])
paths = default.split(':')
if env_key in env:
for path in env[env_key].split(':'):
if path not in 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
env[env_key] = os.pathsep.join(paths)
return env
def write_args_file(self, private_data_dir, args):
@@ -2452,7 +2478,7 @@ class RunInventoryUpdate(BaseTask):
# Use the vendored script path
inventory_path = self.get_path_to('..', 'plugins', 'inventory', injector.script_name)
elif src == 'scm':
inventory_path = inventory_update.get_actual_source_path()
inventory_path = os.path.join(private_data_dir, 'project', inventory_update.source_path)
elif src == 'custom':
handle, inventory_path = tempfile.mkstemp(dir=private_data_dir)
f = os.fdopen(handle, 'w')
@@ -2473,7 +2499,7 @@ class RunInventoryUpdate(BaseTask):
'''
src = inventory_update.source
if src == 'scm' and inventory_update.source_project_update:
return inventory_update.source_project_update.get_project_path(check_if_exists=False)
return os.path.join(private_data_dir, 'project')
if src in CLOUD_PROVIDERS:
injector = None
if src in InventorySource.injectors:
@@ -2509,8 +2535,10 @@ class RunInventoryUpdate(BaseTask):
project_update_task = local_project_sync._get_task_class()
try:
project_update_task().run(local_project_sync.id)
inventory_update.inventory_source.scm_last_revision = local_project_sync.project.scm_revision
sync_task = project_update_task(job_private_data_dir=private_data_dir)
sync_task.run(local_project_sync.id)
local_project_sync.refresh_from_db()
inventory_update.inventory_source.scm_last_revision = local_project_sync.scm_revision
inventory_update.inventory_source.save(update_fields=['scm_last_revision'])
except Exception:
inventory_update = self.update_model(
@@ -2518,6 +2546,13 @@ class RunInventoryUpdate(BaseTask):
job_explanation=('Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' %
('project_update', local_project_sync.name, local_project_sync.id)))
raise
elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project:
# This follows update, not sync, so make copy here
project_path = source_project.get_project_path(check_if_exists=False)
RunProjectUpdate.make_local_copy(
project_path, os.path.join(private_data_dir, 'project'),
source_project.scm_type, source_project.scm_revision
)
@task()

View File

@@ -0,0 +1,45 @@
import pytest
from awx.api.versioning import reverse
from awx.main.models import AdHocCommand, AdHocCommandEvent, JobEvent
@pytest.mark.django_db
@pytest.mark.parametrize('truncate, expected', [
(True, False),
(False, True),
])
def test_job_events_sublist_truncation(get, organization_factory, job_template_factory, truncate, expected):
objs = organization_factory("org", superusers=['admin'])
jt = job_template_factory("jt", organization=objs.organization,
inventory='test_inv', project='test_proj').job_template
job = jt.create_unified_job()
JobEvent.create_from_data(job_id=job.pk, uuid='abc123', event='runner_on_start',
stdout='a' * 1025)
url = reverse('api:job_job_events_list', kwargs={'pk': job.pk})
if not truncate:
url += '?no_truncate=1'
response = get(url, user=objs.superusers.admin, expect=200)
assert (len(response.data['results'][0]['stdout']) == 1025) == expected
@pytest.mark.django_db
@pytest.mark.parametrize('truncate, expected', [
(True, False),
(False, True),
])
def test_ad_hoc_events_sublist_truncation(get, organization_factory, job_template_factory, truncate, expected):
objs = organization_factory("org", superusers=['admin'])
adhoc = AdHocCommand()
adhoc.save()
AdHocCommandEvent.create_from_data(ad_hoc_command_id=adhoc.pk, uuid='abc123', event='runner_on_start',
stdout='a' * 1025)
url = reverse('api:ad_hoc_command_ad_hoc_command_events_list', kwargs={'pk': adhoc.pk})
if not truncate:
url += '?no_truncate=1'
response = get(url, user=objs.superusers.admin, expect=200)
assert (len(response.data['results'][0]['stdout']) == 1025) == expected

View File

@@ -117,3 +117,10 @@ def test_handle_content_type(post, admin):
admin,
content_type='text/html',
expect=415)
@pytest.mark.django_db
def test_basic_not_found(get, admin_user):
root_url = reverse('api:api_v2_root_view')
r = get(root_url + 'fooooooo', user=admin_user, expect=404)
assert r.data.get('detail') == 'The requested resource could not be found.'

View File

@@ -8,6 +8,8 @@ from unittest.mock import PropertyMock
# Django
from django.urls import resolve
from django.http import Http404
from django.core.handlers.exception import response_for_exception
from django.contrib.auth.models import User
from django.core.serializers.json import DjangoJSONEncoder
from django.db.backends.sqlite3.base import SQLiteCursorWrapper
@@ -581,8 +583,12 @@ def _request(verb):
if 'format' not in kwargs and 'content_type' not in kwargs:
kwargs['format'] = 'json'
view, view_args, view_kwargs = resolve(urllib.parse.urlparse(url)[2])
request = getattr(APIRequestFactory(), verb)(url, **kwargs)
request_error = None
try:
view, view_args, view_kwargs = resolve(urllib.parse.urlparse(url)[2])
except Http404 as e:
request_error = e
if isinstance(kwargs.get('cookies', None), dict):
for key, value in kwargs['cookies'].items():
request.COOKIES[key] = value
@@ -591,7 +597,10 @@ def _request(verb):
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if not request_error:
response = view(request, *view_args, **view_kwargs)
else:
response = response_for_exception(request, request_error)
if middleware:
middleware.process_response(request, response)
if expect:

View File

@@ -87,7 +87,7 @@ class TestJobNotificationMixin(object):
'use_fact_cache': bool,
'verbosity': int},
'job_friendly_name': str,
'job_summary_dict': str,
'job_metadata': str,
'url': str}
@@ -144,5 +144,3 @@ class TestJobNotificationMixin(object):
context_stub = JobNotificationMixin.context_stub()
check_structure_and_completeness(TestJobNotificationMixin.CONTEXT_STRUCTURE, context_stub)

View File

@@ -1,5 +1,4 @@
import subprocess
import yaml
import base64
from unittest import mock # noqa
@@ -51,6 +50,5 @@ def test_kubectl_ssl_verification(containerized_job):
cred.inputs['ssl_ca_cert'] = cert.stdout
cred.save()
pm = PodManager(containerized_job)
config = yaml.load(open(pm.kube_config), Loader=yaml.FullLoader)
ca_data = config['clusters'][0]['cluster']['certificate-authority-data']
ca_data = pm.kube_config['clusters'][0]['cluster']['certificate-authority-data']
assert cert.stdout == base64.b64decode(ca_data.encode())

View File

@@ -264,6 +264,7 @@ def test_inventory_update_injected_content(this_kind, script_or_plugin, inventor
assert envvars.pop('ANSIBLE_INVENTORY_ENABLED') == ('auto' if use_plugin else 'script')
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)
env.pop('ANSIBLE_COLLECTIONS_PATHS', None) # collection paths not relevant to this test
base_dir = os.path.join(DATA, script_or_plugin)
if not os.path.exists(base_dir):
os.mkdir(base_dir)

View File

@@ -43,7 +43,7 @@ def test_basic_parameterization(get, post, user, organization):
assert 'url' in response.data['notification_configuration']
assert 'headers' in response.data['notification_configuration']
assert 'messages' in response.data
assert response.data['messages'] == {'started': None, 'success': None, 'error': None}
assert response.data['messages'] == {'started': None, 'success': None, 'error': None, 'workflow_approval': None}
@pytest.mark.django_db

View File

@@ -19,6 +19,8 @@ from awx.main.models import (
Credential
)
from rest_framework.exceptions import PermissionDenied
from crum import impersonate
@@ -252,7 +254,8 @@ class TestJobRelaunchAccess:
assert 'job_var' in job.launch_config.extra_data
assert bob.can_access(Job, 'start', job, validate_license=False)
assert not alice.can_access(Job, 'start', job, validate_license=False)
with pytest.raises(PermissionDenied):
alice.can_access(Job, 'start', job, validate_license=False)
@pytest.mark.django_db

View File

@@ -7,6 +7,8 @@ from awx.main.access import (
# WorkflowJobNodeAccess
)
from rest_framework.exceptions import PermissionDenied
from awx.main.models import InventorySource, JobLaunchConfig
@@ -169,7 +171,8 @@ class TestWorkflowJobAccess:
wfjt.ask_inventory_on_launch = True
wfjt.save()
JobLaunchConfig.objects.create(job=workflow_job, inventory=inventory)
assert not WorkflowJobAccess(rando).can_start(workflow_job)
with pytest.raises(PermissionDenied):
WorkflowJobAccess(rando).can_start(workflow_job)
inventory.use_role.members.add(rando)
assert WorkflowJobAccess(rando).can_start(workflow_job)

View File

@@ -26,7 +26,7 @@ class TestNotificationTemplateSerializer():
{'started': {'message': '{{ job.id }}', 'body': '{{ job.status }}'},
'success': {'message': None, 'body': '{{ job_friendly_name }}'},
'error': {'message': '{{ url }}', 'body': None}},
{'started': {'body': '{{ job_summary_dict }}'}},
{'started': {'body': '{{ job_metadata }}'}},
{'started': {'body': '{{ job.summary_fields.inventory.total_hosts }}'}},
{'started': {'body': u'Iñtërnâtiônàlizætiøn'}}
])

View File

@@ -288,13 +288,17 @@ class AWXProxyHandler(logging.Handler):
'''
thread_local = threading.local()
_auditor = None
def __init__(self, **kwargs):
# TODO: process 'level' kwarg
super(AWXProxyHandler, self).__init__(**kwargs)
self._handler = None
self._old_kwargs = {}
if settings.LOG_AGGREGATOR_AUDIT:
@property
def auditor(self):
if not self._auditor:
self._auditor = logging.handlers.RotatingFileHandler(
filename='/var/log/tower/external.log',
maxBytes=1024 * 1024 * 50, # 50 MB
@@ -307,6 +311,7 @@ class AWXProxyHandler(logging.Handler):
return json.dumps(message)
self._auditor.setFormatter(WritableLogstashFormatter())
return self._auditor
def get_handler_class(self, protocol):
return HANDLER_MAPPING.get(protocol, AWXNullHandler)
@@ -341,8 +346,8 @@ class AWXProxyHandler(logging.Handler):
if AWXProxyHandler.thread_local.enabled:
actual_handler = self.get_handler()
if settings.LOG_AGGREGATOR_AUDIT:
self._auditor.setLevel(settings.LOG_AGGREGATOR_LEVEL)
self._auditor.emit(record)
self.auditor.setLevel(settings.LOG_AGGREGATOR_LEVEL)
self.auditor.emit(record)
return actual_handler.emit(record)
def perform_test(self, custom_settings):

View File

@@ -635,16 +635,18 @@ PRIMARY_GALAXY_USERNAME = ''
PRIMARY_GALAXY_TOKEN = ''
PRIMARY_GALAXY_PASSWORD = ''
PRIMARY_GALAXY_AUTH_URL = ''
# Settings for the fallback galaxy server(s), normally this is the
# actual Ansible Galaxy site.
# server options: 'id', 'url', 'username', 'password', 'token', 'auth_url'
# To not use any fallback servers set this to []
FALLBACK_GALAXY_SERVERS = [
{
'id': 'galaxy',
'url': 'https://galaxy.ansible.com'
}
]
# Settings for the public galaxy server(s).
PUBLIC_GALAXY_ENABLED = True
PUBLIC_GALAXY_SERVER = {
'id': 'galaxy',
'url': 'https://galaxy.ansible.com'
}
# List of dicts of fallback (additional) Galaxy servers. If configured, these
# will be higher precedence than public Galaxy, but lower than primary Galaxy.
# Available options: 'id', 'url', 'username', 'password', 'token', 'auth_url'
FALLBACK_GALAXY_SERVERS = []
# Enable bubblewrap support for running jobs (playbook runs only).
# Note: This setting may be overridden by database settings.

View File

@@ -282,10 +282,12 @@ function getLaunchedByDetails () {
tooltip = strings.get('tooltips.SCHEDULE');
link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
value = $filter('sanitize')(schedule.name);
} else {
} else if (schedule) {
tooltip = null;
link = null;
value = $filter('sanitize')(schedule.name);
} else {
return null;
}
return { label, link, tooltip, value };

View File

@@ -5,7 +5,7 @@
<!-- LEFT PANE HEADER ACTIONS -->
<div class="JobResults-panelHeaderButtonActions">
<!-- RELAUNCH ACTION -->
<at-relaunch job="vm.job"></at-relaunch>
<at-relaunch ng-if="vm.job" job="vm.job"></at-relaunch>
<!-- CANCEL ACTION -->
<button

View File

@@ -213,8 +213,8 @@ function JobRenderService ($q, $compile, $sce, $window) {
const record = this.createRecord(event, lines);
if (lines.length === 1 && lines[0] === '') {
// Some events, mainly runner_on_start events, have an actual line count of 1
// (stdout = '') and a claimed line count of 0 (end_line - start_line = 0).
// runner_on_start, runner_on_ok, and a few other events have an actual line count
// of 1 (stdout = '') and a claimed line count of 0 (end_line - start_line = 0).
// Since a zero-length string has an actual line count of 1, they'll still get
// rendered as blank lines unless we intercept them and add some special
// handling to remove them.

View File

@@ -208,6 +208,7 @@
max-width: none !important;
width: 100% !important;
padding-right: 0px !important;
margin-top: 10px;
}
.Form-formGroup--checkbox{

View File

@@ -15,7 +15,9 @@
title="{{ label || vm.strings.get('code_mirror.label.VARIABLES') }}"
tabindex="-1"
ng-if="tooltip">
<i class="fa fa-question-circle"></i>
<span class="at-Popover-icon" ng-class="{ 'at-Popover-icon--defaultCursor': popover.on === 'mouseenter' && !popover.click }">
<i class="fa fa-question-circle"></i>
</span>
</a>
<div class="atCodeMirror-toggleContainer FormToggle-container">
<div id="{{ name }}_parse_type" class="btn-group">

View File

@@ -202,6 +202,7 @@
.at-Row-toggle {
align-self: flex-start;
margin-right: @at-space-4x;
margin-left: 15px;
}
.at-Row-actions {
@@ -385,29 +386,3 @@
margin-right: @at-margin-right-list-row-item-inline-label;
}
}
@media screen and (max-width: @at-breakpoint-instances-wrap) {
.at-Row-items--instances {
margin-bottom: @at-padding-bottom-instances-wrap;
}
}
@media screen and (max-width: @at-breakpoint-compact-list) {
.at-Row-actions {
align-items: center;
}
.at-RowAction {
margin: @at-margin-list-row-action-mobile;
}
.at-RowItem--inline {
display: flex;
margin-right: inherit;
.at-RowItem-label {
width: @at-width-list-row-item-label;
margin-right: inherit;
}
}
}

View File

@@ -89,6 +89,9 @@ export default ['i18n', function(i18n) {
type: 'text',
reset: 'PRIMARY_GALAXY_AUTH_URL',
},
PUBLIC_GALAXY_ENABLED: {
type: 'toggleSwitch',
},
AWX_TASK_ENV: {
type: 'textarea',
reset: 'AWX_TASK_ENV',

View File

@@ -1,9 +1,11 @@
.CapacityAdjuster {
margin-right: @at-space-4x;
margin-top: 15px;
margin-left: -10px;
position: relative;
&-valueLabel {
bottom: @at-space-5x;
top: -10px;
color: @at-color-body-text;
font-size: @at-font-size;
position: absolute;

View File

@@ -5,6 +5,8 @@ capacity-bar {
font-size: @at-font-size;
min-width: 100px;
white-space: nowrap;
margin-top: 5px;
margin-bottom: 5px;
.CapacityBar {
background-color: @default-bg;
@@ -42,12 +44,4 @@ capacity-bar {
text-align: right;
text-transform: uppercase;
}
.Capacity-details--percentage {
width: 40px;
}
&:only-child {
margin-right: 50px;
}
}

View File

@@ -12,6 +12,7 @@ function AddContainerGroupController(ToJSON, $scope, $state, models, strings, i1
vm.form = instanceGroup.createFormSchema('post');
vm.form.name.required = true;
delete vm.form.name.help_text;
vm.form.credential = {
type: 'field',
@@ -22,6 +23,7 @@ function AddContainerGroupController(ToJSON, $scope, $state, models, strings, i1
vm.form.credential._route = "instanceGroups.addContainerGroup.credentials";
vm.form.credential._model = credential;
vm.form.credential._placeholder = strings.get('container.CREDENTIAL_PLACEHOLDER');
vm.form.credential.help_text = strings.get('container.CREDENTIAL_HELP_TEXT');
vm.form.credential.required = true;
vm.form.extraVars = {
@@ -29,6 +31,7 @@ function AddContainerGroupController(ToJSON, $scope, $state, models, strings, i1
value: DataSet.data.actions.POST.pod_spec_override.default,
name: 'extraVars',
toggleLabel: strings.get('container.POD_SPEC_TOGGLE'),
tooltip: strings.get('container.EXTRA_VARS_HELP_TEXT')
};
vm.tab = {

View File

@@ -1,8 +1,8 @@
<div ui-view="credentials"></div>
<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">
<a class="containerGroups-messageBar-link" href="https://docs.ansible.com/ansible-tower/latest/html/administration/external_execution_envs.html#container-groups" target="_blank" style="color: white">
<div class="Section-messageBar">
<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 currently in tech preview and is subject to change in a future release. Click here for documentation.</span>
</div>
</a>
<at-panel>
@@ -34,6 +34,7 @@
variables="vm.form.extraVars.value"
label="{{ vm.form.extraVars.label }}"
name="{{ vm.form.extraVars.name }}"
tooltip="{{ vm.form.extraVars.tooltip }}"
>
</at-code-mirror>
</div>

View File

@@ -27,6 +27,7 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
vm.switchDisabled = false;
vm.form.disabled = !instanceGroup.has('options', 'actions.PUT');
vm.form.name.required = true;
delete vm.form.name.help_text;
vm.form.credential = {
type: 'field',
label: i18n._('Credential'),
@@ -38,6 +39,7 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
vm.form.credential._displayValue = EditContainerGroupDataset.data.summary_fields.credential.name;
vm.form.credential.required = true;
vm.form.credential._value = EditContainerGroupDataset.data.summary_fields.credential.id;
vm.form.credential.help_text = strings.get('container.CREDENTIAL_HELP_TEXT');
vm.tab = {
details: {
@@ -59,7 +61,8 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
label: strings.get('container.POD_SPEC_LABEL'),
value: EditContainerGroupDataset.data.pod_spec_override || "---",
name: 'extraVars',
disabled: true
disabled: true,
tooltip: strings.get('container.EXTRA_VARS_HELP_TEXT')
};
vm.switchDisabled = true;
} else {
@@ -67,7 +70,8 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
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')
toggleLabel: strings.get('container.POD_SPEC_TOGGLE'),
tooltip: strings.get('container.EXTRA_VARS_HELP_TEXT')
};
}

View File

@@ -1,135 +1,100 @@
.InstanceGroups {
.at-Row-actions{
justify-content: flex-start;
width: 300px;
& > capacity-bar:only-child{
margin-left: 0px;
margin-top: 5px
}
}
.at-RowAction{
margin: 0;
}
.at-Row-links{
justify-content: flex-start;
.at-Row--instances {
.at-Row-content {
flex-wrap: nowrap;
}
.BreadCrumb-menuLinkImage:hover {
color: @default-link;
.at-Row-toggle {
align-self: auto;
flex: initial;
}
.List-details {
align-self: flex-end;
color: @default-interface-txt;
.at-Row-itemGroup {
display: flex;
flex: 0 0 auto;
font-size: 12px;
margin-right:20px;
text-transform: uppercase;
flex: 1;
flex-wrap: wrap;
}
.Capacity-details {
.at-Row-items--instances {
display: flex;
margin-right: 20px;
flex-wrap: wrap;
align-items: center;
.Capacity-details--label {
color: @default-interface-txt;
margin: 0 10px 0 0;
width: 100px;
}
align-content: center;
flex: 1;
}
.RunningJobs-details {
align-items: center;
display: flex;
.RunningJobs-details--label {
margin: 0 10px 0 0;
}
.at-RowItem--isHeader {
min-width: 250px;
}
.List-tableCell--capacityColumn {
.at-Row-items--capacity {
display: flex;
height: 40px;
flex-wrap: wrap;
align-items: center;
}
.List-noItems {
margin-top: 20px;
}
.List-tableRow .List-titleBadge {
margin: 0 0 0 5px;
}
.Panel-docsLink {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 7px;
background: @at-white;
border-radius: @at-border-radius;
height: 30px;
width: 30px;
margin: 0 20px 0 auto;
i {
font-size: @at-font-size-icon;
color: @at-gray-646972;
}
}
.Panel-docsLink:hover {
background-color: @at-blue;
i {
color: @at-white;
}
}
.at-Row-toggle{
margin-top: 20px;
padding-left: 15px;
}
.ContainerGroups-codeMirror{
margin-bottom: 10px;
}
.at-Row-container{
flex-wrap: wrap;
}
.containerGroups-messageBar-link:hover{
text-decoration: underline;
}
@media screen and (max-width: 1060px) and (min-width: 769px){
.at-Row-links {
justify-content: flex-start;
flex-wrap: wrap;
}
}
@media screen and (min-width: 1061px){
.at-Row-actions{
justify-content: flex-end;
& > capacity-bar:only-child {
margin-right: 30px;
}
}
.instanceGroupsList-details{
display: flex;
}
.at-Row-links {
justify-content: flex-end;
display: flex;
width: 445px;
}
.CapacityAdjuster {
padding-bottom: 15px;
}
}
.at-Row--instanceGroups {
.at-Row-content {
flex-wrap: nowrap;
}
.at-Row-itemGroup {
display: flex;
flex: 1;
flex-wrap: wrap;
}
.at-Row-items--instanceGroups {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1;
max-width: 100%;
}
.at-Row-itemHeaderGroup {
min-width: 320px;
display: flex;
}
.at-Row-items--capacity {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-right: 5px;
min-width: 215px;
}
.at-Row--instanceSpacer {
width: 140px;
}
.at-Row--capacitySpacer {
flex: .6;
}
.at-Row-actions {
min-width: 50px;
}
}
@media screen and (max-width: 1260px) {
.at-Row--instances .at-Row-items--capacity {
flex: 1
}
.at-Row--instances .CapacityAdjuster {
padding-bottom: 5px;
}
}
@media screen and (max-width: 600px) {
.at-Row--instanceGroups .at-Row-itemHeaderGroup,
.at-Row--instanceGroups .at-Row-itemGroup {
max-width: 270px;
}
}

View File

@@ -72,8 +72,9 @@ function InstanceGroupsStrings(BaseString) {
CREDENTIAL_PLACEHOLDER: t.s('SELECT A CREDENTIAL'),
POD_SPEC_LABEL: t.s('Pod Spec Override'),
BADGE_TEXT: t.s('Container Group'),
POD_SPEC_TOGGLE: t.s('Customize Pod Spec')
POD_SPEC_TOGGLE: t.s('Customize Pod Spec'),
CREDENTIAL_HELP_TEXT: t.s('Credential to authenticate with Kubernetes or OpenShift.  Must be of type \"Kubernetes/OpenShift API Bearer Token\”.'),
EXTRA_VARS_HELP_TEXT: t.s('Field for passing a custom Kubernetes or OpenShift Pod specification.')
};
}

View File

@@ -43,35 +43,45 @@
</at-list-toolbar>
<at-list results='vm.instances'>
<at-row ng-repeat="instance in vm.instances"
ng-class="{'at-Row--active': (instance.id === vm.activeId)}">
ng-class="{'at-Row--active': (instance.id === vm.activeId)}"
class="at-Row--instances">
<div class="at-Row-toggle">
<at-switch on-toggle="vm.toggle(instance)" switch-on="instance.enabled" switch-disabled="vm.rowAction.toggle._disabled"></at-switch>
</div>
<div class="at-Row-items at-Row-items--instances">
<at-row-item
header-value="{{ instance.hostname }}"
header-tag="{{ instance.managed_by_policy ? '' : vm.strings.get('list.MANUAL') }}">
</at-row-item>
<at-row-item
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_RUNNING_JOBS') }}"
label-state="instanceGroups.instanceJobs({instance_group_id: {{vm.instance_group_id}}, instance_id: {{instance.id}}, job_search: {status__in: ['running,waiting']}})"
value="{{ instance.jobs_running }}"
inline="true"
badge="true">
</at-row-item>
<at-row-item
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_TOTAL_JOBS') }}"
label-state="instanceGroups.instanceJobs({instance_group_id: {{vm.instance_group_id}}, instance_id: {{instance.id}}})"
value="{{ instance.jobs_total }}"
inline="true"
badge="true">
</at-row-item>
</div>
<div class="at-Row-actions">
<capacity-adjuster state="instance" disabled="{{vm.rowAction.capacity_adjustment._disabled}}"></capacity-adjuster>
<capacity-bar label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_USED_CAPACITY') }}" capacity="instance.consumed_capacity" total-capacity="instance.capacity"></capacity-bar>
<div class="at-Row-itemGroup">
<div class="at-Row-items at-Row-items--instances">
<at-row-item
header-value="{{ instance.hostname }}"
header-tag="{{ instance.managed_by_policy ? '' : vm.strings.get('list.MANUAL') }}">
</at-row-item>
<div class="at-Row-nonHeaderItems">
<at-row-item
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_RUNNING_JOBS') }}"
label-state="instanceGroups.instanceJobs({instance_group_id: {{vm.instance_group_id}}, instance_id: {{instance.id}}, job_search: {status__in: ['running,waiting']}})"
value="{{ instance.jobs_running }}"
inline="true"
badge="true">
</at-row-item>
<at-row-item
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_TOTAL_JOBS') }}"
label-state="instanceGroups.instanceJobs({instance_group_id: {{vm.instance_group_id}}, instance_id: {{instance.id}}})"
value="{{ instance.jobs_total }}"
inline="true"
badge="true">
</at-row-item>
</div>
</div>
<div class="at-Row-items--capacity">
<capacity-adjuster
state="instance"
disabled="{{vm.rowAction.capacity_adjustment._disabled}}">
</capacity-adjuster>
<capacity-bar
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_USED_CAPACITY') }}"
capacity="instance.consumed_capacity"
total-capacity="instance.capacity">
</capacity-bar>
</div>
</div>
</at-row>
</at-list>

View File

@@ -41,10 +41,11 @@
</at-list-toolbar>
<at-list results="instance_groups">
<at-row ng-repeat="instance_group in instance_groups"
ng-class="{'at-Row--active': (instance_group.id === vm.activeId)}" >
<div class="at-Row-items">
<div class="at-Row-container">
<div class="at-Row-content">
ng-class="{'at-Row--active': (instance_group.id === vm.activeId)}"
class="at-Row--instanceGroups">
<div class="at-Row-itemGroup">
<div class="at-Row-items at-Row-items--instanceGroups">
<div class="at-Row-itemHeaderGroup">
<at-row-item
ng-if="!instance_group.credential"
header-value="{{ instance_group.name }}"
@@ -67,23 +68,14 @@
</div>
</div>
<div class="at-RowItem--labels" ng-if="!instance_group.credential">
<div class="LabelList-tagContainer">
<div class="LabelList-tag" ng-class="{'LabelList-tag--deletable' : (showDelete && template.summary_fields.user_capabilities.edit)}">
<span class="LabelList-name">{{vm.strings.get('instance.BADGE_TEXT') }}</span>
</div>
<div class="LabelList-tagContainer">
<div class="LabelList-tag" ng-class="{'LabelList-tag--deletable' : (showDelete && template.summary_fields.user_capabilities.edit)}">
<span class="LabelList-name">{{vm.strings.get('instance.BADGE_TEXT') }}</span>
</div>
</div>
</div>
</div>
<div class="instanceGroupsList-details">
<div class="at-Row-links">
<at-row-item
ng-if="!instance_group.credential"
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_INSTANCES') }}"
label-state="instanceGroups.instances({instance_group_id: {{ instance_group.id }}})"
value="{{ instance_group.instances }}"
inline="true"
badge="true">
</at-row-item>
<div class="at-Row-nonHeaderItems">
<at-row-item
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_RUNNING_JOBS') }}"
label-state="instanceGroups.jobs({instance_group_id: {{ instance_group.id }}, job_search: {status__in: ['running,waiting']}})"
@@ -98,14 +90,38 @@
inline="true"
badge="true">
</at-row-item>
</div>
<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>
<at-row-action icon="fa-trash" ng-click="vm.deleteInstanceGroup(instance_group)" ng-if="vm.rowAction.trash(instance_group)">
</at-row-action>
</div>
<at-row-item
ng-if="!instance_group.credential"
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_INSTANCES') }}"
label-state="instanceGroups.instances({instance_group_id: {{ instance_group.id }}})"
value="{{ instance_group.instances }}"
inline="true"
badge="true">
</at-row-item>
<div
ng-if="instance_group.credential"
class="at-Row--instanceSpacer">
</div>
</div>
</div>
<div class="at-Row-items--capacity" ng-if="!instance_group.credential">
<capacity-bar
label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_USED_CAPACITY') }}"
capacity="instance_group.consumed_capacity"
total-capacity="instance_group.capacity">
</capacity-bar>
</div>
<div
ng-if="instance_group.credential"
class="at-Row--capacitySpacer">
</div>
</div>
<div class="at-Row-actions" >
<at-row-action
icon="fa-trash"
ng-click="vm.deleteInstanceGroup(instance_group)"
ng-if="vm.rowAction.trash(instance_group)">
</at-row-action>
</div>
</at-row>
</at-list>

View File

@@ -671,6 +671,98 @@ export default ['i18n', function(i18n) {
"|| notification_type.value == 'webhook')",
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
approved_message: {
label: i18n._('Workflow Approved Message'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && notification_type.value != 'webhook'",
rows: 2,
oneLine: 'true',
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
approved_body: {
label: i18n._('Workflow Approved Message Body'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && " +
"(notification_type.value == 'email' " +
"|| notification_type.value == 'pagerduty' " +
"|| notification_type.value == 'webhook')",
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
denied_message: {
label: i18n._('Workflow Denied Message'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && notification_type.value != 'webhook'",
rows: 2,
oneLine: 'true',
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
denied_body: {
label: i18n._('Workflow Denied Message Body'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && " +
"(notification_type.value == 'email' " +
"|| notification_type.value == 'pagerduty' " +
"|| notification_type.value == 'webhook')",
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
running_message: {
label: i18n._('Workflow Running Message'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && notification_type.value != 'webhook'",
rows: 2,
oneLine: 'true',
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
running_body: {
label: i18n._('Workflow Running Message Body'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && " +
"(notification_type.value == 'email' " +
"|| notification_type.value == 'pagerduty' " +
"|| notification_type.value == 'webhook')",
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
timed_out_message: {
label: i18n._('Workflow Timed Out Message'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && notification_type.value != 'webhook'",
rows: 2,
oneLine: 'true',
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
timed_out_body: {
label: i18n._('Workflow Timed Out Message Body'),
class: 'Form-formGroup--fullWidth',
type: 'syntax_highlight',
mode: 'jinja2',
default: '',
ngShow: "customize_messages && " +
"(notification_type.value == 'email' " +
"|| notification_type.value == 'pagerduty' " +
"|| notification_type.value == 'webhook')",
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
},
buttons: { //for now always generates <button> tags

View File

@@ -1,19 +1,20 @@
const emptyDefaults = {
started: {
message: '',
body: '',
},
success: {
message: '',
body: '',
},
error: {
message: '',
body: '',
},
started: { message: '', body: '' },
success: { message: '', body: '' },
error: { message: '', body: '' },
workflow_approval: {
approved: { message: '', body: '' },
denied: { message: '', body: '' },
running: { message: '', body: '' },
timed_out: { message: '', body: '' },
}
};
function getMessageIfUpdated(message, defaultValue) {
return message === defaultValue ? null : message;
}
export default [function() {
return {
getMessagesObj: function ($scope, defaultMessages) {
@@ -23,22 +24,34 @@ export default [function() {
const defaults = defaultMessages[$scope.notification_type.value] || {};
return {
started: {
message: $scope.started_message === defaults.started.message ?
null : $scope.started_message,
body: $scope.started_body === defaults.started.body ?
null : $scope.started_body,
message: getMessageIfUpdated($scope.started_message, defaults.started.message),
body: getMessageIfUpdated($scope.started_body, defaults.started.body),
},
success: {
message: $scope.success_message === defaults.success.message ?
null : $scope.success_message,
body: $scope.success_body === defaults.success.body ?
null : $scope.success_body,
message: getMessageIfUpdated($scope.success_message, defaults.success.message),
body: getMessageIfUpdated($scope.success_body, defaults.success.body),
},
error: {
message: $scope.error_message === defaults.error.message ?
null : $scope.error_message,
body: $scope.error_body === defaults.error.body ?
null : $scope.error_body,
message: getMessageIfUpdated($scope.error_message, defaults.error.message),
body: getMessageIfUpdated($scope.error_body, defaults.error.body),
},
workflow_approval: {
approved: {
message: getMessageIfUpdated($scope.approved_message, defaults.workflow_approval.approved.message),
body: getMessageIfUpdated($scope.approved_body, defaults.workflow_approval.approved.body),
},
denied: {
message: getMessageIfUpdated($scope.denied_message, defaults.workflow_approval.denied.message),
body: getMessageIfUpdated($scope.denied_body, defaults.workflow_approval.denied.body),
},
running: {
message: getMessageIfUpdated($scope.running_message, defaults.workflow_approval.running.message),
body: getMessageIfUpdated($scope.running_body, defaults.workflow_approval.running.body),
},
timed_out: {
message: getMessageIfUpdated($scope.timed_out_message, defaults.workflow_approval.timed_out.message),
body: getMessageIfUpdated($scope.timed_out_body, defaults.workflow_approval.timed_out.body),
},
}
};
},
@@ -56,6 +69,15 @@ export default [function() {
$scope.success_body = defaults.success.body;
$scope.error_message = defaults.error.message;
$scope.error_body = defaults.error.body;
$scope.approved_message = defaults.workflow_approval.approved.message;
$scope.approved_body = defaults.workflow_approval.approved.body;
$scope.denied_message = defaults.workflow_approval.denied.message;
$scope.denied_body = defaults.workflow_approval.denied.body;
$scope.running_message = defaults.workflow_approval.running.message;
$scope.running_body = defaults.workflow_approval.running.body;
$scope.timed_out_message = defaults.workflow_approval.timed_out.message;
$scope.timed_out_body = defaults.workflow_approval.timed_out.body;
if (!messages) {
return;
}
@@ -84,6 +106,48 @@ export default [function() {
isCustomized = true;
$scope.error_body = messages.error.body;
}
if (messages.workflow_approval) {
if (messages.workflow_approval.approved &&
messages.workflow_approval.approved.message) {
isCustomized = true;
$scope.approved_message = messages.workflow_approval.approved.message;
}
if (messages.workflow_approval.approved &&
messages.workflow_approval.approved.body) {
isCustomized = true;
$scope.approved_body = messages.workflow_approval.approved.body;
}
if (messages.workflow_approval.denied &&
messages.workflow_approval.denied.message) {
isCustomized = true;
$scope.denied_message = messages.workflow_approval.denied.message;
}
if (messages.workflow_approval.denied &&
messages.workflow_approval.denied.body) {
isCustomized = true;
$scope.denied_body = messages.workflow_approval.denied.body;
}
if (messages.workflow_approval.running &&
messages.workflow_approval.running.message) {
isCustomized = true;
$scope.running_message = messages.workflow_approval.running.message;
}
if (messages.workflow_approval.running &&
messages.workflow_approval.running.body) {
isCustomized = true;
$scope.running_body = messages.workflow_approval.running.body;
}
if (messages.workflow_approval.timed_out &&
messages.workflow_approval.timed_out.message) {
isCustomized = true;
$scope.timed_out_message = messages.workflow_approval.timed_out.message;
}
if (messages.workflow_approval.timed_out &&
messages.workflow_approval.timed_out.body) {
isCustomized = true;
$scope.timed_out_body = messages.workflow_approval.timed_out.body;
}
}
$scope.customize_messages = isCustomized;
},
@@ -110,6 +174,30 @@ export default [function() {
if ($scope.error_body === oldDefaults.error.body) {
$scope.error_body = newDefaults.error.body;
}
if ($scope.approved_message === oldDefaults.workflow_approval.approved.message) {
$scope.approved_message = newDefaults.workflow_approval.approved.message;
}
if ($scope.approved_body === oldDefaults.workflow_approval.approved.body) {
$scope.approved_body = newDefaults.workflow_approval.approved.body;
}
if ($scope.denied_message === oldDefaults.workflow_approval.denied.message) {
$scope.denied_message = newDefaults.workflow_approval.denied.message;
}
if ($scope.denied_body === oldDefaults.workflow_approval.denied.body) {
$scope.denied_body = newDefaults.workflow_approval.denied.body;
}
if ($scope.running_message === oldDefaults.workflow_approval.running.message) {
$scope.running_message = newDefaults.workflow_approval.running.message;
}
if ($scope.running_body === oldDefaults.workflow_approval.running.body) {
$scope.running_body = newDefaults.workflow_approval.running.body;
}
if ($scope.timed_out_message === oldDefaults.workflow_approval.timed_out.message) {
$scope.timed_out_message = newDefaults.workflow_approval.timed_out.message;
}
if ($scope.timed_out_body === oldDefaults.workflow_approval.timed_out.body) {
$scope.timed_out_body = newDefaults.workflow_approval.timed_out.body;
}
}
};
}];

View File

@@ -233,6 +233,38 @@ export default [ 'ProcessErrors', 'CredentialTypeModel', 'TemplatesStrings', '$f
}, true);
};
function getSelectedTags(tagId) {
const selectedTags = [];
const choiceElements = $(tagId).siblings(".select2").first()
.find(".select2-selection__choice");
choiceElements.each((index, option) => {
selectedTags.push({
value: option.title,
name: option.title,
label: option.title
});
});
return selectedTags;
}
function consolidateTags (tags, otherTags) {
const seen = [];
const consolidated = [];
tags.forEach(tag => {
if (!seen.includes(tag.value)) {
seen.push(tag.value);
consolidated.push(tag);
}
});
otherTags.forEach(tag => {
if (!seen.includes(tag.value)) {
seen.push(tag.value);
consolidated.push(tag);
}
});
return consolidated;
}
vm.next = (currentTab) => {
if(_.has(vm, 'steps.other_prompts.tab._active') && vm.steps.other_prompts.tab._active === true){
try {
@@ -243,6 +275,22 @@ export default [ 'ProcessErrors', 'CredentialTypeModel', 'TemplatesStrings', '$f
event.preventDefault();
return;
}
// The current tag input state lives somewhere in the associated select2
// widgetry and isn't directly tied to the vm, so extract the tag values
// and update the vm to keep it in sync.
if (vm.promptDataClone.launchConf.ask_tags_on_launch) {
vm.promptDataClone.prompts.tags.value = consolidateTags(
angular.copy(vm.promptDataClone.prompts.tags.value),
getSelectedTags("#job_launch_job_tags")
);
}
if (vm.promptDataClone.launchConf.ask_skip_tags_on_launch) {
vm.promptDataClone.prompts.skipTags.value = consolidateTags(
angular.copy(vm.promptDataClone.prompts.skipTags.value),
getSelectedTags("#job_launch_skip_tags")
);
}
}
let nextStep;

View File

@@ -12,19 +12,6 @@ export default
let scope;
let consolidateTags = (tagModel, tagId) => {
let tags = angular.copy(tagModel);
$(tagId).siblings(".select2").first().find(".select2-selection__choice").each((optionIndex, option) => {
tags.push({
value: option.title,
name: option.title,
label: option.title
});
});
return [...tags.reduce((map, tag) => map.has(tag.value) ? map : map.set(tag.value, tag), new Map()).values()];
};
vm.init = (_scope_) => {
scope = _scope_;
@@ -35,14 +22,6 @@ export default
const surveyPasswords = {};
if (scope.promptData.launchConf.ask_tags_on_launch) {
scope.promptData.prompts.tags.value = consolidateTags(scope.promptData.prompts.tags.value, "#job_launch_job_tags");
}
if (scope.promptData.launchConf.ask_skip_tags_on_launch) {
scope.promptData.prompts.skipTags.value = consolidateTags(scope.promptData.prompts.skipTags.value, "#job_launch_skip_tags");
}
if (scope.promptData.launchConf.survey_enabled){
scope.promptData.extraVars = ToJSON(scope.parseType, scope.promptData.prompts.variables.value, false);
scope.promptData.surveyQuestions.forEach(surveyQuestion => {