diff --git a/Makefile b/Makefile
index 9fd0fd3db4..9b307613d4 100644
--- a/Makefile
+++ b/Makefile
@@ -400,7 +400,7 @@ flake8_collection:
test_collection_all: prepare_collection_venv test_collection flake8_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
test_unit:
diff --git a/awx/__init__.py b/awx/__init__.py
index e7a5a828ec..b97a5694af 100644
--- a/awx/__init__.py
+++ b/awx/__init__.py
@@ -82,6 +82,16 @@ def find_commands(management_dir):
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():
# Update the default settings environment variable based on current 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.
import django.core.management
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
# database settings to use when management command is run as an external
# program via unit tests.
diff --git a/awx/api/filters.py b/awx/api/filters.py
index 327303dd2e..ea9d011562 100644
--- a/awx/api/filters.py
+++ b/awx/api/filters.py
@@ -126,7 +126,7 @@ class FieldLookupBackend(BaseFilterBackend):
'''
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',
'startswith', 'istartswith', 'endswith', 'iendswith',
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 14d8944dc1..bcca272a83 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -45,7 +45,6 @@ from polymorphic.models import PolymorphicModel
from awx.main.access import get_user_capabilities
from awx.main.constants import (
SCHEDULEABLE_PROVIDERS,
- ANSI_SGR_PATTERN,
ACTIVE_STATES,
CENSOR_VALUE,
)
@@ -70,7 +69,8 @@ from awx.main.utils import (
get_type_for_model, get_model_for_type,
camelcase_to_underscore, getattrd, parse_yaml_or_json,
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.redact import UriCleaner, REPLACE_STR
@@ -140,6 +140,7 @@ SUMMARIZABLE_FK_FIELDS = {
'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'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})
res['approve'] = self.reverse('api:workflow_approval_approve', 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
@@ -3851,25 +3854,17 @@ class JobEventSerializer(BaseSerializer):
return d
def to_representation(self, obj):
- ret = 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
+ data = super(JobEventSerializer, self).to_representation(obj)
# Show full stdout for playbook_on_* events.
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
- if max_bytes > 0 and 'stdout' in ret and len(ret['stdout']) >= max_bytes:
- ret['stdout'] = ret['stdout'][:(max_bytes - 1)] + u'\u2026'
- set_count = 0
- 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
+ if 'stdout' in data:
+ data['stdout'] = truncate_stdout(data['stdout'], max_bytes)
+ return data
class JobEventWebSocketSerializer(JobEventSerializer):
@@ -3964,22 +3959,14 @@ class AdHocCommandEventSerializer(BaseSerializer):
return res
def to_representation(self, obj):
- ret = super(AdHocCommandEventSerializer, 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
+ data = super(AdHocCommandEventSerializer, self).to_representation(obj)
+ # 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
- if max_bytes > 0 and 'stdout' in ret and len(ret['stdout']) >= max_bytes:
- ret['stdout'] = ret['stdout'][:(max_bytes - 1)] + u'\u2026'
- set_count = 0
- 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
+ if 'stdout' in data:
+ data['stdout'] = truncate_stdout(data['stdout'], max_bytes)
+ return data
class AdHocCommandEventWebSocketSerializer(AdHocCommandEventSerializer):
diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py
index 383c7aeee9..f337345df9 100644
--- a/awx/api/views/__init__.py
+++ b/awx/api/views/__init__.py
@@ -3768,12 +3768,23 @@ class JobEventList(ListAPIView):
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):
model = models.JobEvent
serializer_class = serializers.JobEventSerializer
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ context.update(no_truncate=True)
+ return context
+
class JobEventChildrenList(SubListAPIView):
@@ -4002,12 +4013,23 @@ class AdHocCommandEventList(ListAPIView):
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):
model = models.AdHocCommandEvent
serializer_class = serializers.AdHocCommandEventSerializer
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ context.update(no_truncate=True)
+ return context
+
class BaseAdHocCommandEventsList(SubListAPIView):
diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py
index dfa3f6627a..987f5467b4 100644
--- a/awx/api/views/inventory.py
+++ b/awx/api/views/inventory.py
@@ -70,12 +70,16 @@ class InventoryUpdateEventsList(SubListAPIView):
class InventoryScriptList(ListCreateAPIView):
+ deprecated = True
+
model = CustomInventoryScript
serializer_class = CustomInventoryScriptSerializer
class InventoryScriptDetail(RetrieveUpdateDestroyAPIView):
+ deprecated = True
+
model = CustomInventoryScript
serializer_class = CustomInventoryScriptSerializer
@@ -92,6 +96,8 @@ class InventoryScriptDetail(RetrieveUpdateDestroyAPIView):
class InventoryScriptObjectRolesList(SubListAPIView):
+ deprecated = True
+
model = Role
serializer_class = RoleSerializer
parent_model = CustomInventoryScript
@@ -105,6 +111,8 @@ class InventoryScriptObjectRolesList(SubListAPIView):
class InventoryScriptCopy(CopyAPIView):
+ deprecated = True
+
model = CustomInventoryScript
copy_return_serializer_class = CustomInventoryScriptSerializer
diff --git a/awx/main/conf.py b/awx/main/conf.py
index 09e94485db..d75e254d1e 100644
--- a/awx/main/conf.py
+++ b/awx/main/conf.py
@@ -755,7 +755,7 @@ register(
allow_null=True,
default=False,
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_slug='logging',
)
diff --git a/awx/main/migrations/0097_v360_workflowapproval_approved_or_denied_by.py b/awx/main/migrations/0097_v360_workflowapproval_approved_or_denied_by.py
new file mode 100644
index 0000000000..84bf80c7f6
--- /dev/null
+++ b/awx/main/migrations/0097_v360_workflowapproval_approved_or_denied_by.py
@@ -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),
+ ),
+ ]
diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py
index d4a5f12914..58aee91d96 100644
--- a/awx/main/models/schedules.py
+++ b/awx/main/models/schedules.py
@@ -119,10 +119,11 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
tzinfo = r._dtstart.tzinfo
if tzinfo is utc:
return 'UTC'
- fname = tzinfo._filename
- for zone in all_zones:
- if fname.endswith(zone):
- return zone
+ fname = getattr(tzinfo, '_filename', None)
+ if fname:
+ for zone in all_zones:
+ if fname.endswith(zone):
+ return zone
logger.warn('Could not detect valid zoneinfo for {}'.format(self.rrule))
return ''
diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py
index 56f5cfd579..bcfee585b8 100644
--- a/awx/main/models/workflow.py
+++ b/awx/main/models/workflow.py
@@ -13,6 +13,9 @@ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist
#from django import settings as tower_settings
+# Django-CRUM
+from crum import get_current_user
+
# AWX
from awx.api.versioning import reverse
from awx.main.models import (prevent_search, accepts_json, UnifiedJobTemplate,
@@ -690,6 +693,14 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
default=False,
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
@@ -711,6 +722,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
def approve(self, request=None):
self.status = 'successful'
+ self.approved_or_denied_by = get_current_user()
self.save()
self.send_approval_notification('approved')
self.websocket_emit_status(self.status)
@@ -719,6 +731,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
def deny(self, request=None):
self.status = 'failed'
+ self.approved_or_denied_by = get_current_user()
self.save()
self.send_approval_notification('denied')
self.websocket_emit_status(self.status)
diff --git a/awx/main/scheduler/kubernetes.py b/awx/main/scheduler/kubernetes.py
index 1aa1978276..90f2849c3d 100644
--- a/awx/main/scheduler/kubernetes.py
+++ b/awx/main/scheduler/kubernetes.py
@@ -3,6 +3,7 @@ import stat
import time
import yaml
import tempfile
+import logging
from base64 import b64encode
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
+logger = logging.getLogger('awx.main.scheduler')
+
class PodManager(object):
@@ -21,32 +24,33 @@ class PodManager(object):
if not self.credential.kubernetes:
raise RuntimeError('Pod deployment cannot occur without a Kubernetes credential')
-
self.kube_api.create_namespaced_pod(body=self.pod_definition,
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
- # at a higher level in the job spawning process. See
- # settings.AWX_ISOLATED_LAUNCH_TIMEOUT and settings.AWX_ISOLATED_CONNECTION_TIMEOUT
- while True:
+ num_retries = settings.AWX_CONTAINER_GROUP_POD_LAUNCH_RETRIES
+ for retry_attempt in range(num_retries - 1):
+ logger.debug(f"Checking for pod {self.pod_name}. Attempt {retry_attempt + 1} of {num_retries}")
pod = self.kube_api.read_namespaced_pod(name=self.pod_name,
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':
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':
+ logger.debug(f"Pod {self.pod_name} is online.")
return pod
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):
return self.kube_api.delete_namespaced_pod(name=self.pod_name,
namespace=self.namespace,
- _request_timeout=settings.AWX_CONTAINER_GROUP_DEFAULT_LAUNCH_TIMEOUT)
+ _request_timeout=settings.AWX_CONTAINER_GROUP_K8S_API_TIMEOUT)
@property
def namespace(self):
diff --git a/awx/main/tasks.py b/awx/main/tasks.py
index eb2d48546d..5a7fea3bf3 100644
--- a/awx/main/tasks.py
+++ b/awx/main/tasks.py
@@ -40,6 +40,9 @@ from django.utils.translation import ugettext_lazy as _
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
+# Kubernetes
+from kubernetes.client.rest import ApiException
+
# Django-CRUM
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_group_removal, extract_ansible_vars, schedule_task_manager,
get_awx_version)
+from awx.main.utils.ansible import read_ansible_config
from awx.main.utils.common import get_ansible_version, _get_ansible_version, get_custom_venv_choices
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
from awx.main.utils.reload import stop_local_services
@@ -1183,6 +1187,18 @@ class BaseTask(object):
'''
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 = self.update_model(pk, status='running',
start_args='') # blank field to remove encrypted passwords
@@ -1208,7 +1224,6 @@ class BaseTask(object):
try:
isolated = self.instance.is_isolated()
- containerized = self.instance.is_containerized
self.instance.send_notification_templates("running")
private_data_dir = self.build_private_data_dir(self.instance)
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):
params['module'] = self.build_module_name(self.instance)
params['module_args'] = self.build_module_args(self.instance)
@@ -1316,16 +1335,6 @@ class BaseTask(object):
params.pop('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)
isolated_manager_instance = isolated_manager.IsolatedManager(
@@ -1385,6 +1394,42 @@ class BaseTask(object):
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()
class RunJob(BaseTask):
'''
@@ -1529,14 +1574,22 @@ class RunJob(BaseTask):
if authorize:
env['ANSIBLE_NET_AUTH_PASS'] = network_cred.get_input('authorize_password', default='')
- for env_key, folder, default in (
- ('ANSIBLE_COLLECTIONS_PATHS', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
- ('ANSIBLE_ROLES_PATH', 'requirements_roles', '~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles')):
+ path_vars = (
+ ('ANSIBLE_COLLECTIONS_PATHS', 'collections_paths', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
+ ('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(':')
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)
@@ -1790,7 +1843,10 @@ class RunJob(BaseTask):
if job.is_containerized:
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:
inventory = job.inventory
diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py
index 77c13fffd2..2bae5aef9a 100644
--- a/awx/main/tests/unit/test_tasks.py
+++ b/awx/main/tests/unit/test_tasks.py
@@ -368,6 +368,7 @@ class TestGenericRun():
task = tasks.RunJob()
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())
with mock.patch('awx.main.tasks.copy_tree'):
@@ -387,6 +388,7 @@ class TestGenericRun():
task = tasks.RunJob()
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()
with mock.patch('awx.main.tasks.copy_tree'):
@@ -578,6 +580,7 @@ class TestAdhocRun(TestJobExecution):
task = tasks.RunAdHocCommand()
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()
with pytest.raises(Exception):
diff --git a/awx/main/utils/ansible.py b/awx/main/utils/ansible.py
index 7e68d88189..fa15c47ab1 100644
--- a/awx/main/utils/ansible.py
+++ b/awx/main/utils/ansible.py
@@ -5,11 +5,15 @@
import codecs
import re
import os
+import logging
from itertools import islice
+from configparser import ConfigParser
# Django
from django.utils.encoding import smart_str
+logger = logging.getLogger('awx.main.utils.ansible')
+
__all__ = ['skip_directory', 'could_be_playbook', 'could_be_inventory']
@@ -97,3 +101,20 @@ def could_be_inventory(project_path, dir_path, filename):
except IOError:
return None
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
diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py
index d36dfa272b..11e4722f5f 100644
--- a/awx/main/utils/common.py
+++ b/awx/main/utils/common.py
@@ -38,18 +38,22 @@ from django.apps import apps
logger = logging.getLogger('awx.main.utils')
-__all__ = ['get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelcase', 'memoize', 'memoize_delete',
- 'get_ansible_version', 'get_ssh_version', 'get_licenser', 'get_awx_version', 'update_scm_url',
- 'get_type_for_model', 'get_model_for_type', 'copy_model_by_class', 'region_sorting',
- 'copy_m2m_relationships', 'prefetch_page_capabilities', 'to_python_boolean',
- 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal',
- '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'getattr_dne', 'NoDefaultProvided',
- 'get_current_apps', 'set_current_apps',
- 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity',
- 'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict',
- 'NullablePromptPseudoField', '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']
+__all__ = [
+ 'get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelcase', 'memoize',
+ 'memoize_delete', 'get_ansible_version', 'get_ssh_version', 'get_licenser',
+ 'get_awx_version', 'update_scm_url', 'get_type_for_model', 'get_model_for_type',
+ 'copy_model_by_class', 'region_sorting', 'copy_m2m_relationships',
+ 'prefetch_page_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields',
+ 'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd',
+ 'getattr_dne', 'NoDefaultProvided', 'get_current_apps', 'set_current_apps',
+ 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity',
+ 'get_cpu_capacity', 'get_mem_capacity', 'wrap_args_with_proot', 'build_proot_temp_dir',
+ 'check_proot_installed', 'model_to_dict', 'NullablePromptPseudoField',
+ '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):
@@ -1088,3 +1092,19 @@ def create_temporary_fifo(data):
).start()
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)
diff --git a/awx/plugins/inventory/vmware_inventory.py b/awx/plugins/inventory/vmware_inventory.py
index 310194e164..183b9a19b0 100755
--- a/awx/plugins/inventory/vmware_inventory.py
+++ b/awx/plugins/inventory/vmware_inventory.py
@@ -39,8 +39,9 @@ import uuid
from time import time
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:
import argparse
@@ -152,7 +153,7 @@ class VMWareInventory(object):
try:
text = str(text)
except UnicodeEncodeError:
- text = text.encode('ascii', 'ignore')
+ text = text.encode('utf-8')
print('%s %s' % (datetime.datetime.now(), text))
def show(self):
@@ -186,14 +187,14 @@ class VMWareInventory(object):
def write_to_cache(self, data):
''' Dump inventory to json file '''
- with open(self.cache_path_cache, 'wb') as f:
- f.write(json.dumps(data))
+ with open(self.cache_path_cache, 'w') as f:
+ f.write(json.dumps(data, indent=2))
def get_inventory_from_cache(self):
''' Read in jsonified inventory '''
jdata = None
- with open(self.cache_path_cache, 'rb') as f:
+ with open(self.cache_path_cache, 'r') as f:
jdata = f.read()
return json.loads(jdata)
@@ -343,10 +344,22 @@ class VMWareInventory(object):
'pwd': self.password,
'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.verify_mode = ssl.CERT_NONE
+ context.check_hostname = False
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)
@@ -390,7 +403,7 @@ class VMWareInventory(object):
instances = [x for x in instances if x.name == self.args.host]
instance_tuples = []
- for instance in sorted(instances):
+ for instance in instances:
if self.guest_props:
ifacts = self.facts_from_proplist(instance)
else:
@@ -614,7 +627,14 @@ class VMWareInventory(object):
lastref = lastref[x]
else:
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
def facts_from_vobj(self, vobj, level=0):
@@ -685,7 +705,7 @@ class VMWareInventory(object):
if vobj.isalnum():
rdata = vobj
else:
- rdata = vobj.decode('ascii', 'ignore')
+ rdata = vobj.encode('utf-8').decode('utf-8')
elif issubclass(type(vobj), bool) or isinstance(vobj, bool):
rdata = vobj
elif issubclass(type(vobj), integer_types) or isinstance(vobj, integer_types):
diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py
index 639d19f9e8..07c76f8b01 100644
--- a/awx/settings/defaults.py
+++ b/awx/settings/defaults.py
@@ -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_IMAGE = 'ansible/ansible-runner'
diff --git a/awx/ui/client/legacy/styles/lists.less b/awx/ui/client/legacy/styles/lists.less
index 3e004461af..8357a78b78 100644
--- a/awx/ui/client/legacy/styles/lists.less
+++ b/awx/ui/client/legacy/styles/lists.less
@@ -372,9 +372,7 @@ table, tbody {
.List-noItems {
margin-top: 52px;
- display: flex;
- align-items: center;
- justify-content: center;
+ display: inline-block;
width: 100%;
height: 200px;
border-radius: 5px;
@@ -383,7 +381,7 @@ table, tbody {
color: @list-no-items-txt;
text-transform: uppercase;
text-align: center;
- padding: 10px;
+ padding: 80px 10px;
}
.modal-body > .List-noItems {
diff --git a/awx/ui/client/lib/components/code-mirror/_index.less b/awx/ui/client/lib/components/code-mirror/_index.less
index 6eeb936e79..98c258d528 100644
--- a/awx/ui/client/lib/components/code-mirror/_index.less
+++ b/awx/ui/client/lib/components/code-mirror/_index.less
@@ -52,7 +52,6 @@
height: calc(~"100vh - 80px");
}
-
@media screen and (min-width: 768px){
.NetworkingExtraVars .modal-dialog{
width: 700px;
diff --git a/awx/ui/client/lib/components/tabs/tab.directive.js b/awx/ui/client/lib/components/tabs/tab.directive.js
index 4c7eea3785..85dc5f4d2c 100644
--- a/awx/ui/client/lib/components/tabs/tab.directive.js
+++ b/awx/ui/client/lib/components/tabs/tab.directive.js
@@ -20,16 +20,18 @@ function AtTabController ($state) {
group.register(scope);
};
- vm.go = () => {
+ vm.handleClick = () => {
if (scope.state._disabled || scope.state._active) {
return;
}
-
if (scope.state._go) {
$state.go(scope.state._go, scope.state._params, { reload: true });
- } else {
- group.clearActive();
- scope.state._active = true;
+ return;
+ }
+ group.clearActive();
+ scope.state._active = true;
+ if (scope.state._onClickActivate) {
+ scope.state._onClickActivate();
}
};
}
diff --git a/awx/ui/client/lib/components/tabs/tab.partial.html b/awx/ui/client/lib/components/tabs/tab.partial.html
index eb007feb01..5b77e02841 100644
--- a/awx/ui/client/lib/components/tabs/tab.partial.html
+++ b/awx/ui/client/lib/components/tabs/tab.partial.html
@@ -2,6 +2,6 @@
ng-attr-disabled="{{ state._disabled || undefined }}"
ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }"
ng-hide="{{ state._hide }}"
- ng-click="state._go && vm.go();">
+ ng-click="vm.handleClick();">