diff options
64 files changed, 1027 insertions, 191 deletions
diff --git a/INSTALL.md b/INSTALL.md index f9b91d78b8..734185b5e5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -120,6 +120,8 @@ If these variables are present then all deployments will use these hosted images To complete a deployment to OpenShift, you will obviously need access to an OpenShift cluster. For demo and testing purposes, you can use [Minishift](https://github.com/minishift/minishift) to create a single node cluster running inside a virtual machine. +When using OpenShift for deploying AWX make sure you have correct privileges to add the security context 'privileged', otherwise the installation will fail. The privileged context is needed because of the use of [the bubblewrap tool](https://github.com/containers/bubblewrap) to add an additional layer of security when using containers. + You will also need to have the `oc` command in your PATH. The `install.yml` playbook will call out to `oc` when logging into, and creating objects on the cluster. The default resource requests per-deployment requires: @@ -189,7 +189,7 @@ requirements_awx: virtualenv_awx cat requirements/requirements.txt requirements/requirements_git.txt | $(VENV_BASE)/awx/bin/pip install $(PIP_OPTIONS) --no-binary $(SRC_ONLY_PKGS) --ignore-installed -r /dev/stdin ; \ fi echo "include-system-site-packages = true" >> $(VENV_BASE)/awx/lib/python$(PYTHON_VERSION)/pyvenv.cfg - #$(VENV_BASE)/awx/bin/pip uninstall --yes -r requirements/requirements_tower_uninstall.txt + $(VENV_BASE)/awx/bin/pip uninstall --yes -r requirements/requirements_tower_uninstall.txt requirements_awx_dev: $(VENV_BASE)/awx/bin/pip install -r requirements/requirements_dev.txt @@ -1 +1 @@ -7.0.0 +8.0.0 diff --git a/awx/api/generics.py b/awx/api/generics.py index 6a78329368..37ca8bef42 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -92,7 +92,7 @@ class LoggedLoginView(auth_views.LoginView): ret = super(LoggedLoginView, self).post(request, *args, **kwargs) current_user = getattr(request, 'user', None) if request.user.is_authenticated: - logger.info(smart_text(u"User {} logged in.".format(self.request.user.username))) + logger.info(smart_text(u"User {} logged in from {}".format(self.request.user.username,request.META.get('REMOTE_ADDR', None)))) ret.set_cookie('userLoggedIn', 'true') current_user = UserSerializer(self.request.user) current_user = smart_text(JSONRenderer().render(current_user.data)) @@ -574,7 +574,7 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): status=status.HTTP_400_BAD_REQUEST) # Verify we have permission to add the object as given. - if not request.user.can_access(self.model, 'add', serializer.initial_data): + if not request.user.can_access(self.model, 'add', serializer.validated_data): raise PermissionDenied() # save the object through the serializer, reload and returned the saved diff --git a/awx/api/serializers.py b/awx/api/serializers.py index bcca272a83..818160ac3b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4406,6 +4406,8 @@ class NotificationTemplateSerializer(BaseSerializer): for event in messages: if not messages[event]: continue + if not isinstance(messages[event], dict): + continue body = messages[event].get('body', {}) if body: try: diff --git a/awx/main/analytics/core.py b/awx/main/analytics/core.py index 34e4fc9f90..02a586b7eb 100644 --- a/awx/main/analytics/core.py +++ b/awx/main/analytics/core.py @@ -88,8 +88,8 @@ def gather(dest=None, module=None, collection_type='scheduled'): logger.exception("Invalid License provided, or No License Provided") return "Error: Invalid License provided, or No License Provided" - if not settings.INSIGHTS_TRACKING_STATE: - logger.error("Automation Analytics not enabled") + if collection_type != 'dry-run' and not settings.INSIGHTS_TRACKING_STATE: + logger.error("Automation Analytics not enabled. Use --dry-run to gather locally without sending.") return if module is None: @@ -167,7 +167,7 @@ def ship(path): files = {'file': (os.path.basename(path), f, settings.INSIGHTS_AGENT_MIME)} response = requests.post(url, files=files, - verify=True, + verify="/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", auth=(rh_user, rh_password), timeout=(31, 31)) if response.status_code != 202: diff --git a/awx/main/conf.py b/awx/main/conf.py index d75e254d1e..cce5e0a5de 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -351,8 +351,9 @@ register( 'AWX_RESOURCE_PROFILING_ENABLED', field_class=fields.BooleanField, default=False, - label=_('Enable resource profiling on all tower jobs'), - help_text=_('If set, resource profiling data will be collected on all jobs.'), # noqa + label=_('Enable detailed resource profiling on all playbook runs'), + help_text=_('If set, detailed resource profiling data will be collected on all jobs. ' + 'This data can be gathered with `sosreport`.'), # noqa category=_('Jobs'), category_slug='jobs', ) @@ -362,7 +363,8 @@ register( field_class=FloatField, default='0.25', label=_('Interval (in seconds) between polls for cpu usage.'), - help_text=_('Interval (in seconds) between polls for cpu usage.'), + help_text=_('Interval (in seconds) between polls for cpu usage. ' + 'Setting this lower than the default will affect playbook performance.'), category=_('Jobs'), category_slug='jobs', required=False, @@ -373,7 +375,8 @@ register( field_class=FloatField, default='0.25', label=_('Interval (in seconds) between polls for memory usage.'), - help_text=_('Interval (in seconds) between polls for memory usage.'), + help_text=_('Interval (in seconds) between polls for memory usage. ' + 'Setting this lower than the default will affect playbook performance.'), category=_('Jobs'), category_slug='jobs', required=False, @@ -384,7 +387,8 @@ register( field_class=FloatField, default='0.25', label=_('Interval (in seconds) between polls for PID count.'), - help_text=_('Interval (in seconds) between polls for PID count.'), + help_text=_('Interval (in seconds) between polls for PID count. ' + 'Setting this lower than the default will affect playbook performance.'), category=_('Jobs'), category_slug='jobs', required=False, diff --git a/awx/main/credential_plugins/aim.py b/awx/main/credential_plugins/aim.py index a5bac71fdd..f9e0076b40 100644 --- a/awx/main/credential_plugins/aim.py +++ b/awx/main/credential_plugins/aim.py @@ -101,7 +101,7 @@ def aim_backend(**kwargs): aim_plugin = CredentialPlugin( - 'CyberArk AIM Secret Lookup', + 'CyberArk AIM Central Credential Provider Lookup', inputs=aim_inputs, backend=aim_backend ) diff --git a/awx/main/isolated/manager.py b/awx/main/isolated/manager.py index d78587cf7b..5a0555ed72 100644 --- a/awx/main/isolated/manager.py +++ b/awx/main/isolated/manager.py @@ -172,6 +172,7 @@ class IsolatedManager(object): if runner_obj.status == 'failed': self.instance.result_traceback = runner_obj.stdout.read() self.instance.save(update_fields=['result_traceback']) + return 'error', runner_obj.rc return runner_obj.status, runner_obj.rc diff --git a/awx/main/management/commands/gather_analytics.py b/awx/main/management/commands/gather_analytics.py index 8f66b6f12a..aa096d6f28 100644 --- a/awx/main/management/commands/gather_analytics.py +++ b/awx/main/management/commands/gather_analytics.py @@ -11,6 +11,8 @@ class Command(BaseCommand): help = 'Gather AWX analytics data' def add_arguments(self, parser): + parser.add_argument('--dry-run', dest='dry-run', action='store_true', + help='Gather analytics without shipping. Works even if analytics are disabled in settings.') parser.add_argument('--ship', dest='ship', action='store_true', help='Enable to ship metrics to the Red Hat Cloud') @@ -23,9 +25,14 @@ class Command(BaseCommand): self.logger.propagate = False def handle(self, *args, **options): - tgz = gather(collection_type='manual') self.init_logging() + opt_ship = options.get('ship') + opt_dry_run = options.get('dry-run') + if opt_ship and opt_dry_run: + self.logger.error('Both --ship and --dry-run cannot be processed at the same time.') + return + tgz = gather(collection_type='manual' if not opt_dry_run else 'dry-run') if tgz: self.logger.debug(tgz) - if options.get('ship'): + if opt_ship: ship(tgz) diff --git a/awx/main/migrations/0098_v360_rename_cyberark_aim_credential_type.py b/awx/main/migrations/0098_v360_rename_cyberark_aim_credential_type.py new file mode 100644 index 0000000000..0bd03b94ba --- /dev/null +++ b/awx/main/migrations/0098_v360_rename_cyberark_aim_credential_type.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.4 on 2019-10-16 19:51 + +from django.db import migrations +from awx.main.models import CredentialType + + +def update_cyberark_aim_name(apps, schema_editor): + CredentialType.setup_tower_managed_defaults() + aim_types = apps.get_model('main', 'CredentialType').objects.filter( + namespace='aim' + ).order_by('id') + + if aim_types.count() == 2: + original, renamed = aim_types.all() + apps.get_model('main', 'Credential').objects.filter( + credential_type_id=original.id + ).update( + credential_type_id=renamed.id + ) + original.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0097_v360_workflowapproval_approved_or_denied_by'), + ] + + operations = [ + migrations.RunPython(update_cyberark_aim_name) + ] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index ce3295cc69..0e0f50e5b3 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -86,6 +86,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): unique_together = (('organization', 'name', 'credential_type')) PASSWORD_FIELDS = ['inputs'] + FIELDS_TO_PRESERVE_AT_COPY = ['input_sources'] credential_type = models.ForeignKey( 'CredentialType', @@ -1162,6 +1163,8 @@ class CredentialInputSource(PrimordialModel): unique_together = (('target_credential', 'input_field_name'),) ordering = ('target_credential', 'source_credential', 'input_field_name',) + FIELDS_TO_PRESERVE_AT_COPY = ['source_credential', 'metadata', 'input_field_name'] + target_credential = models.ForeignKey( 'Credential', related_name='input_sources', diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 65be4db925..d573d1ed96 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -629,15 +629,17 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana @property def task_impact(self): - # NOTE: We sorta have to assume the host count matches and that forks default to 5 - from awx.main.models.inventory import Host if self.launch_type == 'callback': count_hosts = 2 else: - count_hosts = Host.objects.filter(inventory__jobs__pk=self.pk).count() - if self.job_slice_count > 1: - # Integer division intentional - count_hosts = (count_hosts + self.job_slice_count - self.job_slice_number) // self.job_slice_count + # If for some reason we can't count the hosts then lets assume the impact as forks + if self.inventory is not None: + count_hosts = self.inventory.hosts.count() + if self.job_slice_count > 1: + # Integer division intentional + count_hosts = (count_hosts + self.job_slice_count - self.job_slice_number) // self.job_slice_count + else: + count_hosts = 5 if self.forks == 0 else self.forks return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1 @property diff --git a/awx/main/scheduler/kubernetes.py b/awx/main/scheduler/kubernetes.py index 90f2849c3d..00f82a3859 100644 --- a/awx/main/scheduler/kubernetes.py +++ b/awx/main/scheduler/kubernetes.py @@ -1,3 +1,4 @@ +import collections import os import stat import time @@ -47,6 +48,27 @@ class PodManager(object): else: logger.warn(f"Pod {self.pod_name} did not start. Status is {pod.status.phase}.") + @classmethod + def list_active_jobs(self, instance_group): + task = collections.namedtuple('Task', 'id instance_group')( + id='', + instance_group=instance_group + ) + pm = PodManager(task) + try: + for pod in pm.kube_api.list_namespaced_pod( + pm.namespace, + label_selector='ansible-awx={}'.format(settings.INSTALL_UUID) + ).to_dict().get('items', []): + job = pod['metadata'].get('labels', {}).get('ansible-awx-job-id') + if job: + try: + yield int(job) + except ValueError: + pass + except Exception: + logger.exception('Failed to list pods for container group {}'.format(instance_group)) + def delete(self): return self.kube_api.delete_namespaced_pod(name=self.pod_name, namespace=self.namespace, @@ -71,7 +93,7 @@ class PodManager(object): @property def pod_name(self): - return f"job-{self.task.id}" + return f"awx-job-{self.task.id}" @property def pod_definition(self): @@ -102,6 +124,10 @@ class PodManager(object): if self.task: pod_spec['metadata']['name'] = self.pod_name + pod_spec['metadata']['labels'] = { + 'ansible-awx': settings.INSTALL_UUID, + 'ansible-awx-job-id': str(self.task.id) + } pod_spec['spec']['containers'][0]['name'] = self.pod_name return pod_spec diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index df23b7136e..4c8ca36960 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -253,6 +253,18 @@ class TaskManager(): task.log_format, task.execution_node, controller_node)) elif rampart_group.is_containerized: task.instance_group = rampart_group + if not task.supports_isolation(): + # 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 else: task.instance_group = rampart_group if instance is not None: diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 5a7fea3bf3..ff53cd00ac 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -459,6 +459,25 @@ def cluster_node_heartbeat(): @task(queue=get_local_queuename) +def awx_k8s_reaper(): + from awx.main.scheduler.kubernetes import PodManager # prevent circular import + for group in InstanceGroup.objects.filter(credential__isnull=False).iterator(): + if group.is_containerized: + logger.debug("Checking for orphaned k8s pods for {}.".format(group)) + for job in UnifiedJob.objects.filter( + pk__in=list(PodManager.list_active_jobs(group)) + ).exclude(status__in=ACTIVE_STATES): + logger.debug('{} is no longer active, reaping orphaned k8s pod'.format(job.log_format)) + try: + PodManager(job).delete() + except Exception: + logger.exception("Failed to delete orphaned pod {} from {}".format( + job.log_format, group + )) + + + +@task(queue=get_local_queuename) def awx_isolated_heartbeat(): local_hostname = settings.CLUSTER_HOST_ID logger.debug("Controlling node checking for any isolated management tasks.") @@ -1094,6 +1113,13 @@ class BaseTask(object): if os.path.isdir(job_profiling_dir): shutil.copytree(job_profiling_dir, os.path.join(awx_profiling_dir, str(instance.pk))) + if instance.is_containerized: + from awx.main.scheduler.kubernetes import PodManager # prevent circular import + pm = PodManager(instance) + logger.debug(f"Deleting pod {pm.pod_name}") + pm.delete() + + def event_handler(self, event_data): # # ⚠️ D-D-D-DANGER ZONE ⚠️ @@ -1841,13 +1867,6 @@ class RunJob(BaseTask): if isolated_manager_instance and not job.is_containerized: isolated_manager_instance.cleanup() - if job.is_containerized: - from awx.main.scheduler.kubernetes import PodManager # prevent circular import - pm = PodManager(job) - logger.debug(f"Deleting pod {pm.pod_name}") - pm.delete() - - try: inventory = job.inventory except Inventory.DoesNotExist: diff --git a/awx/main/utils/handlers.py b/awx/main/utils/handlers.py index 249c16a119..82d3a285bb 100644 --- a/awx/main/utils/handlers.py +++ b/awx/main/utils/handlers.py @@ -294,18 +294,19 @@ class AWXProxyHandler(logging.Handler): super(AWXProxyHandler, self).__init__(**kwargs) self._handler = None self._old_kwargs = {} - self._auditor = logging.handlers.RotatingFileHandler( - filename='/var/log/tower/external.log', - maxBytes=1024 * 1024 * 50, # 50 MB - backupCount=5, - ) - - class WritableLogstashFormatter(LogstashFormatter): - @classmethod - def serialize(cls, message): - return json.dumps(message) - - self._auditor.setFormatter(WritableLogstashFormatter()) + if settings.LOG_AGGREGATOR_AUDIT: + self._auditor = logging.handlers.RotatingFileHandler( + filename='/var/log/tower/external.log', + maxBytes=1024 * 1024 * 50, # 50 MB + backupCount=5, + ) + + class WritableLogstashFormatter(LogstashFormatter): + @classmethod + def serialize(cls, message): + return json.dumps(message) + + self._auditor.setFormatter(WritableLogstashFormatter()) def get_handler_class(self, protocol): return HANDLER_MAPPING.get(protocol, AWXNullHandler) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 07c76f8b01..658d41d6b3 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -479,6 +479,11 @@ CELERYBEAT_SCHEDULE = { 'schedule': timedelta(seconds=20), 'options': {'expires': 20} }, + 'k8s_reaper': { + 'task': 'awx.main.tasks.awx_k8s_reaper', + 'schedule': timedelta(seconds=60), + 'options': {'expires': 50,} + }, # 'isolated_heartbeat': set up at the end of production.py and development.py } AWX_INCONSISTENT_TASK_INTERVAL = 60 * 3 diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 07124d8bd9..7f171f728a 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -392,7 +392,8 @@ function last () { return lastPage(); } - return lastRange(); + return lastRange() + .then(() => previousRange()); } function next () { diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js index 161aebceb3..3dad042aa4 100644 --- a/awx/ui/client/features/output/render.service.js +++ b/awx/ui/client/features/output/render.service.js @@ -213,6 +213,18 @@ 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). + // 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. + // + // Although we're not going to render the blank line, the actual line count of + // the zero-length stdout string, which is 1, has already been recorded at this + // point so we must also go back and set the event's recorded line length to 0 + // in order to avoid deleting too many lines when we need to pop or shift a + // page that contains this event off of the view. + this.records[record.uuid].lineCount = 0; return { html: '', count: 0 }; } @@ -473,7 +485,7 @@ function JobRenderService ($q, $compile, $sce, $window) { this.shift = lines => { // We multiply by two here under the assumption that one element and one text node // is generated for each line of output. - const count = 2 * lines; + const count = (2 * lines) + 1; const elements = this.el.contents().slice(0, count); return this.remove(elements); @@ -482,7 +494,7 @@ function JobRenderService ($q, $compile, $sce, $window) { this.pop = lines => { // We multiply by two here under the assumption that one element and one text node // is generated for each line of output. - const count = 2 * lines; + const count = (2 * lines) + 1; const elements = this.el.contents().slice(-count); return this.remove(elements); @@ -558,7 +570,7 @@ function JobRenderService ($q, $compile, $sce, $window) { } const max = this.state.tail; - const min = max - count; + const min = max - count + 1; let lines = 0; @@ -589,7 +601,7 @@ function JobRenderService ($q, $compile, $sce, $window) { } const min = this.state.head; - const max = min + count; + const max = min + count - 1; let lines = 0; diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index 09ef3e52f2..cb8299be89 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ import { OUTPUT_SEARCH_DOCLINK, OUTPUT_SEARCH_FIELDS, @@ -17,7 +18,7 @@ function toggleSearchKey () { } function getCurrentQueryset () { - const { job_event_search } = $state.params; // eslint-disable-line camelcase + const { job_event_search } = $state.params; return qs.decodeArr(job_event_search); } @@ -114,12 +115,13 @@ function JobSearchController (_$state_, _qs_, _strings_, { subscribe }) { vm.key = false; vm.rejected = false; vm.disabled = true; - vm.running = false; + vm.isJobActive = false; vm.tags = getSearchTags(getCurrentQueryset()); - unsubscribe = subscribe(({ running }) => { - vm.disabled = running; - vm.running = running; + unsubscribe = subscribe(({ running, event_processing_finished }) => { + const isJobActive = running || !event_processing_finished; + vm.disabled = isJobActive; + vm.isJobActive = isJobActive; }); }; diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index 602270bd95..b6e6a7342f 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -7,7 +7,7 @@ ng-disabled="vm.disabled" ng-class="{ 'at-Input--rejected': vm.rejected }" ng-model="vm.value" - ng-attr-placeholder="{{ vm.running ? + ng-attr-placeholder="{{ vm.isJobActive ? vm.strings.get('search.PLACEHOLDER_RUNNING') : vm.strings.get('search.PLACEHOLDER_DEFAULT') }}"> <span class="input-group-btn input-group-append"> diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index ad3bbd8886..f661d5b491 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -50,7 +50,8 @@ function JobStatusService (moment, message) { inventoryScm: { id: model.get('source_project_update'), status: model.get('summary_fields.inventory_source.status') - } + }, + event_processing_finished: model.get('event_processing_finished'), }; this.initHostStatusCounts({ model }); @@ -309,6 +310,10 @@ function JobStatusService (moment, message) { this.state.resultTraceback = traceback; }; + this.setEventProcessingFinished = val => { + this.state.event_processing_finished = val; + }; + this.setHostStatusCounts = counts => { counts = counts || {}; @@ -348,6 +353,7 @@ function JobStatusService (moment, message) { this.setArtifacts(model.get('artifacts')); this.setExecutionNode(model.get('execution_node')); this.setResultTraceback(model.get('result_traceback')); + this.setEventProcessingFinished(model.get('event_processing_finished')); this.initHostStatusCounts({ model }); this.initPlaybookCounts({ model }); diff --git a/awx/ui/client/legacy/styles/lists.less b/awx/ui/client/legacy/styles/lists.less index 8357a78b78..3e004461af 100644 --- a/awx/ui/client/legacy/styles/lists.less +++ b/awx/ui/client/legacy/styles/lists.less @@ -372,7 +372,9 @@ table, tbody { .List-noItems { margin-top: 52px; - display: inline-block; + display: flex; + align-items: center; + justify-content: center; width: 100%; height: 200px; border-radius: 5px; @@ -381,7 +383,7 @@ table, tbody { color: @list-no-items-txt; text-transform: uppercase; text-align: center; - padding: 80px 10px; + padding: 10px; } .modal-body > .List-noItems { diff --git a/awx/ui/client/src/notifications/shared/message-utils.service.js b/awx/ui/client/src/notifications/shared/message-utils.service.js index 48711e5faf..f49b96f0ed 100644 --- a/awx/ui/client/src/notifications/shared/message-utils.service.js +++ b/awx/ui/client/src/notifications/shared/message-utils.service.js @@ -60,27 +60,27 @@ export default [function() { return; } let isCustomized = false; - if (messages.started.message) { + if (messages.started && messages.started.message) { isCustomized = true; $scope.started_message = messages.started.message; } - if (messages.started.body) { + if (messages.started && messages.started.body) { isCustomized = true; $scope.started_body = messages.started.body; } - if (messages.success.message) { + if (messages.success && messages.success.message) { isCustomized = true; $scope.success_message = messages.success.message; } - if (messages.success.body) { + if (messages.success && messages.success.body) { isCustomized = true; $scope.success_body = messages.success.body; } - if (messages.error.message) { + if (messages.error && messages.error.message) { isCustomized = true; $scope.error_message = messages.error.message; } - if (messages.error.body) { + if (messages.error && messages.error.body) { isCustomized = true; $scope.error_body = messages.error.body; } diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 7d05309b3a..526cb7c35c 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,5 +1,7 @@ import AdHocCommands from './models/AdHocCommands'; import Config from './models/Config'; +import CredentialTypes from './models/CredentialTypes'; +import Credentials from './models/Credentials'; import InstanceGroups from './models/InstanceGroups'; import Inventories from './models/Inventories'; import InventorySources from './models/InventorySources'; @@ -23,6 +25,8 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates'; const AdHocCommandsAPI = new AdHocCommands(); const ConfigAPI = new Config(); +const CredentialsAPI = new Credentials(); +const CredentialTypesAPI = new CredentialTypes(); const InstanceGroupsAPI = new InstanceGroups(); const InventoriesAPI = new Inventories(); const InventorySourcesAPI = new InventorySources(); @@ -47,6 +51,8 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); export { AdHocCommandsAPI, ConfigAPI, + CredentialsAPI, + CredentialTypesAPI, InstanceGroupsAPI, InventoriesAPI, InventorySourcesAPI, diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js new file mode 100644 index 0000000000..65906cdcbd --- /dev/null +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class CredentialTypes extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/credential_types/'; + } +} + +export default CredentialTypes; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js new file mode 100644 index 0000000000..2e3634b4e5 --- /dev/null +++ b/awx/ui_next/src/api/models/Credentials.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Credentials extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/credentials/'; + } +} + +export default Credentials; diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index cff6858db7..1587f6c86b 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -44,6 +44,19 @@ class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) { readCredentials(id, params) { return this.http.get(`${this.baseUrl}${id}/credentials/`, { params }); } + + associateCredentials(id, credentialId) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { + id: credentialId, + }); + } + + disassociateCredentials(id, credentialId) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { + id: credentialId, + disassociate: true, + }); + } } export default JobTemplates; diff --git a/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.jsx b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.jsx new file mode 100644 index 0000000000..9bd91fbac3 --- /dev/null +++ b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Tooltip } from '@patternfly/react-core'; +import { CopyIcon } from '@patternfly/react-icons'; + +import styled from 'styled-components'; + +const CopyButton = styled(Button)` + padding: 2px 4px; + margin-left: 8px; + border: none; + &:hover { + background-color: #0066cc; + color: white; + } +`; + +export const clipboardCopyFunc = (event, text) => { + const clipboard = event.currentTarget.parentElement; + const el = document.createElement('input'); + el.value = text; + clipboard.appendChild(el); + el.select(); + document.execCommand('copy'); + clipboard.removeChild(el); +}; + +class ClipboardCopyButton extends React.Component { + constructor(props) { + super(props); + + this.state = { + copied: false, + }; + + this.handleCopyClick = this.handleCopyClick.bind(this); + } + + handleCopyClick = event => { + const { stringToCopy, switchDelay } = this.props; + if (this.timer) { + window.clearTimeout(this.timer); + this.setState({ copied: false }); + } + clipboardCopyFunc(event, stringToCopy); + this.setState({ copied: true }, () => { + this.timer = window.setTimeout(() => { + this.setState({ copied: false }); + this.timer = null; + }, switchDelay); + }); + }; + + render() { + const { clickTip, entryDelay, exitDelay, hoverTip } = this.props; + const { copied } = this.state; + + return ( + <Tooltip + entryDelay={entryDelay} + exitDelay={exitDelay} + trigger="mouseenter focus click" + content={copied ? clickTip : hoverTip} + > + <CopyButton + variant="plain" + onClick={this.handleCopyClick} + aria-label={hoverTip} + > + <CopyIcon /> + </CopyButton> + </Tooltip> + ); + } +} + +ClipboardCopyButton.propTypes = { + clickTip: PropTypes.string.isRequired, + entryDelay: PropTypes.number, + exitDelay: PropTypes.number, + hoverTip: PropTypes.string.isRequired, + stringToCopy: PropTypes.string.isRequired, + switchDelay: PropTypes.number, +}; + +ClipboardCopyButton.defaultProps = { + entryDelay: 100, + exitDelay: 1600, + switchDelay: 2000, +}; + +export default ClipboardCopyButton; diff --git a/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.test.jsx b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.test.jsx new file mode 100644 index 0000000000..0cdbab8302 --- /dev/null +++ b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import ClipboardCopyButton from './ClipboardCopyButton'; + +document.execCommand = jest.fn(); + +jest.useFakeTimers(); + +describe('ClipboardCopyButton', () => { + test('renders the expected content', () => { + const wrapper = mountWithContexts( + <ClipboardCopyButton + clickTip="foo" + hoverTip="bar" + stringToCopy="foobar!" + /> + ); + expect(wrapper).toHaveLength(1); + }); + test('clicking button calls execCommand to copy to clipboard', () => { + const wrapper = mountWithContexts( + <ClipboardCopyButton + clickTip="foo" + hoverTip="bar" + stringToCopy="foobar!" + /> + ).find('ClipboardCopyButton'); + expect(wrapper.state('copied')).toBe(false); + wrapper.find('Button').simulate('click'); + expect(document.execCommand).toBeCalledWith('copy'); + expect(wrapper.state('copied')).toBe(true); + jest.runAllTimers(); + wrapper.update(); + expect(wrapper.state('copied')).toBe(false); + }); +}); diff --git a/awx/ui_next/src/components/ClipboardCopyButton/index.js b/awx/ui_next/src/components/ClipboardCopyButton/index.js new file mode 100644 index 0000000000..45adfe436e --- /dev/null +++ b/awx/ui_next/src/components/ClipboardCopyButton/index.js @@ -0,0 +1 @@ +export { default } from './ClipboardCopyButton'; diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index 874571fcb0..dfe6c2ad00 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -15,16 +15,19 @@ import { ButtonVariant, InputGroup as PFInputGroup, Modal, + ToolbarItem, } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import AnsibleSelect from '../AnsibleSelect'; import PaginatedDataList from '../PaginatedDataList'; +import VerticalSeperator from '../VerticalSeparator'; import DataListToolbar from '../DataListToolbar'; import CheckboxListItem from '../CheckboxListItem'; import SelectedList from '../SelectedList'; -import { ChipGroup, Chip } from '../Chip'; +import { ChipGroup, Chip, CredentialChip } from '../Chip'; import { getQSConfig, parseQueryString } from '../../util/qs'; const SearchButton = styled(Button)` @@ -83,14 +86,20 @@ class Lookup extends React.Component { } componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { + const { location, selectedCategory } = this.props; + if ( + location !== prevProps.location || + prevProps.selectedCategory !== selectedCategory + ) { this.getData(); } } assertCorrectValueType() { - const { multiple, value } = this.props; + const { multiple, value, selectCategoryOptions } = this.props; + if (selectCategoryOptions) { + return; + } if (!multiple && Array.isArray(value)) { throw new Error( 'Lookup value must not be an array unless `multiple` is set' @@ -123,7 +132,13 @@ class Lookup extends React.Component { } toggleSelected(row) { - const { name, onLookupSave, multiple } = this.props; + const { + name, + onLookupSave, + multiple, + onToggleItem, + selectCategoryOptions, + } = this.props; const { lookupSelectedItems: updatedSelectedItems, isModalOpen, @@ -132,8 +147,10 @@ class Lookup extends React.Component { const selectedIndex = updatedSelectedItems.findIndex( selectedRow => selectedRow.id === row.id ); - if (multiple) { + if (selectCategoryOptions) { + onToggleItem(row, isModalOpen); + } if (selectedIndex > -1) { updatedSelectedItems.splice(selectedIndex, 1); this.setState({ lookupSelectedItems: updatedSelectedItems }); @@ -156,7 +173,7 @@ class Lookup extends React.Component { handleModalToggle() { const { isModalOpen } = this.state; - const { value, multiple } = this.props; + const { value, multiple, selectCategory } = this.props; // Resets the selected items from parent state whenever modal is opened // This handles the case where the user closes/cancels the modal and // opens it again @@ -168,6 +185,9 @@ class Lookup extends React.Component { this.setState({ lookupSelectedItems }); } else { this.clearQSParams(); + if (selectCategory) { + selectCategory(null, 'Machine'); + } } this.setState(prevState => ({ isModalOpen: !prevState.isModalOpen, @@ -180,8 +200,9 @@ class Lookup extends React.Component { const value = multiple ? lookupSelectedItems : lookupSelectedItems[0] || null; - onLookupSave(value, name); + this.handleModalToggle(); + onLookupSave(value, name); } clearQSParams() { @@ -201,6 +222,7 @@ class Lookup extends React.Component { count, } = this.state; const { + form, id, lookupHeader, value, @@ -208,27 +230,40 @@ class Lookup extends React.Component { multiple, name, onBlur, + selectCategory, required, i18n, + selectCategoryOptions, + selectedCategory, } = this.props; - const header = lookupHeader || i18n._(t`Items`); const canDelete = !required || (multiple && value.length > 1); - - const chips = value ? ( - <ChipGroup> - {(multiple ? value : [value]).map(chip => ( - <Chip - key={chip.id} - onClick={() => this.toggleSelected(chip)} - isReadOnly={!canDelete} - > - {chip.name} - </Chip> - ))} - </ChipGroup> - ) : null; - + const chips = () => { + return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( + <ChipGroup> + {(multiple ? value : [value]).map(chip => ( + <CredentialChip + key={chip.id} + onClick={() => this.toggleSelected(chip)} + isReadOnly={!canDelete} + credential={chip} + /> + ))} + </ChipGroup> + ) : ( + <ChipGroup> + {(multiple ? value : [value]).map(chip => ( + <Chip + key={chip.id} + onClick={() => this.toggleSelected(chip)} + isReadOnly={!canDelete} + > + {chip.name} + </Chip> + ))} + </ChipGroup> + ); + }; return ( <Fragment> <InputGroup onBlur={onBlur}> @@ -240,7 +275,9 @@ class Lookup extends React.Component { > <SearchIcon /> </SearchButton> - <ChipHolder className="pf-c-form-control">{chips}</ChipHolder> + <ChipHolder className="pf-c-form-control"> + {value ? chips(value) : null} + </ChipHolder> </InputGroup> <Modal className="awx-c-modal" @@ -265,6 +302,21 @@ class Lookup extends React.Component { </Button>, ]} > + {selectCategoryOptions && selectCategoryOptions.length > 0 && ( + <ToolbarItem css=" display: flex; align-items: center;"> + <span css="flex: 0 0 25%;">Selected Category</span> + <VerticalSeperator /> + <AnsibleSelect + css="flex: 1 1 75%;" + id="multiCredentialsLookUp-select" + label="Selected Category" + data={selectCategoryOptions} + value={selectedCategory.label} + onChange={selectCategory} + form={form} + /> + </ToolbarItem> + )} <PaginatedDataList items={results} itemCount={count} @@ -277,9 +329,18 @@ class Lookup extends React.Component { itemId={item.id} name={multiple ? item.name : name} label={item.name} - isSelected={lookupSelectedItems.some(i => i.id === item.id)} + isSelected={ + selectCategoryOptions + ? value.some(i => i.id === item.id) + : lookupSelectedItems.some(i => i.id === item.id) + } onSelect={() => this.toggleSelected(item)} - isRadio={!multiple} + isRadio={ + !multiple || + (selectCategoryOptions && + selectCategoryOptions.length && + selectedCategory.value !== 'Vault') + } /> )} renderToolbar={props => <DataListToolbar {...props} fillWidth />} @@ -288,10 +349,13 @@ class Lookup extends React.Component { {lookupSelectedItems.length > 0 && ( <SelectedList label={i18n._(t`Selected`)} - selected={lookupSelectedItems} + selected={selectCategoryOptions ? value : lookupSelectedItems} showOverflowAfter={5} onRemove={this.toggleSelected} isReadOnly={!canDelete} + isCredentialList={ + selectCategoryOptions && selectCategoryOptions.length > 0 + } /> )} {error ? <div>error</div> : ''} diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx new file mode 100644 index 0000000000..b73ca373a3 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -0,0 +1,162 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { FormGroup, Tooltip } from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import { CredentialsAPI, CredentialTypesAPI } from '@api'; +import Lookup from '@components/Lookup'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +class MultiCredentialsLookup extends React.Component { + constructor(props) { + super(props); + + this.state = { + selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' }, + credentialTypes: [], + }; + this.loadCredentialTypes = this.loadCredentialTypes.bind(this); + this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind( + this + ); + this.loadCredentials = this.loadCredentials.bind(this); + this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this); + } + + componentDidMount() { + this.loadCredentialTypes(); + } + + async loadCredentialTypes() { + const { onError } = this.props; + try { + const { data } = await CredentialTypesAPI.read(); + const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; + const credentialTypes = []; + data.results.forEach(cred => { + acceptableTypes.forEach(aT => { + if (aT === cred.kind) { + // This object has several repeated values as some of it's children + // require different field values. + cred = { + id: cred.id, + key: cred.id, + kind: cred.kind, + type: cred.namespace, + value: cred.name, + label: cred.name, + isDisabled: false, + }; + credentialTypes.push(cred); + } + }); + }); + this.setState({ credentialTypes }); + } catch (err) { + onError(err); + } + } + + async loadCredentials(params) { + const { selectedCredentialType } = this.state; + params.credential_type = selectedCredentialType.id || 1; + return CredentialsAPI.read(params); + } + + toggleCredentialSelection(newCredential) { + const { onChange, credentials: credentialsToUpdate } = this.props; + + let newCredentialsList; + const isSelectedCredentialInState = + credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > + 0; + + if (isSelectedCredentialInState) { + newCredentialsList = credentialsToUpdate.filter( + cred => cred.id !== newCredential.id + ); + } else { + newCredentialsList = credentialsToUpdate.filter( + credential => + credential.kind === 'vault' || credential.kind !== newCredential.kind + ); + newCredentialsList = [...newCredentialsList, newCredential]; + } + onChange(newCredentialsList); + } + + handleCredentialTypeSelect(value, type) { + const { credentialTypes } = this.state; + const selectedType = credentialTypes.filter(item => item.label === type); + this.setState({ selectedCredentialType: selectedType[0] }); + } + + render() { + const { selectedCredentialType, credentialTypes } = this.state; + const { tooltip, i18n, credentials } = this.props; + return ( + <FormGroup label={i18n._(t`Credentials`)} fieldId="org-credentials"> + {tooltip && ( + <Tooltip position="right" content={tooltip}> + <QuestionCircleIcon /> + </Tooltip> + )} + {credentialTypes && ( + <Lookup + selectCategoryOptions={credentialTypes} + selectCategory={this.handleCredentialTypeSelect} + selectedCategory={selectedCredentialType} + onToggleItem={this.toggleCredentialSelection} + onloadCategories={this.loadCredentialTypes} + id="org-credentials" + lookupHeader={i18n._(t`Credentials`)} + name="credentials" + value={credentials} + multiple + onLookupSave={() => {}} + getItems={this.loadCredentials} + qsNamespace="credentials" + columns={[ + { + name: i18n._(t`Name`), + key: 'name', + isSortable: true, + isSearchable: true, + }, + ]} + sortedColumnKey="name" + /> + )} + </FormGroup> + ); + } +} + +MultiCredentialsLookup.propTypes = { + tooltip: PropTypes.string, + credentials: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + description: PropTypes.string, + kind: PropTypes.string, + clound: PropTypes.bool, + }) + ), + onChange: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, +}; + +MultiCredentialsLookup.defaultProps = { + tooltip: '', + credentials: [], +}; +export { MultiCredentialsLookup as _MultiCredentialsLookup }; + +export default withI18n()(MultiCredentialsLookup); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx new file mode 100644 index 0000000000..a525fb9e9a --- /dev/null +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import MultiCredentialsLookup from './MultiCredentialsLookup'; +import { CredentialsAPI, CredentialTypesAPI } from '@api'; + +jest.mock('@api'); + +describe('<MultiCredentialsLookup />', () => { + let wrapper; + let lookup; + let credLookup; + let onChange; + + const credentials = [ + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, + { name: 'Gatsby', id: 21, kind: 'vault' }, + { name: 'Gatsby', id: 8, kind: 'Machine' }, + ]; + beforeEach(() => { + CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 400, + kind: 'ssh', + namespace: 'biz', + name: 'Amazon Web Services', + }, + { id: 500, kind: 'vault', namespace: 'buzz', name: 'Vault' }, + { id: 600, kind: 'machine', namespace: 'fuzz', name: 'Machine' }, + ], + count: 2, + }, + }); + CredentialsAPI.read.mockResolvedValueOnce({ + data: { + results: [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ], + count: 3, + }, + }); + onChange = jest.fn(); + wrapper = mountWithContexts( + <MultiCredentialsLookup + onError={() => {}} + credentials={credentials} + onChange={onChange} + tooltip="This is credentials look up" + /> + ); + lookup = wrapper.find('Lookup'); + credLookup = wrapper.find('MultiCredentialsLookup'); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('MultiCredentialsLookup renders properly', () => { + expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1); + expect(CredentialTypesAPI.read).toHaveBeenCalled(); + }); + + test('onChange is called when you click to remove a credential from input', async () => { + const chip = wrapper.find('PFChip'); + const button = chip.at(1).find('Button'); + expect(chip).toHaveLength(4); + button.prop('onClick')(); + expect(onChange).toBeCalledWith([ + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 8, kind: 'Machine', name: 'Gatsby' }, + ]); + }); + + test('can change credential types', () => { + lookup.prop('selectCategory')({}, 'Vault'); + expect(credLookup.state('selectedCredentialType')).toEqual({ + id: 500, + key: 500, + kind: 'vault', + type: 'buzz', + value: 'Vault', + label: 'Vault', + isDisabled: false, + }); + expect(CredentialsAPI.read).toHaveBeenCalled(); + }); + test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => { + lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' }); + expect(onChange).toBeCalledWith([ + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, + { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 9, kind: 'Machine', name: 'Party' }, + ]); + }); + test('Toggle credentials only adds 1 credential per credential type', () => { + lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' }); + expect(onChange).toBeCalledWith([ + ...credentials, + { name: 'Party', id: 22, kind: 'vault' }, + ]); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 99e10ac27f..cde48e2bcd 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -2,3 +2,4 @@ export { default } from './Lookup'; export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; export { default as InventoryLookup } from './InventoryLookup'; export { default as ProjectLookup } from './ProjectLookup'; +export { default as MultiCredentialsLookup } from './MultiCredentialsLookup'; diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index 6fcaf05939..661eeec382 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Split as PFSplit, SplitItem } from '@patternfly/react-core'; import styled from 'styled-components'; -import { ChipGroup, Chip } from '../Chip'; +import { ChipGroup, Chip, CredentialChip } from '../Chip'; import VerticalSeparator from '../VerticalSeparator'; const Split = styled(PFSplit)` @@ -27,23 +27,34 @@ class SelectedList extends Component { onRemove, displayKey, isReadOnly, + isCredentialList, } = this.props; + const chips = isCredentialList + ? selected.map(item => ( + <CredentialChip + key={item.id} + isReadOnly={isReadOnly} + onClick={() => onRemove(item)} + credential={item} + > + {item[displayKey]} + </CredentialChip> + )) + : selected.map(item => ( + <Chip + key={item.id} + isReadOnly={isReadOnly} + onClick={() => onRemove(item)} + > + {item[displayKey]} + </Chip> + )); return ( <Split> <SplitLabelItem>{label}</SplitLabelItem> <VerticalSeparator /> <SplitItem> - <ChipGroup showOverflowAfter={showOverflowAfter}> - {selected.map(item => ( - <Chip - key={item.id} - isReadOnly={isReadOnly} - onClick={() => onRemove(item)} - > - {item[displayKey]} - </Chip> - ))} - </ChipGroup> + <ChipGroup showOverflowAfter={showOverflowAfter}>{chips}</ChipGroup> </SplitItem> </Split> ); diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index b082a7a09e..fe006cd857 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -279,14 +279,6 @@ function JobDetail({ job, i18n, history }) { > {i18n._(t`Delete`)} </Button> - <Button - variant="secondary" - aria-label="close" - component={Link} - to="/jobs" - > - {i18n._(t`Close`)} - </Button> </ActionButtonWrapper> {isDeleteModalOpen && ( <AlertModal diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx index 076f16a940..3c211eb265 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx @@ -13,13 +13,6 @@ describe('<JobDetail />', () => { mountWithContexts(<JobDetail job={mockJobData} />); }); - test('should display a Close button', () => { - const wrapper = mountWithContexts(<JobDetail job={mockJobData} />); - - expect(wrapper.find('Button[aria-label="close"]').length).toBe(1); - wrapper.unmount(); - }); - test('should display details', () => { const wrapper = mountWithContexts(<JobDetail job={mockJobData} />); diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx index 9e6a1bec04..037092ae7f 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx @@ -137,14 +137,6 @@ function ProjectDetail({ project, i18n }) { {i18n._(t`Edit`)} </Button> )} - <Button - variant="secondary" - aria-label={i18n._(t`close`)} - component={Link} - to="/projects" - > - {i18n._(t`Close`)} - </Button> </ActionButtonWrapper> </CardBody> ); diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx index ab9c024c7e..a360e3d114 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx @@ -186,16 +186,4 @@ describe('<ProjectDetail />', () => { .simulate('click', { button: 0 }); expect(history.location.pathname).toEqual('/projects/1/edit'); }); - - test('close button should navigate to projects list', () => { - const history = createMemoryHistory(); - const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />, { - context: { router: { history } }, - }); - expect(wrapper.find('Button[aria-label="close"]').length).toBe(1); - wrapper - .find('Button[aria-label="close"] Link') - .simulate('click', { button: 0 }); - expect(history.location.pathname).toEqual('/projects'); - }); }); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx index e44970edf0..f9a0f55327 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx @@ -13,6 +13,7 @@ const mockProjects = [ url: '/api/v2/projects/1', type: 'project', scm_type: 'git', + scm_revision: 'hfadsh89sa9gsaisdf0jogos0fgd9sgdf89adsf98', summary_fields: { last_job: { id: 9000, @@ -30,6 +31,7 @@ const mockProjects = [ url: '/api/v2/projects/2', type: 'project', scm_type: 'svn', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', summary_fields: { last_job: { id: 9002, @@ -47,6 +49,7 @@ const mockProjects = [ url: '/api/v2/projects/3', type: 'project', scm_type: 'insights', + scm_revision: '4893adfi749493afjksjoaiosdgjoaisdjadfisjaso', summary_fields: { last_job: { id: 9003, diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index ed81638f45..03fd7df13f 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -12,6 +12,7 @@ import { Link as _Link } from 'react-router-dom'; import { SyncIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; +import ClipboardCopyButton from '@components/ClipboardCopyButton'; import DataListCell from '@components/DataListCell'; import DataListCheck from '@components/DataListCheck'; import ListActionButton from '@components/ListActionButton'; @@ -102,6 +103,16 @@ class ProjectListItem extends React.Component { <DataListCell key="type"> {project.scm_type.toUpperCase()} </DataListCell>, + <DataListCell key="revision"> + {project.scm_revision.substring(0, 7)} + {project.scm_revision ? ( + <ClipboardCopyButton + stringToCopy={project.scm_revision} + hoverTip={i18n._(t`Copy full revision to clipboard.`)} + clickTip={i18n._(t`Successfully copied to clipboard!`)} + /> + ) : null} + </DataListCell>, <DataListCell lastcolumn="true" key="action"> {project.summary_fields.user_capabilities.start && ( <Tooltip content={i18n._(t`Sync Project`)} position="top"> diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx index 1573efac80..bb5a7bcc4b 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx @@ -17,6 +17,7 @@ describe('<ProjectsListItem />', () => { url: '/api/v2/projects/1', type: 'project', scm_type: 'git', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', summary_fields: { last_job: { id: 9000, @@ -43,6 +44,7 @@ describe('<ProjectsListItem />', () => { url: '/api/v2/projects/1', type: 'project', scm_type: 'git', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', summary_fields: { last_job: { id: 9000, diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index 0623ad7ab8..979c2f9728 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -22,6 +22,7 @@ function JobTemplateAdd({ history, i18n }) { organizationId, instanceGroups, initialInstanceGroups, + credentials, ...remainingValues } = values; @@ -33,6 +34,7 @@ function JobTemplateAdd({ history, i18n }) { await Promise.all([ submitLabels(id, labels, organizationId), submitInstanceGroups(id, instanceGroups), + submitCredentials(id, credentials), ]); history.push(`/templates/${type}/${id}/details`); } catch (error) { @@ -60,6 +62,13 @@ function JobTemplateAdd({ history, i18n }) { return Promise.all(associatePromises); } + function submitCredentials(templateId, credentials = []) { + const associateCredentials = credentials.map(cred => + JobTemplatesAPI.associateCredentials(templateId, cred.id) + ); + return Promise.all(associateCredentials); + } + function handleCancel() { history.push(`/templates`); } diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx index 2c18c0296e..eac6ea5905 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx @@ -174,13 +174,27 @@ class JobTemplateDetail extends Component { {summary_fields.inventory && ( <Detail label={i18n._(t`Inventory`)} - value={summary_fields.inventory.name} + value={ + <Link + to={`/inventories/${ + summary_fields.inventory.kind === 'smart' + ? 'smart_inventory' + : 'inventory' + }/${summary_fields.inventory.id}/details`} + > + {summary_fields.inventory.name} + </Link> + } /> )} {summary_fields.project && ( <Detail label={i18n._(t`Project`)} - value={summary_fields.project.name} + value={ + <Link to={`/projects/${summary_fields.project.id}/details`}> + {summary_fields.project.name} + </Link> + } /> )} <Detail label={i18n._(t`Playbook`)} value={playbook} /> @@ -321,14 +335,6 @@ class JobTemplateDetail extends Component { )} </LaunchButton> )} - <Button - variant="secondary" - component={Link} - to="/templates" - aria-label={i18n._(t`Close`)} - > - {i18n._(t`Close`)} - </Button> </ButtonGroup> </CardBody> ) diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index a0cf904ab2..1db7b084a6 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -109,6 +109,7 @@ class JobTemplateEdit extends Component { organizationId, instanceGroups, initialInstanceGroups, + credentials, ...remainingValues } = values; @@ -118,6 +119,7 @@ class JobTemplateEdit extends Component { await Promise.all([ this.submitLabels(labels, organizationId), this.submitInstanceGroups(instanceGroups, initialInstanceGroups), + this.submitCredentials(credentials), ]); history.push(this.detailsUrl); } catch (formSubmitError) { @@ -154,13 +156,30 @@ class JobTemplateEdit extends Component { async submitInstanceGroups(groups, initialGroups) { const { template } = this.props; const { added, removed } = getAddedAndRemoved(initialGroups, groups); - const associatePromises = added.map(group => + const disassociatePromises = await removed.map(group => + JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id) + ); + const associatePromises = await added.map(group => JobTemplatesAPI.associateInstanceGroup(template.id, group.id) ); - const disassociatePromises = removed.map(group => - JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id) + return Promise.all([...disassociatePromises, ...associatePromises]); + } + + async submitCredentials(newCredentials) { + const { template } = this.props; + const { added, removed } = getAddedAndRemoved( + template.summary_fields.credentials, + newCredentials + ); + const disassociateCredentials = removed.map(cred => + JobTemplatesAPI.disassociateCredentials(template.id, cred.id) + ); + const disassociatePromise = await Promise.all(disassociateCredentials); + const associateCredentials = added.map(cred => + JobTemplatesAPI.associateCredentials(template.id, cred.id) ); - return Promise.all([...associatePromises, ...disassociatePromises]); + const associatePromise = Promise.all(associateCredentials); + return Promise.all([disassociatePromise, associatePromise]); } handleCancel() { @@ -179,11 +198,19 @@ class JobTemplateEdit extends Component { const canEdit = template.summary_fields.user_capabilities.edit; if (hasContentLoading) { - return <ContentLoading />; + return ( + <CardBody> + <ContentLoading /> + </CardBody> + ); } if (contentError) { - return <ContentError error={contentError} />; + return ( + <CardBody> + <ContentError error={contentError} /> + </CardBody> + ); } if (!canEdit) { diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx index 6d6c1e4083..dc5c22f4ee 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -40,6 +40,10 @@ const mockJobTemplate = { id: 2, organization_id: 1, }, + credentials: [ + { id: 1, kind: 'cloud', name: 'Foo' }, + { id: 2, kind: 'ssh', name: 'Bar' }, + ], }, }; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index bffdf85cef..6eff4dcbe1 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -7,7 +7,6 @@ import { withFormik, Field } from 'formik'; import { Form, FormGroup, - Card, Switch, Checkbox, TextInput, @@ -27,6 +26,7 @@ import { InventoryLookup, InstanceGroupsLookup, ProjectLookup, + MultiCredentialsLookup, } from '@components/Lookup'; import { JobTemplatesAPI } from '@api'; import LabelSelect from './LabelSelect'; @@ -62,6 +62,7 @@ class JobTemplateForm extends Component { inventory: null, labels: { results: [] }, project: null, + credentials: [], }, isNew: true, }, @@ -149,7 +150,6 @@ class JobTemplateForm extends Component { isDisabled: false, }, ]; - const verbosityOptions = [ { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, @@ -165,19 +165,11 @@ class JobTemplateForm extends Component { } if (hasContentLoading) { - return ( - <Card className="awx-c-card"> - <ContentLoading /> - </Card> - ); + return <ContentLoading />; } if (contentError) { - return ( - <Card className="awx-c-card"> - <ContentError error={contentError} /> - </Card> - ); + return <ContentError error={contentError} />; } const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div'; return ( @@ -319,6 +311,24 @@ class JobTemplateForm extends Component { )} /> </FormRow> + <FormRow> + <Field + name="credentials" + fieldId="template-credentials" + render={({ field }) => ( + <MultiCredentialsLookup + credentials={field.value} + onChange={newCredentials => + setFieldValue('credentials', newCredentials) + } + onError={err => this.setState({ contentError: err })} + tooltip={i18n._( + t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.` + )} + /> + )} + /> + </FormRow> <AdvancedFieldsWrapper label="Advanced"> <FormRow> <FormField @@ -587,6 +597,7 @@ const FormikApp = withFormik({ organizationId: summary_fields.inventory.organization_id || null, initialInstanceGroups: [], instanceGroups: [], + credentials: summary_fields.credentials || [], }; }, handleSubmit: (values, { props }) => props.handleSubmit(values), diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx index 071edaecf8..7ed98ad893 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx @@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import JobTemplateForm from './JobTemplateForm'; -import { LabelsAPI, JobTemplatesAPI, ProjectsAPI } from '@api'; +import { LabelsAPI, JobTemplatesAPI, ProjectsAPI, CredentialsAPI } from '@api'; jest.mock('@api'); @@ -28,6 +28,10 @@ describe('<JobTemplateForm />', () => { name: 'qux', }, labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] }, + credentials: [ + { id: 1, kind: 'cloud', name: 'Foo' }, + { id: 2, kind: 'ssh', name: 'Bar' }, + ], }, }; const mockInstanceGroups = [ @@ -55,10 +59,21 @@ describe('<JobTemplateForm />', () => { policy_instance_list: [], }, ]; + const mockCredentials = [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ]; + beforeEach(() => { LabelsAPI.read.mockReturnValue({ data: mockData.summary_fields.labels, }); + CredentialsAPI.read.mockReturnValue({ + data: { results: mockCredentials }, + }); JobTemplatesAPI.readInstanceGroups.mockReturnValue({ data: { results: mockInstanceGroups }, }); @@ -134,6 +149,13 @@ describe('<JobTemplateForm />', () => { target: { value: 'new baz type', name: 'playbook' }, }); expect(form.state('values').playbook).toEqual('new baz type'); + wrapper + .find('CredentialChip') + .at(0) + .prop('onClick')(); + expect(form.state('values').credentials).toEqual([ + { id: 2, kind: 'ssh', name: 'Bar' }, + ]); }); test('should call handleSubmit when Submit button is clicked', async () => { diff --git a/installer/roles/kubernetes/defaults/main.yml b/installer/roles/kubernetes/defaults/main.yml index 3961bf1622..d0cdf1cec2 100644 --- a/installer/roles/kubernetes/defaults/main.yml +++ b/installer/roles/kubernetes/defaults/main.yml @@ -30,7 +30,7 @@ rabbitmq_cpu_request: 500 memcached_mem_request: 1 memcached_cpu_request: 500 -kubernetes_rabbitmq_version: "3.7.4" +kubernetes_rabbitmq_version: "3.7.15" kubernetes_rabbitmq_image: "ansible/awx_rabbitmq" kubernetes_memcached_version: "latest" @@ -45,7 +45,13 @@ kubernetes_deployment_replica_size: 1 postgress_activate_wait: 60 +restore_backup_file: "./tower-openshift-backup-latest.tar.gz" + insights_url_base: "https://example.org" custom_venvs_path: "/opt/custom-venvs" custom_venvs_python: "python2" + +ca_trust_bundle: "/etc/pki/tls/certs/ca-bundle.crt" +rabbitmq_use_ssl: False + diff --git a/installer/roles/kubernetes/tasks/backup.yml b/installer/roles/kubernetes/tasks/backup.yml index 692ea02b0d..a4b0c5cd9d 100644 --- a/installer/roles/kubernetes/tasks/backup.yml +++ b/installer/roles/kubernetes/tasks/backup.yml @@ -50,7 +50,7 @@ shell: | {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} exec ansible-tower-management -- \ bash -c "PGPASSWORD={{ pg_password | quote }} \ - pg_dump --clean --create \ + scl enable rh-postgresql10 -- pg_dump --clean --create \ --host='{{ pg_hostname | default('postgresql') }}' \ --port={{ pg_port | default('5432') }} \ --username='{{ pg_username }}' \ diff --git a/installer/roles/kubernetes/tasks/main.yml b/installer/roles/kubernetes/tasks/main.yml index 221180424d..8f03022bfb 100644 --- a/installer/roles/kubernetes/tasks/main.yml +++ b/installer/roles/kubernetes/tasks/main.yml @@ -113,6 +113,59 @@ seconds: "{{ postgress_activate_wait }}" when: openshift_pg_activate.changed or kubernetes_pg_activate.changed +- name: Check if Postgres 9.6 is being used + shell: | + POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + get pods -l=name=postgresql --field-selector status.phase=Running -o jsonpath="{.items[0].metadata.name}") + {{ kubectl_or_oc }} exec -ti $POD -n {{ kubernetes_namespace }} -- bash -c "psql -U {{ pg_username }} -tAc 'select version()'" + register: pg_version + +- name: Upgrade Postgres if necessary + block: + - name: Set new pg image + shell: | + IMAGE=registry.access.redhat.com/rhscl/postgresql-10-rhel7 + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set image dc/postgresql postgresql=$IMAGE + + - name: Wait for change to take affect + pause: + seconds: 5 + + - name: Set env var for pg upgrade + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_UPGRADE=copy + + - name: Wait for change to take affect + pause: + seconds: 5 + + - name: Set env var for new pg version + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=10 + + - name: Wait for Postgres to redeploy + pause: + seconds: "{{ postgress_activate_wait }}" + + - name: Wait for Postgres to finish upgrading + shell: | + POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + get pods -l=name=postgresql -o jsonpath="{.items[0].metadata.name}") + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} logs $POD | grep 'Upgrade DONE' + register: pg_upgrade_logs + retries: 360 + delay: 10 + until: pg_upgrade_logs is success + + - name: Unset upgrade env var + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_UPGRADE- + + - name: Wait for Postgres to redeploy + pause: + seconds: "{{ postgress_activate_wait }}" + when: "pg_version is success and '9.6' in pg_version.stdout" + - name: Set image names if using custom registry block: - name: Set task image name @@ -126,6 +179,10 @@ when: kubernetes_web_image is not defined when: docker_registry is defined +- name: Generate SSL certificates for RabbitMQ, if needed + include_tasks: ssl_cert_gen.yml + when: "rabbitmq_use_ssl|default(False)|bool" + - name: Render deployment templates set_fact: "{{ item }}": "{{ lookup('template', item + '.yml.j2') }}" diff --git a/installer/roles/kubernetes/tasks/restore.yml b/installer/roles/kubernetes/tasks/restore.yml index 10f1292495..3acb394c57 100644 --- a/installer/roles/kubernetes/tasks/restore.yml +++ b/installer/roles/kubernetes/tasks/restore.yml @@ -21,7 +21,7 @@ - name: Unarchive Tower backup unarchive: - src: tower-openshift-backup-latest.tar.gz + src: "{{ restore_backup_file }}" dest: "{{ playbook_dir }}/tower-openshift-restore" extra_opts: [--strip-components=1] @@ -76,7 +76,7 @@ shell: | {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ exec -i ansible-tower-management -- bash -c "PGPASSWORD={{ pg_password | quote }} \ - psql \ + scl enable rh-postgresql10 -- psql \ --host={{ pg_hostname | default('postgresql') }} \ --port={{ pg_port | default('5432') }} \ --username=postgres \ @@ -88,7 +88,7 @@ shell: | {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ exec -i ansible-tower-management -- bash -c "PGPASSWORD={{ pg_password | quote }} \ - psql \ + scl enable rh-postgresql10 -- psql \ --host={{ pg_hostname | default('postgresql') }} \ --port={{ pg_port | default('5432') }} \ --username={{ pg_username }} \ @@ -99,7 +99,7 @@ shell: | {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ exec -i ansible-tower-management -- bash -c "PGPASSWORD={{ pg_password | quote }} \ - psql \ + scl enable rh-postgresql10 -- psql \ --host={{ pg_hostname | default('postgresql') }} \ --port={{ pg_port | default('5432') }} \ --username=postgres \ diff --git a/installer/roles/kubernetes/templates/configmap.yml.j2 b/installer/roles/kubernetes/templates/configmap.yml.j2 index 0677721681..2468a80618 100644 --- a/installer/roles/kubernetes/templates/configmap.yml.j2 +++ b/installer/roles/kubernetes/templates/configmap.yml.j2 @@ -18,6 +18,8 @@ data: SYSTEM_TASK_ABS_MEM = {{ ((task_mem_request|int * 1024) / 100)|int }} INSIGHTS_URL_BASE = "{{ insights_url_base }}" + INSIGHTS_AGENT_MIME = "application/vnd.redhat.tower.analytics+tgz" + AUTOMATION_ANALYTICS_URL = 'https://cloud.redhat.com/api/ingress/v1/upload' #Autoprovisioning should replace this CLUSTER_HOST_ID = socket.gethostname() @@ -62,6 +64,7 @@ data: LOGGING['loggers']['rbac_migrations']['handlers'] = ['console'] LOGGING['loggers']['awx.isolated.manager.playbooks']['handlers'] = ['console'] LOGGING['handlers']['callback_receiver'] = {'class': 'logging.NullHandler'} + LOGGING['handlers']['fact_receiver'] = {'class': 'logging.NullHandler'} LOGGING['handlers']['task_system'] = {'class': 'logging.NullHandler'} LOGGING['handlers']['tower_warnings'] = {'class': 'logging.NullHandler'} LOGGING['handlers']['rbac_migrations'] = {'class': 'logging.NullHandler'} diff --git a/installer/roles/kubernetes/templates/credentials.py.j2 b/installer/roles/kubernetes/templates/credentials.py.j2 index 22678db7a3..f353796bb1 100644 --- a/installer/roles/kubernetes/templates/credentials.py.j2 +++ b/installer/roles/kubernetes/templates/credentials.py.j2 @@ -7,6 +7,9 @@ DATABASES = { 'PASSWORD': "{{ pg_password }}", 'HOST': "{{ pg_hostname|default('postgresql') }}", 'PORT': "{{ pg_port }}", + 'OPTIONS': { 'sslmode': '{{ pg_sslmode|default("prefer") }}', + 'sslrootcert': '{{ ca_trust_bundle }}', + }, } } BROKER_URL = 'amqp://{}:{}@{}:{}/{}'.format( diff --git a/installer/roles/kubernetes/templates/deployment.yml.j2 b/installer/roles/kubernetes/templates/deployment.yml.j2 index 383a3e0a8a..b7a3f9cb10 100644 --- a/installer/roles/kubernetes/templates/deployment.yml.j2 +++ b/installer/roles/kubernetes/templates/deployment.yml.j2 @@ -61,6 +61,20 @@ data: queue_master_locator=min-masters ## enable guest user loopback_users.guest = false +{% if rabbitmq_use_ssl|default(False)|bool %} + ssl_options.cacertfile=/etc/pki/rabbitmq/ca.crt + ssl_options.certfile=/etc/pki/rabbitmq/server-combined.pem + ssl_options.verify=verify_peer +{% endif %} + rabbitmq-env.conf: | + NODENAME=${RABBITMQ_NODENAME} + USE_LONGNAME=true +{% if rabbitmq_use_ssl|default(False)|bool %} + ERL_SSL_PATH=$(erl -eval 'io:format("~p", [code:lib_dir(ssl, ebin)]),halt().' -noshell) + SSL_ADDITIONAL_ERL_ARGS="-pa '$ERL_SSL_PATH' -proto_dist inet_tls -ssl_dist_opt server_certfile /etc/pki/rabbitmq/server-combined.pem -ssl_dist_opt server_secure_renegotiate true client_secure_renegotiate true" + SERVER_ADDITIONAL_ERL_ARGS="$SERVER_ADDITIONAL_ERL_ARGS $SSL_ADDITIONAL_ERL_ARGS" + CTL_ERL_ARGS="$SSL_ADDITIONAL_ERL_ARGS" +{% endif %} {% if kubernetes_context is defined %} --- @@ -156,7 +170,7 @@ spec: {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install -U \ {% for module in custom_venv.python_modules %}{{ module }} {% endfor %} && {% endif %} - deactivate && + deactivate && {% endfor %} : volumeMounts: @@ -307,6 +321,10 @@ spec: mountPath: /etc/rabbitmq - name: rabbitmq-healthchecks mountPath: /usr/local/bin/healthchecks +{% if rabbitmq_use_ssl|default(False)|bool %} + - name: "{{ kubernetes_deployment_name }}-rabbitmq-certs-vol" + mountPath: /etc/pki/rabbitmq +{% endif %} resources: requests: memory: "{{ rabbitmq_mem_request }}Gi" @@ -362,7 +380,7 @@ spec: type: Directory {% endif %} {% if custom_venvs is defined %} - - name: custom-venvs + - name: custom-venvs emptyDir: {} {% endif %} - name: {{ kubernetes_deployment_name }}-application-config @@ -398,6 +416,23 @@ spec: path: enabled_plugins - key: rabbitmq_definitions.json path: rabbitmq_definitions.json + - key: rabbitmq-env.conf + path: rabbitmq-env.conf + +{% if rabbitmq_use_ssl|default(False)|bool %} + - name: "{{ kubernetes_deployment_name }}-rabbitmq-certs-vol" + secret: + secretName: "{{ kubernetes_deployment_name }}-rabbitmq-certs" + items: + - key: rabbitmq_ssl_cert + path: 'server.crt' + - key: rabbitmq_ssl_key + path: 'server.key' + - key: rabbitmq_ssl_cacert + path: 'ca.crt' + - key: rabbitmq_ssl_combined + path: 'server-combined.pem' +{% endif %} - name: rabbitmq-healthchecks configMap: name: {{ kubernetes_deployment_name }}-healthchecks @@ -426,7 +461,7 @@ data: conn.request('GET', '/api/healthchecks/node', headers={'Authorization': 'Basic %s' % authsecret}) r1 = conn.getresponse() if r1.status != 200: - sys.stderr.write('Received http error %i\n' % (r1.status)) + sys.stderr.write('Received http error %i\\n' % (r1.status)) sys.exit(1) body = r1.read() if body != '{"status":"ok"}': diff --git a/installer/roles/kubernetes/templates/environment.sh.j2 b/installer/roles/kubernetes/templates/environment.sh.j2 index cd1c34cb05..db5cd548da 100644 --- a/installer/roles/kubernetes/templates/environment.sh.j2 +++ b/installer/roles/kubernetes/templates/environment.sh.j2 @@ -2,8 +2,8 @@ DATABASE_USER={{ pg_username }} DATABASE_NAME={{ pg_database }} DATABASE_HOST={{ pg_hostname|default('postgresql') }} DATABASE_PORT={{ pg_port|default('5432') }} -DATABASE_PASSWORD={{ pg_password|default('awxpass') }} -DATABASE_ADMIN_PASSWORD={{ pg_admin_password|default('postgrespass') }} +DATABASE_PASSWORD={{ pg_password | quote }} +DATABASE_ADMIN_PASSWORD={{ pg_admin_password | quote }} MEMCACHED_HOST={{ memcached_hostname|default('localhost') }} MEMCACHED_PORT={{ memcached_port|default('11211') }} RABBITMQ_HOST={{ rabbitmq_hostname|default('localhost') }} diff --git a/installer/roles/kubernetes/templates/secret.yml.j2 b/installer/roles/kubernetes/templates/secret.yml.j2 index f57691666d..5c31cf45b1 100644 --- a/installer/roles/kubernetes/templates/secret.yml.j2 +++ b/installer/roles/kubernetes/templates/secret.yml.j2 @@ -13,3 +13,18 @@ data: rabbitmq_erlang_cookie: "{{ rabbitmq_erlang_cookie | b64encode }}" credentials_py: "{{ lookup('template', 'credentials.py.j2') | b64encode }}" environment_sh: "{{ lookup('template', 'environment.sh.j2') | b64encode }}" + +{% if rabbitmq_use_ssl|default(False)|bool %} +--- +apiVersion: v1 +kind: Secret +metadata: + namespace: {{ kubernetes_namespace }} + name: "{{ kubernetes_deployment_name }}-rabbitmq-certs" +type: Opaque +data: + rabbitmq_ssl_cert: "{{ lookup('file', rmq_cert_tempdir.path + '/server.crt') | b64encode }}" + rabbitmq_ssl_key: "{{ lookup('file', rmq_cert_tempdir.path + '/server.key') | b64encode }}" + rabbitmq_ssl_cacert: "{{ lookup('file', rmq_cert_tempdir.path + '/ca.crt') | b64encode }}" + rabbitmq_ssl_combined: "{{ lookup('file', rmq_cert_tempdir.path + '/server-combined.pem') | b64encode }}" +{% endif %} diff --git a/installer/roles/local_docker/templates/environment.sh.j2 b/installer/roles/local_docker/templates/environment.sh.j2 index 7d78b8c96f..832f112d6d 100644 --- a/installer/roles/local_docker/templates/environment.sh.j2 +++ b/installer/roles/local_docker/templates/environment.sh.j2 @@ -1,12 +1,12 @@ -DATABASE_USER={{ pg_username }} -DATABASE_NAME={{ pg_database }} -DATABASE_HOST={{ pg_hostname|default('postgres') }} -DATABASE_PORT={{ pg_port|default('5432') }} -DATABASE_PASSWORD={{ pg_password|default('awxpass') }} -DATABASE_ADMIN_PASSWORD={{ pg_admin_password|default('postgrespass') }} +DATABASE_USER={{ pg_username|quote }} +DATABASE_NAME={{ pg_database|quote }} +DATABASE_HOST={{ pg_hostname|default('postgres')|quote }} +DATABASE_PORT={{ pg_port|default('5432')|quote }} +DATABASE_PASSWORD={{ pg_password|default('awxpass')|quote }} +DATABASE_ADMIN_PASSWORD={{ pg_admin_password|default('postgrespass')|quote }} MEMCACHED_HOST={{ memcached_hostname|default('memcached') }} -MEMCACHED_PORT={{ memcached_port|default('11211') }} -RABBITMQ_HOST={{ rabbitmq_hostname|default('rabbitmq') }} -RABBITMQ_PORT={{ rabbitmq_port|default('5672') }} -AWX_ADMIN_USER={{ admin_user }} -AWX_ADMIN_PASSWORD={{ admin_password | quote }} +MEMCACHED_PORT={{ memcached_port|default('11211')|quote }} +RABBITMQ_HOST={{ rabbitmq_hostname|default('rabbitmq')|quote }} +RABBITMQ_PORT={{ rabbitmq_port|default('5672')|quote }} +AWX_ADMIN_USER={{ admin_user|quote }} +AWX_ADMIN_PASSWORD={{ admin_password|quote }} diff --git a/requirements/requirements.in b/requirements/requirements.in index b0e83528bd..e26d228bd4 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,4 +1,4 @@ -ansible-runner==1.4.2 +ansible-runner==1.4.4 appdirs==1.4.2 asgi-amqp==1.1.3 azure-keyvault==1.1.0 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 99778135c2..89d1ef12ac 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,6 +1,6 @@ adal==1.2.1 # via msrestazure amqp==2.4.2 # via kombu -ansible-runner==1.4.2 +ansible-runner==1.4.4 appdirs==1.4.2 argparse==1.4.0 # via uwsgitop asgi-amqp==1.1.3 diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index 59a7c5837a..635862432c 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -11,7 +11,7 @@ pytest==3.6.0 pytest-cov pytest-django pytest-pythonpath -pytest-mock +pytest-mock==1.11.1 pytest-timeout pytest-xdist<1.28.0 tox # for awxkit diff --git a/requirements/requirements_tower_uninstall.txt b/requirements/requirements_tower_uninstall.txt index dc3292cd0b..56cbaa5f19 100644 --- a/requirements/requirements_tower_uninstall.txt +++ b/requirements/requirements_tower_uninstall.txt @@ -1 +1 @@ -enum34 +rsa # stop adding new crypto libs |