From c9ff3e99b8f84f16e4763b965a82365b234321db Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 26 Sep 2017 10:28:41 -0400 Subject: [PATCH 01/17] celeryd attach to queues dynamically * Based on the tower topology (Instance and InstanceGroup relationships), have celery dyamically listen to queues on boot * Add celery task capable of "refreshing" what queues each celeryd worker listens to. This will be used to support changes in the topology. * Cleaned up some celery task definitions. * Converged wrongly targeted job launch/finish messages to 'tower' queue, rather than a 1-off queue. * Dynamically route celery tasks destined for the local node * separate beat process add support for separate beat process --- Makefile | 2 +- awx/main/models/ha.py | 3 + awx/main/scheduler/tasks.py | 4 +- awx/main/tasks.py | 34 +++++- awx/main/tests/unit/utils/test_ha.py | 100 ++++++++++++++++++ awx/main/utils/ha.py | 71 +++++++++++++ awx/settings/defaults.py | 48 +++++---- awx/settings/development.py | 9 -- installer/image_build/files/settings.py | 3 - .../image_build/files/supervisor_task.conf | 3 +- tools/docker-compose/supervisor.conf | 2 +- 11 files changed, 237 insertions(+), 42 deletions(-) create mode 100644 awx/main/tests/unit/utils/test_ha.py create mode 100644 awx/main/utils/ha.py diff --git a/Makefile b/Makefile index 58479e0f19..874a4e52ee 100644 --- a/Makefile +++ b/Makefile @@ -326,7 +326,7 @@ celeryd: @if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ fi; \ - celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=$(CELERY_SCHEDULE_FILE) -Q tower_scheduler,tower_broadcast_all,$(COMPOSE_HOST),$(AWX_GROUP_QUEUES) -n celery@$(COMPOSE_HOST) --pidfile /tmp/celery_pid + celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=$(CELERY_SCHEDULE_FILE) -Q tower_broadcast_all -n celery@$(COMPOSE_HOST) --pidfile /tmp/celery_pid # Run to start the zeromq callback receiver receiver: diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index f2e57f7a07..cb63beb126 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -63,6 +63,9 @@ class Instance(models.Model): grace_period = settings.AWX_ISOLATED_PERIODIC_CHECK * 2 return self.modified < ref_time - timedelta(seconds=grace_period) + def is_controller(self): + return Instance.objects.filter(rampart_groups__controller__instances=self).exists() + class InstanceGroup(models.Model): """A model representing a Queue/Group of AWX Instances.""" diff --git a/awx/main/scheduler/tasks.py b/awx/main/scheduler/tasks.py index 70d4c95354..89e36f6a93 100644 --- a/awx/main/scheduler/tasks.py +++ b/awx/main/scheduler/tasks.py @@ -21,12 +21,12 @@ class LogErrorsTask(Task): super(LogErrorsTask, self).on_failure(exc, task_id, args, kwargs, einfo) -@shared_task +@shared_task(base=LogErrorsTask) def run_job_launch(job_id): TaskManager().schedule() -@shared_task +@shared_task(base=LogErrorsTask) def run_job_complete(job_id): TaskManager().schedule() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 1f74fb04a1..36274b926d 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -26,7 +26,7 @@ except Exception: # Celery from celery import Task, shared_task -from celery.signals import celeryd_init, worker_process_init, worker_shutdown +from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, beat_init # Django from django.conf import settings @@ -57,6 +57,7 @@ from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, ignore_inventory_computed_fields, ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars) from awx.main.utils.reload import restart_local_services, stop_local_services +from awx.main.utils.ha import update_celery_worker_routes, register_celery_worker_queues from awx.main.utils.handlers import configure_external_logger from awx.main.consumers import emit_channel_notification from awx.conf import settings_registry @@ -147,6 +148,37 @@ def handle_setting_changes(self, setting_keys): break +@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask) +def handle_ha_toplogy_changes(self): + instance = Instance.objects.me() + logger.debug("Reconfigure celeryd queues task on host {}".format(self.request.hostname)) + (instance, removed_queues, added_queues) = register_celery_worker_queues(self.app, self.request.hostname) + logger.info("Workers on tower node '{}' removed from queues {} and added to queues {}" + .format(instance.hostname, removed_queues, added_queues)) + updated_routes = update_celery_worker_routes(instance, settings) + logger.info("Worker on tower node '{}' updated celery routes {} all routes are now {}" + .format(instance.hostname, updated_routes, self.app.conf.CELERY_ROUTES)) + + +@worker_ready.connect +def handle_ha_toplogy_worker_ready(sender, **kwargs): + logger.debug("Configure celeryd queues task on host {}".format(sender.hostname)) + (instance, removed_queues, added_queues) = register_celery_worker_queues(sender.app, sender.hostname) + logger.info("Workers on tower node '{}' unsubscribed from queues {} and subscribed to queues {}" + .format(instance.hostname, removed_queues, added_queues)) + + +@beat_init.connect +@celeryd_init.connect +def handle_update_celery_routes(sender=None, conf=None, **kwargs): + conf = conf if conf else sender.app.conf + logger.debug("Registering celery routes for {}".format(sender)) + instance = Instance.objects.me() + added_routes = update_celery_worker_routes(instance, conf) + logger.info("Workers on tower node '{}' added routes {} all routes are now {}" + .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) + + @shared_task(queue='tower', base=LogErrorsTask) def send_notifications(notification_list, job_id=None): if not isinstance(notification_list, list): diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py new file mode 100644 index 0000000000..6bd1b856b9 --- /dev/null +++ b/awx/main/tests/unit/utils/test_ha.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Ansible Tower by Red Hat +# All Rights Reserved. + +# python +import pytest +import mock + +# AWX +from awx.main.utils.ha import ( + _add_remove_celery_worker_queues, + update_celery_worker_routes, +) + + +@pytest.fixture +def conf(): + class Conf(): + CELERY_ROUTES = dict() + CELERYBEAT_SCHEDULE = dict() + return Conf() + + +class TestAddRemoveCeleryWorkerQueues(): + @pytest.fixture + def instance_generator(self, mocker): + def fn(groups=['east', 'west', 'north', 'south'], hostname='east-1'): + instance = mocker.MagicMock() + instance.hostname = hostname + instance.rampart_groups = mocker.MagicMock() + instance.rampart_groups.values_list = mocker.MagicMock(return_value=groups) + + return instance + return fn + + @pytest.fixture + def worker_queues_generator(self, mocker): + def fn(queues=['east', 'west']): + return [dict(name=n, alias='') for n in queues] + return fn + + @pytest.fixture + def mock_app(self, mocker): + app = mocker.MagicMock() + app.control = mocker.MagicMock() + app.control.cancel_consumer = mocker.MagicMock() + return app + + @pytest.mark.parametrize("static_queues,_worker_queues,groups,hostname,added_expected,removed_expected", [ + (['east', 'west'], ['east', 'west', 'east-1'], [], 'east-1', [], []), + ([], ['east', 'west', 'east-1'], ['east', 'west'], 'east-1', [], []), + ([], ['east', 'west'], ['east', 'west'], 'east-1', ['east-1'], []), + ([], [], ['east', 'west'], 'east-1', ['east', 'west', 'east-1'], []), + ([], ['china', 'russia'], ['east', 'west'], 'east-1', ['east', 'west', 'east-1'], ['china', 'russia']), + ]) + def test__add_remove_celery_worker_queues_noop(self, mock_app, + instance_generator, + worker_queues_generator, + static_queues, _worker_queues, + groups, hostname, + added_expected, removed_expected): + instance = instance_generator(groups=groups, hostname=hostname) + worker_queues = worker_queues_generator(_worker_queues) + with mock.patch('awx.main.utils.ha.settings.AWX_CELERY_QUEUES_STATIC', static_queues): + (added_queues, removed_queues) = _add_remove_celery_worker_queues(mock_app, instance, worker_queues, hostname) + assert set(added_queues) == set(added_expected) + assert set(removed_queues) == set(removed_expected) + + +class TestUpdateCeleryWorkerRoutes(): + + @pytest.mark.parametrize("is_controller,expected_routes", [ + (False, { + 'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'}, + 'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'} + }), + (True, { + 'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'}, + 'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'}, + 'awx.main.tasks.awx_isolated_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'}, + }), + ]) + def test_update_celery_worker_routes(self, mocker, conf, is_controller, expected_routes): + instance = mocker.MagicMock() + instance.hostname = 'east-1' + instance.is_controller = mocker.MagicMock(return_value=is_controller) + + assert update_celery_worker_routes(instance, conf) == expected_routes + assert conf.CELERY_ROUTES == expected_routes + + def test_update_celery_worker_routes_deleted(self, mocker, conf): + instance = mocker.MagicMock() + instance.hostname = 'east-1' + instance.is_controller = mocker.MagicMock(return_value=False) + conf.CELERY_ROUTES = {'awx.main.tasks.awx_isolated_heartbeat': 'foobar'} + + update_celery_worker_routes(instance, conf) + assert 'awx.main.tasks.awx_isolated_heartbeat' not in conf.CELERY_ROUTES + diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py new file mode 100644 index 0000000000..9efb3e9cf3 --- /dev/null +++ b/awx/main/utils/ha.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Ansible Tower by Red Hat +# All Rights Reserved. + +# Django +from django.conf import settings + +# AWX +from awx.main.models import Instance + + +def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): + removed_queues = [] + added_queues = [] + ig_names = set(instance.rampart_groups.values_list('name', flat=True)) + worker_queue_names = set([q['name'] for q in worker_queues]) + + + # Remove queues that aren't in the instance group + for queue in worker_queues: + if queue['name'] in settings.AWX_CELERY_QUEUES_STATIC or \ + queue['alias'] in settings.AWX_CELERY_QUEUES_STATIC: + continue + + if queue['name'] not in ig_names | set([instance.hostname]): + app.control.cancel_consumer(queue['name'], reply=True, destination=[worker_name]) + removed_queues.append(queue['name']) + + # Add queues for instance and instance groups + for queue_name in ig_names | set([instance.hostname]): + if queue_name not in worker_queue_names: + app.control.add_consumer(queue_name, reply=True, destination=[worker_name]) + added_queues.append(queue_name) + + return (added_queues, removed_queues) + + +def update_celery_worker_routes(instance, conf): + tasks = [ + 'awx.main.tasks.cluster_node_heartbeat', + 'awx.main.tasks.purge_old_stdout_files', + ] + routes_updated = {} + + # Instance is, effectively, a controller node + if instance.is_controller(): + tasks.append('awx.main.tasks.awx_isolated_heartbeat') + else: + if 'awx.main.tasks.awx_isolated_heartbeat' in conf.CELERY_ROUTES: + del conf.CELERY_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] + + for t in tasks: + conf.CELERY_ROUTES[t] = {'queue': instance.hostname, 'routing_key': instance.hostname} + routes_updated[t] = conf.CELERY_ROUTES[t] + + return routes_updated + + +def register_celery_worker_queues(app, celery_worker_name): + instance = Instance.objects.me() + added_queues = [] + removed_queues = [] + + celery_host_queues = app.control.inspect([celery_worker_name]).active_queues() + + celery_worker_queues = celery_host_queues[celery_worker_name] if celery_host_queues else [] + (added_queues, removed_queues) = _add_remove_celery_worker_queues(app, instance, celery_worker_queues, celery_worker_name) + + return (instance, removed_queues, added_queues) + diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 2bcb8ee3d2..c97348e45e 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -392,6 +392,18 @@ EMAIL_HOST_USER = '' EMAIL_HOST_PASSWORD = '' EMAIL_USE_TLS = False +# The number of seconds to sleep between status checks for jobs running on isolated nodes +AWX_ISOLATED_CHECK_INTERVAL = 30 + +# The timeout (in seconds) for launching jobs on isolated nodes +AWX_ISOLATED_LAUNCH_TIMEOUT = 600 + +# Ansible connection timeout (in seconds) for communicating with isolated instances +AWX_ISOLATED_CONNECTION_TIMEOUT = 10 + +# The time (in seconds) between the periodic isolated heartbeat status check +AWX_ISOLATED_PERIODIC_CHECK = 600 + # Memcached django cache configuration # CACHES = { # 'default': { @@ -435,20 +447,12 @@ CELERY_BEAT_MAX_LOOP_INTERVAL = 60 CELERY_RESULT_BACKEND = 'django-db' CELERY_IMPORTS = ('awx.main.scheduler.tasks',) CELERY_TASK_QUEUES = ( - Queue('default', Exchange('default'), routing_key='default'), Queue('tower', Exchange('tower'), routing_key='tower'), - Queue('tower_scheduler', Exchange('scheduler', type='topic'), routing_key='tower_scheduler.job.#', durable=False), Broadcast('tower_broadcast_all') ) -CELERY_TASK_ROUTES = { - 'awx.main.scheduler.tasks.run_task_manager': {'queue': 'tower', 'routing_key': 'tower'}, - 'awx.main.scheduler.tasks.run_job_launch': {'queue': 'tower_scheduler', 'routing_key': 'tower_scheduler.job.launch'}, - 'awx.main.scheduler.tasks.run_job_complete': {'queue': 'tower_scheduler', 'routing_key': 'tower_scheduler.job.complete'}, - 'awx.main.tasks.cluster_node_heartbeat': {'queue': 'default', 'routing_key': 'cluster.heartbeat'}, - 'awx.main.tasks.purge_old_stdout_files': {'queue': 'default', 'routing_key': 'cluster.heartbeat'}, -} +CELERY_TASK_ROUTES = {} -CELERY_BEAT_SCHEDULE = { +CELERYBEAT_SCHEDULE = { 'tower_scheduler': { 'task': 'awx.main.tasks.awx_periodic_scheduler', 'schedule': timedelta(seconds=30), @@ -474,11 +478,21 @@ CELERY_BEAT_SCHEDULE = { 'task_manager': { 'task': 'awx.main.scheduler.tasks.run_task_manager', 'schedule': timedelta(seconds=20), - 'options': {'expires': 20,} + 'options': {'expires': 20} }, + 'isolated_heartbeat': { + 'task': 'awx.main.tasks.awx_isolated_heartbeat', + 'schedule': timedelta(seconds=AWX_ISOLATED_PERIODIC_CHECK), + 'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2}, + } } AWX_INCONSISTENT_TASK_INTERVAL = 60 * 3 +# Celery queues that will always be listened to by celery workers +# Note: Broadcast queues have unique, auto-generated names, with the alias +# property value of the original queue name. +AWX_CELERY_QUEUES_STATIC = ['tower_broadcast_all',] + # Django Caching Configuration if is_testing(): CACHES = { @@ -627,18 +641,6 @@ AWX_ANSIBLE_CALLBACK_PLUGINS = "" # Time at which an HA node is considered active AWX_ACTIVE_NODE_TIME = 7200 -# The number of seconds to sleep between status checks for jobs running on isolated nodes -AWX_ISOLATED_CHECK_INTERVAL = 30 - -# The timeout (in seconds) for launching jobs on isolated nodes -AWX_ISOLATED_LAUNCH_TIMEOUT = 600 - -# Ansible connection timeout (in seconds) for communicating with isolated instances -AWX_ISOLATED_CONNECTION_TIMEOUT = 10 - -# The time (in seconds) between the periodic isolated heartbeat status check -AWX_ISOLATED_PERIODIC_CHECK = 600 - # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' # Note: This setting may be overridden by database settings. PENDO_TRACKING_STATE = "off" diff --git a/awx/settings/development.py b/awx/settings/development.py index 682cf21dd8..617c0b6745 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -138,15 +138,6 @@ except ImportError: sys.exit(1) CLUSTER_HOST_ID = socket.gethostname() -CELERY_TASK_ROUTES['awx.main.tasks.cluster_node_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} -# Production only runs this schedule on controlling nodes -# but development will just run it on all nodes -CELERY_TASK_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} -CELERY_BEAT_SCHEDULE['isolated_heartbeat'] = { - 'task': 'awx.main.tasks.awx_isolated_heartbeat', - 'schedule': timedelta(seconds = AWX_ISOLATED_PERIODIC_CHECK), - 'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2,} -} # Supervisor service name dictionary used for programatic restart SERVICE_NAME_DICT = { diff --git a/installer/image_build/files/settings.py b/installer/image_build/files/settings.py index d9a56df2f1..aac778aaba 100644 --- a/installer/image_build/files/settings.py +++ b/installer/image_build/files/settings.py @@ -31,9 +31,6 @@ AWX_PROOT_ENABLED = False CLUSTER_HOST_ID = "awx" SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' -CELERY_TASK_QUEUES += (Queue(CLUSTER_HOST_ID, Exchange(CLUSTER_HOST_ID), routing_key=CLUSTER_HOST_ID),) -CELERY_TASK_ROUTES['awx.main.tasks.cluster_node_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} -CELERY_TASK_ROUTES['awx.main.tasks.purge_old_stdout_files'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} ############################################################################### diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 857f941c96..ad49df4587 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,8 +3,7 @@ nodaemon = True umask = 022 [program:celery] -# TODO: Needs to be reworked to dynamically use instance group queues -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_scheduler,tower_broadcast_all,tower,%(host_node_name)s -n celery@localhost +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@localhost directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} diff --git a/tools/docker-compose/supervisor.conf b/tools/docker-compose/supervisor.conf index 7f72e269c4..b0700e1442 100644 --- a/tools/docker-compose/supervisor.conf +++ b/tools/docker-compose/supervisor.conf @@ -4,7 +4,7 @@ minfds = 4096 nodaemon=true [program:celeryd] -command = celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=/celerybeat-schedule -Q tower_scheduler,tower_broadcast_all,%(ENV_AWX_GROUP_QUEUES)s,%(ENV_HOSTNAME)s -n celery@%(ENV_HOSTNAME)s +command = celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=/celerybeat-schedule -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s autostart = true autorestart = true redirect_stderr=true From 6ede1dfbea42b6ceda61f2aa07d34c8a8866ff51 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 5 Oct 2017 20:52:17 -0400 Subject: [PATCH 02/17] Update openshift installer to support rabbitmq autoscale * Switch rabbitmq container out for one that supports autoscale * Add etcd pod to support autoscale negotiation --- installer/openshift/tasks/main.yml | 9 ++++ .../openshift/templates/deployment.yml.j2 | 28 ++++++++++-- installer/openshift/templates/etcd.yml.j2 | 44 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 installer/openshift/templates/etcd.yml.j2 diff --git a/installer/openshift/tasks/main.yml b/installer/openshift/tasks/main.yml index 0de60caa98..fd6c967d24 100644 --- a/installer/openshift/tasks/main.yml +++ b/installer/openshift/tasks/main.yml @@ -121,6 +121,15 @@ dest: "{{ openshift_base_path }}/deployment.yml" mode: '0600' +- name: Template Openshift AWX etcd2 + template: + src: etcd.yml.j2 + dest: "{{ openshift_base_path }}/etcd.yml" + mode: '0600' + +- name: Apply etcd deployment + shell: "oc apply -f {{ openshift_base_path }}/etcd.yml" + - name: Apply Configmap shell: "oc apply -f {{ openshift_base_path }}/configmap.yml" diff --git a/installer/openshift/templates/deployment.yml.j2 b/installer/openshift/templates/deployment.yml.j2 index 775ad8a49c..1152503b12 100644 --- a/installer/openshift/templates/deployment.yml.j2 +++ b/installer/openshift/templates/deployment.yml.j2 @@ -41,18 +41,40 @@ spec: - name: AWX_ADMIN_PASSWORD value: {{ default_admin_password|default('password') }} - name: awx-rabbit - image: rabbitmq:3 + image: ansible/awx_rabbitmq:latest + imagePullPolicy: Always env: + # For consupmption by rabbitmq-env.conf + - name: MY_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: RABBITMQ_USE_LONGNAME + value: "true" + - name: ERLANG_COOKIE + value: "test" - name: RABBITMQ_ERLANG_COOKIE - value: secretb + value: "secretb" - name: RABBITMQ_NODENAME - value: rabbitmq + value: "rabbit@$(MY_POD_IP)" + - name: AUTOCLUSTER_TYPE + value: "etcd" + - name: AUTOCLUSTER_DELAY + value: "60" + - name: ETCD_HOST + value: "etcd" + - name: AUTOCLUSTER_CLEANUP + value: "true" + - name: CLEANUP_WARN_ONLY + value: "true" - name: RABBITMQ_DEFAULT_USER value: awx - name: RABBITMQ_DEFAULT_PASS value: abcdefg - name: RABBITMQ_DEFAULT_VHOST value: awx + - name: RABBITMQ_CONFIG_FILE + value: /etc/rabbitmq/rabbitmq - name: awx-memcached image: memcached volumes: diff --git a/installer/openshift/templates/etcd.yml.j2 b/installer/openshift/templates/etcd.yml.j2 new file mode 100644 index 0000000000..abbfe13185 --- /dev/null +++ b/installer/openshift/templates/etcd.yml.j2 @@ -0,0 +1,44 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: etcd + namespace: {{ awx_openshift_project }} +spec: + replicas: 1 + template: + metadata: + labels: + name: awx-etcd2 + service: etcd + spec: + containers: + - name: etcd + image: elcolio/etcd:latest + ports: + - containerPort: 4001 + volumeMounts: + - mountPath: /data + name: datadir + volumes: + - name: datadir + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + labels: + name: awx-etcd + name: etcd + namespace: {{ awx_openshift_project }} +spec: + ports: + - name: etcd + port: 4001 + protocol: TCP + targetPort: 4001 + selector: + name: awx-etcd2 + sessionAffinity: None + type: ClusterIP From 624289bed79af33b6a0765e63c6489341bf837e0 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Fri, 6 Oct 2017 10:47:02 -0400 Subject: [PATCH 03/17] Add support for directly managing instance groups * Associating/Disassociating an instance with a group * Triggering a topology rebuild on that change * Force rabbitmq cleanup of offline nodes * Automatically check for dependent service startup * Fetch and set hostname for celery so it doesn't clobber other celeries * Rely on celery init signals to dyanmically set listen queues * Removing old total_capacity instance manager property --- awx/api/views.py | 33 ++++++++++++++++--- awx/main/access.py | 18 ++++++++-- awx/main/managers.py | 5 --- awx/main/tests/functional/test_jobs.py | 5 +-- .../image_build/files/launch_awx_task.sh | 5 +++ .../image_build/files/supervisor_task.conf | 2 +- .../openshift/templates/deployment.yml.j2 | 21 +++++++++++- 7 files changed, 70 insertions(+), 19 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index ac835a30d0..df7d2b61e8 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -57,7 +57,7 @@ import pytz from wsgiref.util import FileWrapper # AWX -from awx.main.tasks import send_notifications +from awx.main.tasks import send_notifications, handle_ha_toplogy_changes from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TokenGetAuthentication @@ -148,6 +148,29 @@ class UnifiedJobDeletionMixin(object): return Response(status=status.HTTP_204_NO_CONTENT) +class InstanceGroupMembershipMixin(object): + ''' + Manages signaling celery to reload its queue configuration on Instance Group membership changes + ''' + def attach(self, request, *args, **kwargs): + response = super(InstanceGroupMembershipMixin, self).attach(request, *args, **kwargs) + if status.is_success(response.status_code): + handle_ha_toplogy_changes.apply_async() + return response + + def unattach(self, request, *args, **kwargs): + response = super(InstanceGroupMembershipMixin, self).unattach(request, *args, **kwargs) + if status.is_success(response.status_code): + handle_ha_toplogy_changes.apply_async() + return response + + def destroy(self, request, *args, **kwargs): + response = super(InstanceGroupMembershipMixin, self).destroy(request, *args, **kwargs) + if status.is_success(response.status_code): + handle_ha_toplogy_changes.apply_async() + return response + + class ApiRootView(APIView): authentication_classes = [] @@ -548,7 +571,7 @@ class InstanceUnifiedJobsList(SubListAPIView): return qs -class InstanceInstanceGroupsList(SubListAPIView): +class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView): view_name = _("Instance's Instance Groups") model = InstanceGroup @@ -558,7 +581,7 @@ class InstanceInstanceGroupsList(SubListAPIView): relationship = 'rampart_groups' -class InstanceGroupList(ListAPIView): +class InstanceGroupList(ListCreateAPIView): view_name = _("Instance Groups") model = InstanceGroup @@ -566,7 +589,7 @@ class InstanceGroupList(ListAPIView): new_in_320 = True -class InstanceGroupDetail(RetrieveAPIView): +class InstanceGroupDetail(InstanceGroupMembershipMixin, RetrieveDestroyAPIView): view_name = _("Instance Group Detail") model = InstanceGroup @@ -584,7 +607,7 @@ class InstanceGroupUnifiedJobsList(SubListAPIView): new_in_320 = True -class InstanceGroupInstanceList(SubListAPIView): +class InstanceGroupInstanceList(InstanceGroupMembershipMixin, SubListAttachDetachAPIView): view_name = _("Instance Group's Instances") model = Instance diff --git a/awx/main/access.py b/awx/main/access.py index 174396e59b..5ce76a52f8 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -424,6 +424,18 @@ class InstanceAccess(BaseAccess): return Instance.objects.filter( rampart_groups__in=self.user.get_queryset(InstanceGroup)).distinct() + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + if relationship == 'rampart_groups' and isinstance(sub_obj, InstanceGroup): + return self.user.is_superuser + return super(InstanceAccess, self).can_attach(obj, sub_obj, relationship, *args, **kwargs) + + def can_unattach(self, obj, sub_obj, relationship, data=None): + if relationship == 'rampart_groups' and isinstance(sub_obj, InstanceGroup): + return self.user.is_superuser + return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, *args, **kwargs) + def can_add(self, data): return False @@ -444,13 +456,13 @@ class InstanceGroupAccess(BaseAccess): organization__in=Organization.accessible_pk_qs(self.user, 'admin_role')) def can_add(self, data): - return False + return self.user.is_superuser def can_change(self, obj, data): - return False + return self.user.is_superuser def can_delete(self, obj): - return False + return self.user.is_superuser class UserAccess(BaseAccess): diff --git a/awx/main/managers.py b/awx/main/managers.py index aa478cb027..f6f2dfd5b7 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -93,11 +93,6 @@ class InstanceManager(models.Manager): """Return count of active Tower nodes for licensing.""" return self.all().count() - def total_capacity(self): - sumval = self.filter(modified__gte=now() - timedelta(seconds=settings.AWX_ACTIVE_NODE_TIME)) \ - .aggregate(total_capacity=Sum('capacity'))['total_capacity'] - return max(50, sumval) - def my_role(self): # NOTE: TODO: Likely to repurpose this once standalone ramparts are a thing return "tower" diff --git a/awx/main/tests/functional/test_jobs.py b/awx/main/tests/functional/test_jobs.py index 6500963fc1..e9504d1232 100644 --- a/awx/main/tests/functional/test_jobs.py +++ b/awx/main/tests/functional/test_jobs.py @@ -1,7 +1,4 @@ -from awx.main.models import ( - Job, - Instance -) +from awx.main.models import Job, Instance from django.test.utils import override_settings import pytest diff --git a/installer/image_build/files/launch_awx_task.sh b/installer/image_build/files/launch_awx_task.sh index b2e489d069..88b59d63fa 100755 --- a/installer/image_build/files/launch_awx_task.sh +++ b/installer/image_build/files/launch_awx_task.sh @@ -4,7 +4,12 @@ if [ `id -u` -ge 500 ]; then cat /tmp/passwd > /etc/passwd rm /tmp/passwd fi + +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=localhost port=11211" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=localhost port=5672" all ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m postgresql_db -U $DATABASE_USER -a "name=$DATABASE_NAME owner=$DATABASE_USER login_user=$DATABASE_USER login_host=$DATABASE_HOST login_password=$DATABASE_PASSWORD port=$DATABASE_PORT" all + awx-manage migrate --noinput --fake-initial if [ ! -z "$AWX_ADMIN_USER" ]&&[ ! -z "$AWX_ADMIN_PASSWORD" ]; then echo "from django.contrib.auth.models import User; User.objects.create_superuser('$AWX_ADMIN_USER', 'root@localhost', '$AWX_ADMIN_PASSWORD')" | awx-manage shell diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index ad49df4587..19aac3c3b0 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,7 +3,7 @@ nodaemon = True umask = 022 [program:celery] -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@localhost +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} diff --git a/installer/openshift/templates/deployment.yml.j2 b/installer/openshift/templates/deployment.yml.j2 index 1152503b12..eddd193938 100644 --- a/installer/openshift/templates/deployment.yml.j2 +++ b/installer/openshift/templates/deployment.yml.j2 @@ -66,7 +66,9 @@ spec: - name: AUTOCLUSTER_CLEANUP value: "true" - name: CLEANUP_WARN_ONLY - value: "true" + value: "false" + - name: CLEANUP_INTERVAL + value: "30" - name: RABBITMQ_DEFAULT_USER value: awx - name: RABBITMQ_DEFAULT_PASS @@ -102,6 +104,23 @@ spec: selector: name: awx-web-deploy --- +--- +apiVersion: v1 +kind: Service +metadata: + name: awx-rmq-mgmt + namespace: {{ awx_openshift_project }} + labels: + name: awx-rmq-mgmt +spec: + type: ClusterIP + ports: + - name: rmqmgmt + port: 15672 + targetPort: 15672 + selector: + name: awx-web-deploy +--- apiVersion: v1 kind: Route metadata: From 0e97dc4b84a6e677b1b44be17c578fce69a8c1a7 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 10 Oct 2017 12:34:49 -0400 Subject: [PATCH 04/17] Beat and celery clustering fixes * use embedded beat rather than standalone * dynamically set celeryd hostname at runtime * add embeded beat flag to celery startup * Embedded beat mode routes will piggyback off of celery worker setup signal --- Makefile | 2 +- awx/main/tasks.py | 10 ++++++++-- installer/image_build/files/supervisor_task.conf | 14 +------------- tools/docker-compose/supervisor.conf | 2 +- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 874a4e52ee..99e4d0c325 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ COMPOSE_HOST ?= $(shell hostname) VENV_BASE ?= /venv SCL_PREFIX ?= -CELERY_SCHEDULE_FILE ?= /celerybeat-schedule +CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db DEV_DOCKER_TAG_BASE ?= gcr.io/ansible-tower-engineering # Python packages to install only from source (not from binary wheels) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 36274b926d..0b52f2e93e 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -26,7 +26,7 @@ except Exception: # Celery from celery import Task, shared_task -from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, beat_init +from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, celeryd_after_setup # Django from django.conf import settings @@ -168,7 +168,6 @@ def handle_ha_toplogy_worker_ready(sender, **kwargs): .format(instance.hostname, removed_queues, added_queues)) -@beat_init.connect @celeryd_init.connect def handle_update_celery_routes(sender=None, conf=None, **kwargs): conf = conf if conf else sender.app.conf @@ -179,6 +178,13 @@ def handle_update_celery_routes(sender=None, conf=None, **kwargs): .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) +@celeryd_after_setup.connect +def handle_update_celery_hostname(sender, instance, **kwargs): + tower_instance = Instance.objects.me() + instance.hostname = 'celery@{}'.format(tower_instance.hostname) + logger.warn("Set hostname to {}".format(instance.hostname)) + + @shared_task(queue='tower', base=LogErrorsTask) def send_notifications(notification_list, job_id=None): if not isinstance(notification_list, list): diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 19aac3c3b0..3bc71cf75b 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,7 +3,7 @@ nodaemon = True umask = 022 [program:celery] -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -B -l debug --autoscale=4 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@$(ENV_HOSTNAME)s directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} @@ -15,18 +15,6 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -[program:awx-celeryd-beat] -command = /var/lib/awx/venv/awx/bin/celery beat -A awx -l debug --pidfile= -s /var/lib/awx/beat.db -directory = /var/lib/awx -autostart = true -autorestart = true -stopwaitsecs = 5 -redirect_stderr=true -stdout_logfile = /dev/stdout -stdout_logfile_maxbytes = 0 -stderr_logfile = /dev/stderr -stderr_logfile_maxbytes = 0 - [program:callback-receiver] command = awx-manage run_callback_receiver directory = /var/lib/awx diff --git a/tools/docker-compose/supervisor.conf b/tools/docker-compose/supervisor.conf index b0700e1442..cedb784324 100644 --- a/tools/docker-compose/supervisor.conf +++ b/tools/docker-compose/supervisor.conf @@ -4,7 +4,7 @@ minfds = 4096 nodaemon=true [program:celeryd] -command = celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=/celerybeat-schedule -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s +command = celery worker -A awx -l DEBUG -B --autoscale=20,3 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s autostart = true autorestart = true redirect_stderr=true From c819560d39ed101517eb3d8138c33b8d0d8f8547 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 12 Oct 2017 14:14:30 -0400 Subject: [PATCH 05/17] Add automatic deprovisioning support, only enabled for openshift * Implement a config watcher for service restarts * If the configmap bind point changes then restart all services --- awx/main/tasks.py | 4 ++ awx/settings/defaults.py | 3 + installer/image_build/files/supervisor.conf | 9 +++ .../image_build/files/supervisor_task.conf | 9 +++ installer/image_build/tasks/main.yml | 6 ++ installer/image_build/templates/Dockerfile.j2 | 3 +- .../openshift/templates/configmap.yml.j2 | 5 +- tools/scripts/config-watcher | 58 +++++++++++++++++++ 8 files changed, 95 insertions(+), 2 deletions(-) create mode 100755 tools/scripts/config-watcher diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 0b52f2e93e..0e9c8bcea5 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -292,6 +292,10 @@ def cluster_node_heartbeat(self): other_inst.save(update_fields=['capacity']) logger.error("Host {} last checked in at {}, marked as lost.".format( other_inst.hostname, other_inst.modified)) + if settings.AWX_AUTO_DEPROVISION_INSTANCES: + deprovision_hostname = other_inst.hostname + other_inst.delete() + logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname)) except DatabaseError as e: if 'did not affect any rows' in str(e): logger.debug('Another instance has marked {} as lost'.format(other_inst.hostname)) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index c97348e45e..db3dec23fb 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -641,6 +641,9 @@ AWX_ANSIBLE_CALLBACK_PLUGINS = "" # Time at which an HA node is considered active AWX_ACTIVE_NODE_TIME = 7200 +# Automatically remove nodes that have missed their heartbeats after some time +AWX_AUTO_DEPROVISION_INSTANCES = False + # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' # Note: This setting may be overridden by database settings. PENDO_TRACKING_STATE = "off" diff --git a/installer/image_build/files/supervisor.conf b/installer/image_build/files/supervisor.conf index ec0acac101..cfcaf5ebe9 100644 --- a/installer/image_build/files/supervisor.conf +++ b/installer/image_build/files/supervisor.conf @@ -41,6 +41,15 @@ priority=5 # TODO: Exit Handler +[eventlistener:awx-config-watcher] +command=/usr/bin/config-watcher +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +events=TICK_60 +priority=0 + [unix_http_server] file=/tmp/supervisor.sock diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 3bc71cf75b..1a4e613925 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -43,6 +43,15 @@ priority=5 # TODO: Exit Handler +[eventlistener:awx-config-watcher] +command=/usr/bin/config-watcher +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +events=TICK_60 +priority=0 + [unix_http_server] file=/tmp/supervisor.sock diff --git a/installer/image_build/tasks/main.yml b/installer/image_build/tasks/main.yml index d3dd66207f..f67e3abf7e 100644 --- a/installer/image_build/tasks/main.yml +++ b/installer/image_build/tasks/main.yml @@ -163,6 +163,12 @@ dest: "{{ docker_base_path }}/requirements" delegate_to: localhost +- name: Stage config watcher + copy: + src: ../tools/scripts/config-watcher + dest: "{{ docker_base_path }}/config-watcher" + delegate_to: localhost + - name: Stage Makefile copy: src: ../Makefile diff --git a/installer/image_build/templates/Dockerfile.j2 b/installer/image_build/templates/Dockerfile.j2 index 16c118b1fe..ab3db53490 100644 --- a/installer/image_build/templates/Dockerfile.j2 +++ b/installer/image_build/templates/Dockerfile.j2 @@ -22,6 +22,7 @@ ADD requirements/requirements_ansible.txt \ requirements/requirements_git.txt \ /tmp/requirements/ ADD ansible.repo /etc/yum.repos.d/ansible.repo +ADD config-watcher /usr/bin/config-watcher ADD RPM-GPG-KEY-ansible-release /etc/pki/rpm-gpg/RPM-GPG-KEY-ansible-release # OS Dependencies WORKDIR /tmp @@ -50,7 +51,7 @@ ADD supervisor.conf /supervisor.conf ADD supervisor_task.conf /supervisor_task.conf ADD launch_awx.sh /usr/bin/launch_awx.sh ADD launch_awx_task.sh /usr/bin/launch_awx_task.sh -RUN chmod +rx /usr/bin/launch_awx.sh && chmod +rx /usr/bin/launch_awx_task.sh +RUN chmod +rx /usr/bin/launch_awx.sh && chmod +rx /usr/bin/launch_awx_task.sh && chmod +rx /usr/bin/config-watcher ADD settings.py /etc/tower/settings.py RUN chmod g+w /etc/passwd RUN chmod -R 777 /var/log/nginx && chmod -R 777 /var/lib/nginx diff --git a/installer/openshift/templates/configmap.yml.j2 b/installer/openshift/templates/configmap.yml.j2 index 79c14fefee..8fb1e2b4bf 100644 --- a/installer/openshift/templates/configmap.yml.j2 +++ b/installer/openshift/templates/configmap.yml.j2 @@ -12,7 +12,10 @@ data: # Container environments don't like chroots AWX_PROOT_ENABLED = False - + + # Automatically deprovision pods that go offline + AWX_AUTO_DEPROVISION_INSTANCES = True + #Autoprovisioning should replace this CLUSTER_HOST_ID = socket.gethostname() SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' diff --git a/tools/scripts/config-watcher b/tools/scripts/config-watcher new file mode 100755 index 0000000000..ffa2e56a1f --- /dev/null +++ b/tools/scripts/config-watcher @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +import os +import sys +import hashlib +from supervisor import childutils + + +def hash(f): + s = hashlib.sha1() + with open(f, "rb") as fd: + for chunk in iter(lambda: fd.read(4096), b""): + s.update(chunk) + return s.hexdigest() + + +def last_hash(f): + with open(f, "r") as fd: + return fd.read().strip() + + +def write_hash(f, h): + with open(f, "w") as fd: + fd.write(h) + + +def main(): + while 1: + rpc = childutils.getRPCInterface(os.environ) + headers, payload = childutils.listener.wait(sys.stdin, sys.stdout) + if not headers['eventname'].startswith('TICK'): + childutils.listener.ok(sys.stdout) + continue + try: + current_hash = hash("/etc/tower/settings.py") + except: + sys.stderr.write("Could not open settings.py, skipping config watcher") + childutils.listener.ok(sys.stdout) + continue + try: + if current_hash == last_hash("/var/lib/awx/.configsha"): + childutils.listener.ok(sys.stdout) + continue + else: + sys.stderr.write("Config changed, reloading services") + for proc in rpc.supervisor.getAllProcessInfo(): + group = proc['group'] + name = proc['name'] + program = "{}:{}".format(group, name) + if group == "tower-processes": + sys.stderr.write('Restarting %s\n' % program) + rpc.supervisor.stopProcess(program) + rpc.supervisor.startProcess(program) + + except: + sys.stderr.write("No previous hash found") + write_hash("/var/lib/awx/.configsha") + childutils.listener.ok(sys.stdout) From 56abfa732efa38c1bfa707cb110bbca7c4fab817 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Tue, 14 Nov 2017 13:49:06 -0500 Subject: [PATCH 06/17] Adding initial instance group policies and policy evaluation planner --- awx/api/serializers.py | 3 +- .../management/commands/register_queue.py | 10 ++++- .../0013_v330_instancegroup_policies.py | 30 ++++++++++++++ awx/main/models/ha.py | 14 +++++++ awx/main/tasks.py | 40 ++++++++++++++++++- 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 awx/main/migrations/0013_v330_instancegroup_policies.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a0822cea0f..adb17dd65b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4011,7 +4011,8 @@ class InstanceGroupSerializer(BaseSerializer): model = InstanceGroup fields = ("id", "type", "url", "related", "name", "created", "modified", "capacity", "committed_capacity", "consumed_capacity", - "percent_capacity_remaining", "jobs_running", "instances", "controller") + "percent_capacity_remaining", "jobs_running", "instances", "controller", + "policy_instance_percentage", "policy_instance_minimum") def get_related(self, obj): res = super(InstanceGroupSerializer, self).get_related(obj) diff --git a/awx/main/management/commands/register_queue.py b/awx/main/management/commands/register_queue.py index 548e305bcc..1e7912836d 100644 --- a/awx/main/management/commands/register_queue.py +++ b/awx/main/management/commands/register_queue.py @@ -17,6 +17,10 @@ class Command(BaseCommand): help='Comma-Delimited Hosts to add to the Queue') parser.add_argument('--controller', dest='controller', type=str, default='', help='The controlling group (makes this an isolated group)') + parser.add_argument('--instance_percent', dest='instance_percent', type=int, default=0, + help='The percentage of active instances that will be assigned to this group'), + parser.add_argument('--instance_minimum', dest='instance_minimum', type=int, default=0, + help='The minimum number of instance that will be retained for this group from available instances') def handle(self, **options): queuename = options.get('queuename') @@ -38,7 +42,9 @@ class Command(BaseCommand): changed = True else: print("Creating instance group {}".format(queuename)) - ig = InstanceGroup(name=queuename) + ig = InstanceGroup(name=queuename, + policy_instance_percentage=options.get('instance_percent'), + policy_instance_minimum=options.get('instance_minimum')) if control_ig: ig.controller = control_ig ig.save() @@ -60,5 +66,7 @@ class Command(BaseCommand): sys.exit(1) else: print("Instance already registered {}".format(instance[0].hostname)) + ig.policy_instance_list = instance_list + ig.save() if changed: print('(changed: True)') diff --git a/awx/main/migrations/0013_v330_instancegroup_policies.py b/awx/main/migrations/0013_v330_instancegroup_policies.py new file mode 100644 index 0000000000..b8fa658fb8 --- /dev/null +++ b/awx/main/migrations/0013_v330_instancegroup_policies.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0008_v320_drop_v1_credential_fields'), + ] + + operations = [ + migrations.AddField( + model_name='instancegroup', + name='policy_instance_list', + field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', blank=True), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_minimum', + field=models.IntegerField(default=0, help_text='Static minimum number of Instances to automatically assign to this group'), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_percentage', + field=models.IntegerField(default=0, help_text='Percentage of Instances to automatically assign to this group'), + ), + ] diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index cb63beb126..eac0bec22f 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -12,6 +12,7 @@ from solo.models import SingletonModel from awx.api.versioning import reverse from awx.main.managers import InstanceManager, InstanceGroupManager +from awx.main.fields import JSONField from awx.main.models.inventory import InventoryUpdate from awx.main.models.jobs import Job from awx.main.models.projects import ProjectUpdate @@ -88,6 +89,19 @@ class InstanceGroup(models.Model): default=None, null=True ) + policy_instance_percentage = models.IntegerField( + default=0, + help_text=_("Percentage of Instances to automatically assign to this group") + ) + policy_instance_minimum = models.IntegerField( + default=0, + help_text=_("Static minimum number of Instances to automatically assign to this group") + ) + policy_instance_list = JSONField( + default=[], + blank=True, + help_text=_("List of exact-match Instances that will always be automatically assigned to this group") + ) def get_absolute_url(self, request=None): return reverse('api:instance_group_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 0e9c8bcea5..109b92a771 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2,7 +2,8 @@ # All Rights Reserved. # Python -from collections import OrderedDict +import codecs +from collections import OrderedDict, namedtuple import ConfigParser import cStringIO import functools @@ -131,6 +132,43 @@ def inform_cluster_of_shutdown(*args, **kwargs): logger.exception('Encountered problem with normal shutdown signal.') +@shared_task(bind=True, queue='tower', base=LogErrorsTask) +def apply_cluster_membership_policies(self): + considered_instances = Instance.objects.all().order_by('id').only('id') + total_instances = considered_instances.count() + actual_groups = [] + actual_instances = [] + Group = namedtuple('Group', ['obj', 'instances']) + Instance = namedtuple('Instance', ['obj', 'groups']) + # Process policy instance list first, these will represent manually managed instances + # that will not go through automatic policy determination + for ig in InstanceGroup.objects.all(): + group_actual = Group(obj=ig, instances=[]) + for i in ig.policy_instance_list: + group_actual.instances.append(i) + if i in considered_instances: + considered_instances.remove(i) + actual_groups.append(group_actual) + # Process Instance minimum policies next, since it represents a concrete lower bound to the + # number of instances to make available to instance groups + for i in considered_instances: + instance_actual = Instance(obj=i, groups=[]) + for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): + if len(g.instances) < g.obj.policy_instance_minimum: + g.instances.append(instance_actual.obj.id) + instance_actual.groups.append(g.obj.id) + break + actual_instances.append(instance_actual) + # Finally process instance policy percentages + for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): + if 100 * float(len(g.instances)) / total_instances < g.obj.policy_instance_percentage: + g.instances.append(i.obj.id) + i.groups.append(g.obj.id) + break + # Next step + + @shared_task(queue='tower_broadcast_all', bind=True, base=LogErrorsTask) def handle_setting_changes(self, setting_keys): orig_len = len(setting_keys) From d9e774c4b680e9c797a72452b1fa28e40f86dfd3 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 16 Nov 2017 14:55:17 -0500 Subject: [PATCH 07/17] Updates for automatic triggering of policies * Switch policy router queue to not be "tower" so that we don't fall into a chicken/egg scenario * Show fixed policy list in serializer so a user can determine if an instance is manually managed * Change IG membership mixin to not directly handle applying topology changes. Instead it just makes sure the policy instance list is accurate * Add create/delete hooks for instances and groups to trigger policy re-evaluation * Update policy algorithm for fairer distribution * Fix an issue where CELERY_ROUTES wasn't renamed after celery/django upgrade * Update unit tests to be more explicit * Update count calculations used by algorithm to only consider non-manual instances * Adding unit tests and fixture * Don't propagate logging messages from awx.main.tasks and awx.main.scheduler * Use advisory lock to prevent policy eval conflicts * Allow updating instance groups from view --- Makefile | 4 +- awx/api/serializers.py | 2 +- awx/api/views.py | 32 ++++--- ...py => 0018_v330_instancegroup_policies.py} | 5 +- awx/main/models/ha.py | 28 +++++- awx/main/tasks.py | 88 +++++++++++-------- awx/main/tests/factories/fixtures.py | 5 +- awx/main/tests/factories/tower.py | 4 +- .../task_management/test_rampart_groups.py | 33 +++++++ awx/main/tests/unit/utils/test_ha.py | 8 +- awx/main/utils/ha.py | 9 +- awx/settings/defaults.py | 5 +- .../image_build/files/launch_awx_task.sh | 2 +- .../image_build/files/supervisor_task.conf | 2 +- 14 files changed, 159 insertions(+), 68 deletions(-) rename awx/main/migrations/{0013_v330_instancegroup_policies.py => 0018_v330_instancegroup_policies.py} (87%) diff --git a/Makefile b/Makefile index 99e4d0c325..61492e4bf4 100644 --- a/Makefile +++ b/Makefile @@ -216,13 +216,11 @@ init: . $(VENV_BASE)/awx/bin/activate; \ fi; \ $(MANAGEMENT_COMMAND) provision_instance --hostname=$(COMPOSE_HOST); \ - $(MANAGEMENT_COMMAND) register_queue --queuename=tower --hostnames=$(COMPOSE_HOST);\ + $(MANAGEMENT_COMMAND) register_queue --queuename=tower --instance_percent=100;\ if [ "$(AWX_GROUP_QUEUES)" == "tower,thepentagon" ]; then \ $(MANAGEMENT_COMMAND) provision_instance --hostname=isolated; \ $(MANAGEMENT_COMMAND) register_queue --queuename='thepentagon' --hostnames=isolated --controller=tower; \ $(MANAGEMENT_COMMAND) generate_isolated_key | ssh -o "StrictHostKeyChecking no" root@isolated 'cat > /root/.ssh/authorized_keys'; \ - elif [ "$(AWX_GROUP_QUEUES)" != "tower" ]; then \ - $(MANAGEMENT_COMMAND) register_queue --queuename=$(firstword $(subst $(comma), ,$(AWX_GROUP_QUEUES))) --hostnames=$(COMPOSE_HOST); \ fi; # Refresh development environment after pulling new code. diff --git a/awx/api/serializers.py b/awx/api/serializers.py index adb17dd65b..f134d393e6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4012,7 +4012,7 @@ class InstanceGroupSerializer(BaseSerializer): fields = ("id", "type", "url", "related", "name", "created", "modified", "capacity", "committed_capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running", "instances", "controller", - "policy_instance_percentage", "policy_instance_minimum") + "policy_instance_percentage", "policy_instance_minimum", "policy_instance_list") def get_related(self, obj): res = super(InstanceGroupSerializer, self).get_related(obj) diff --git a/awx/api/views.py b/awx/api/views.py index df7d2b61e8..56bee58c21 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -57,7 +57,7 @@ import pytz from wsgiref.util import FileWrapper # AWX -from awx.main.tasks import send_notifications, handle_ha_toplogy_changes +from awx.main.tasks import send_notifications from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TokenGetAuthentication @@ -154,20 +154,32 @@ class InstanceGroupMembershipMixin(object): ''' def attach(self, request, *args, **kwargs): response = super(InstanceGroupMembershipMixin, self).attach(request, *args, **kwargs) + sub_id, res = self.attach_validate(request) if status.is_success(response.status_code): - handle_ha_toplogy_changes.apply_async() + if self.parent_model is Instance: + ig_obj = get_object_or_400(self.model, pk=sub_id) + inst_name = ig_obj.hostname + else: + ig_obj = self.get_parent_object() + inst_name = get_object_or_400(self.model, pk=sub_id).hostname + if inst_name not in ig_obj.policy_instance_list: + ig_obj.policy_instance_list.append(inst_name) + ig_obj.save() return response def unattach(self, request, *args, **kwargs): response = super(InstanceGroupMembershipMixin, self).unattach(request, *args, **kwargs) + sub_id, res = self.attach_validate(request) if status.is_success(response.status_code): - handle_ha_toplogy_changes.apply_async() - return response - - def destroy(self, request, *args, **kwargs): - response = super(InstanceGroupMembershipMixin, self).destroy(request, *args, **kwargs) - if status.is_success(response.status_code): - handle_ha_toplogy_changes.apply_async() + if self.parent_model is Instance: + ig_obj = get_object_or_400(self.model, pk=sub_id) + inst_name = self.get_parent_object().hostname + else: + ig_obj = self.get_parent_object() + inst_name = get_object_or_400(self.model, pk=sub_id).hostname + if inst_name in ig_obj.policy_instance_list: + ig_obj.policy_instance_list.pop(ig_obj.policy_instance_list.index(inst_name)) + ig_obj.save() return response @@ -589,7 +601,7 @@ class InstanceGroupList(ListCreateAPIView): new_in_320 = True -class InstanceGroupDetail(InstanceGroupMembershipMixin, RetrieveDestroyAPIView): +class InstanceGroupDetail(RetrieveUpdateDestroyAPIView): view_name = _("Instance Group Detail") model = InstanceGroup diff --git a/awx/main/migrations/0013_v330_instancegroup_policies.py b/awx/main/migrations/0018_v330_instancegroup_policies.py similarity index 87% rename from awx/main/migrations/0013_v330_instancegroup_policies.py rename to awx/main/migrations/0018_v330_instancegroup_policies.py index b8fa658fb8..63403f6766 100644 --- a/awx/main/migrations/0013_v330_instancegroup_policies.py +++ b/awx/main/migrations/0018_v330_instancegroup_policies.py @@ -8,14 +8,15 @@ import awx.main.fields class Migration(migrations.Migration): dependencies = [ - ('main', '0008_v320_drop_v1_credential_fields'), + ('main', '0017_v330_move_deprecated_stdout'), ] operations = [ migrations.AddField( model_name='instancegroup', name='policy_instance_list', - field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', blank=True), + field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', + blank=True), ), migrations.AddField( model_name='instancegroup', diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index eac0bec22f..12e8bf23e9 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -2,7 +2,7 @@ # All Rights Reserved. from django.db import models -from django.db.models.signals import post_save +from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ from django.conf import settings @@ -136,6 +136,32 @@ class JobOrigin(models.Model): app_label = 'main' +@receiver(post_save, sender=InstanceGroup) +def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs): + if created: + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + +@receiver(post_save, sender=Instance) +def on_instance_saved(sender, instance, created=False, raw=False, **kwargs): + if created: + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + +@receiver(post_delete, sender=InstanceGroup) +def on_instance_group_deleted(sender, instance, using, **kwargs): + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + +@receiver(post_delete, sender=Instance) +def on_instance_deleted(sender, instance, using, **kwargs): + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + # Unfortunately, the signal can't just be connected against UnifiedJob; it # turns out that creating a model's subclass doesn't fire the signal for the # superclass model. diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 109b92a771..698fda006e 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -26,7 +26,7 @@ except Exception: psutil = None # Celery -from celery import Task, shared_task +from celery import Task, shared_task, Celery from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, celeryd_after_setup # Django @@ -58,13 +58,14 @@ from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, ignore_inventory_computed_fields, ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars) from awx.main.utils.reload import restart_local_services, stop_local_services +from awx.main.utils.pglock import advisory_lock from awx.main.utils.ha import update_celery_worker_routes, register_celery_worker_queues from awx.main.utils.handlers import configure_external_logger from awx.main.consumers import emit_channel_notification from awx.conf import settings_registry __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', - 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', + 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', 'apply_cluster_membership_policies', 'update_inventory_computed_fields', 'update_host_smart_inventory_memberships', 'send_notifications', 'run_administrative_checks', 'purge_old_stdout_files'] @@ -132,41 +133,54 @@ def inform_cluster_of_shutdown(*args, **kwargs): logger.exception('Encountered problem with normal shutdown signal.') -@shared_task(bind=True, queue='tower', base=LogErrorsTask) +@shared_task(bind=True, queue='tower_instance_router', base=LogErrorsTask) def apply_cluster_membership_policies(self): - considered_instances = Instance.objects.all().order_by('id').only('id') - total_instances = considered_instances.count() - actual_groups = [] - actual_instances = [] - Group = namedtuple('Group', ['obj', 'instances']) - Instance = namedtuple('Instance', ['obj', 'groups']) - # Process policy instance list first, these will represent manually managed instances - # that will not go through automatic policy determination - for ig in InstanceGroup.objects.all(): - group_actual = Group(obj=ig, instances=[]) - for i in ig.policy_instance_list: - group_actual.instances.append(i) - if i in considered_instances: - considered_instances.remove(i) - actual_groups.append(group_actual) - # Process Instance minimum policies next, since it represents a concrete lower bound to the - # number of instances to make available to instance groups - for i in considered_instances: - instance_actual = Instance(obj=i, groups=[]) + with advisory_lock('cluster_policy_lock', wait=True): + considered_instances = Instance.objects.all().order_by('id') + total_instances = considered_instances.count() + filtered_instances = [] + actual_groups = [] + actual_instances = [] + Group = namedtuple('Group', ['obj', 'instances']) + Node = namedtuple('Instance', ['obj', 'groups']) + # Process policy instance list first, these will represent manually managed instances + # that will not go through automatic policy determination + for ig in InstanceGroup.objects.all(): + logger.info("Considering group {}".format(ig.name)) + ig.instances.clear() + group_actual = Group(obj=ig, instances=[]) + for i in ig.policy_instance_list: + inst = Instance.objects.filter(hostname=i) + if not inst.exists(): + continue + inst = inst[0] + logger.info("Policy List, adding {} to {}".format(inst.hostname, ig.name)) + group_actual.instances.append(inst.id) + ig.instances.add(inst) + filtered_instances.append(inst) + actual_groups.append(group_actual) + # Process Instance minimum policies next, since it represents a concrete lower bound to the + # number of instances to make available to instance groups + actual_instances = [Node(obj=i, groups=[]) for i in filter(lambda x: x not in filtered_instances, considered_instances)] + logger.info("Total instances not directly associated: {}".format(total_instances)) for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): - if len(g.instances) < g.obj.policy_instance_minimum: - g.instances.append(instance_actual.obj.id) - instance_actual.groups.append(g.obj.id) - break - actual_instances.append(instance_actual) - # Finally process instance policy percentages - for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): - for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): - if 100 * float(len(g.instances)) / total_instances < g.obj.policy_instance_percentage: + for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + if len(g.instances) >= g.obj.policy_instance_minimum: + break + logger.info("Policy minimum, adding {} to {}".format(i.obj.hostname, g.obj.name)) + g.obj.instances.add(i.obj) g.instances.append(i.obj.id) i.groups.append(g.obj.id) - break - # Next step + # Finally process instance policy percentages + for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): + for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage: + break + logger.info("Policy percentage, adding {} to {}".format(i.obj.hostname, g.obj.name)) + g.instances.append(i.obj.id) + g.obj.instances.add(i.obj) + i.groups.append(g.obj.id) + handle_ha_toplogy_changes.apply_async() @shared_task(queue='tower_broadcast_all', bind=True, base=LogErrorsTask) @@ -190,12 +204,14 @@ def handle_setting_changes(self, setting_keys): def handle_ha_toplogy_changes(self): instance = Instance.objects.me() logger.debug("Reconfigure celeryd queues task on host {}".format(self.request.hostname)) - (instance, removed_queues, added_queues) = register_celery_worker_queues(self.app, self.request.hostname) + awx_app = Celery('awx') + awx_app.config_from_object('django.conf:settings', namespace='CELERY') + (instance, removed_queues, added_queues) = register_celery_worker_queues(awx_app, self.request.hostname) logger.info("Workers on tower node '{}' removed from queues {} and added to queues {}" .format(instance.hostname, removed_queues, added_queues)) updated_routes = update_celery_worker_routes(instance, settings) logger.info("Worker on tower node '{}' updated celery routes {} all routes are now {}" - .format(instance.hostname, updated_routes, self.app.conf.CELERY_ROUTES)) + .format(instance.hostname, updated_routes, self.app.conf.CELERY_TASK_ROUTES)) @worker_ready.connect @@ -213,7 +229,7 @@ def handle_update_celery_routes(sender=None, conf=None, **kwargs): instance = Instance.objects.me() added_routes = update_celery_worker_routes(instance, conf) logger.info("Workers on tower node '{}' added routes {} all routes are now {}" - .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) + .format(instance.hostname, added_routes, conf.CELERY_TASK_ROUTES)) @celeryd_after_setup.connect diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index eee545336d..2f3ec0656f 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -35,8 +35,9 @@ def mk_instance(persisted=True, hostname='instance.example.org'): return Instance.objects.get_or_create(uuid=settings.SYSTEM_UUID, hostname=hostname)[0] -def mk_instance_group(name='tower', instance=None): - ig, status = InstanceGroup.objects.get_or_create(name=name) +def mk_instance_group(name='tower', instance=None, minimum=0, percentage=0): + ig, status = InstanceGroup.objects.get_or_create(name=name, policy_instance_minimum=minimum, + policy_instance_percentage=percentage) if instance is not None: if type(instance) == list: for i in instance: diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py index a8f20f941f..ecb395dd99 100644 --- a/awx/main/tests/factories/tower.py +++ b/awx/main/tests/factories/tower.py @@ -135,8 +135,8 @@ def create_instance(name, instance_groups=None): return mk_instance(hostname=name) -def create_instance_group(name, instances=None): - return mk_instance_group(name=name, instance=instances) +def create_instance_group(name, instances=None, minimum=0, percentage=0): + return mk_instance_group(name=name, instance=instances, minimum=minimum, percentage=percentage) def create_survey_spec(variables=None, default_type='integer', required=True, min=None, max=None): diff --git a/awx/main/tests/functional/task_management/test_rampart_groups.py b/awx/main/tests/functional/task_management/test_rampart_groups.py index f4c6ba95bf..9b4b3eac44 100644 --- a/awx/main/tests/functional/task_management/test_rampart_groups.py +++ b/awx/main/tests/functional/task_management/test_rampart_groups.py @@ -2,6 +2,8 @@ import pytest import mock from datetime import timedelta from awx.main.scheduler import TaskManager +from awx.main.models import InstanceGroup +from awx.main.tasks import apply_cluster_membership_policies @pytest.mark.django_db @@ -151,3 +153,34 @@ def test_failover_group_run(instance_factory, default_instance_group, mocker, tm.schedule() mock_job.assert_has_calls([mock.call(j1, ig1, []), mock.call(j1_1, ig2, [])]) assert mock_job.call_count == 2 + + +@pytest.mark.django_db +def test_instance_group_basic_policies(instance_factory, instance_group_factory): + i0 = instance_factory("i0") + i1 = instance_factory("i1") + i2 = instance_factory("i2") + i3 = instance_factory("i3") + i4 = instance_factory("i4") + ig0 = instance_group_factory("ig0") + ig1 = instance_group_factory("ig1", minimum=2) + ig2 = instance_group_factory("ig2", percentage=50) + ig3 = instance_group_factory("ig3", percentage=50) + ig0.policy_instance_list.append(i0.hostname) + ig0.save() + apply_cluster_membership_policies() + ig0 = InstanceGroup.objects.get(id=ig0.id) + ig1 = InstanceGroup.objects.get(id=ig1.id) + ig2 = InstanceGroup.objects.get(id=ig2.id) + ig3 = InstanceGroup.objects.get(id=ig3.id) + assert len(ig0.instances.all()) == 1 + assert i0 in ig0.instances.all() + assert len(InstanceGroup.objects.get(id=ig1.id).instances.all()) == 2 + assert i1 in ig1.instances.all() + assert i2 in ig1.instances.all() + assert len(InstanceGroup.objects.get(id=ig2.id).instances.all()) == 2 + assert i3 in ig2.instances.all() + assert i4 in ig2.instances.all() + assert len(InstanceGroup.objects.get(id=ig3.id).instances.all()) == 2 + assert i1 in ig3.instances.all() + assert i2 in ig3.instances.all() diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index 6bd1b856b9..b71ca454e1 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -17,7 +17,7 @@ from awx.main.utils.ha import ( @pytest.fixture def conf(): class Conf(): - CELERY_ROUTES = dict() + CELERY_TASK_ROUTES = dict() CELERYBEAT_SCHEDULE = dict() return Conf() @@ -87,14 +87,14 @@ class TestUpdateCeleryWorkerRoutes(): instance.is_controller = mocker.MagicMock(return_value=is_controller) assert update_celery_worker_routes(instance, conf) == expected_routes - assert conf.CELERY_ROUTES == expected_routes + assert conf.CELERY_TASK_ROUTES == expected_routes def test_update_celery_worker_routes_deleted(self, mocker, conf): instance = mocker.MagicMock() instance.hostname = 'east-1' instance.is_controller = mocker.MagicMock(return_value=False) - conf.CELERY_ROUTES = {'awx.main.tasks.awx_isolated_heartbeat': 'foobar'} + conf.CELERY_TASK_ROUTES = {'awx.main.tasks.awx_isolated_heartbeat': 'foobar'} update_celery_worker_routes(instance, conf) - assert 'awx.main.tasks.awx_isolated_heartbeat' not in conf.CELERY_ROUTES + assert 'awx.main.tasks.awx_isolated_heartbeat' not in conf.CELERY_TASK_ROUTES diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index 9efb3e9cf3..376faf9eb9 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -14,6 +14,7 @@ def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): removed_queues = [] added_queues = [] ig_names = set(instance.rampart_groups.values_list('name', flat=True)) + ig_names.add("tower_instance_router") worker_queue_names = set([q['name'] for q in worker_queues]) @@ -47,12 +48,12 @@ def update_celery_worker_routes(instance, conf): if instance.is_controller(): tasks.append('awx.main.tasks.awx_isolated_heartbeat') else: - if 'awx.main.tasks.awx_isolated_heartbeat' in conf.CELERY_ROUTES: - del conf.CELERY_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] + if 'awx.main.tasks.awx_isolated_heartbeat' in conf.CELERY_TASK_ROUTES: + del conf.CELERY_TASK_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] for t in tasks: - conf.CELERY_ROUTES[t] = {'queue': instance.hostname, 'routing_key': instance.hostname} - routes_updated[t] = conf.CELERY_ROUTES[t] + conf.CELERY_TASK_ROUTES[t] = {'queue': instance.hostname, 'routing_key': instance.hostname} + routes_updated[t] = conf.CELERY_TASK_ROUTES[t] return routes_updated diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index db3dec23fb..5fb4edfe69 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -432,6 +432,7 @@ DEVSERVER_DEFAULT_PORT = '8013' # Set default ports for live server tests. os.environ.setdefault('DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:9013-9199') +BROKER_POOL_LIMIT = None CELERY_BROKER_URL = 'amqp://guest:guest@localhost:5672//' CELERY_EVENT_QUEUE_TTL = 5 CELERY_TASK_DEFAULT_QUEUE = 'tower' @@ -452,7 +453,7 @@ CELERY_TASK_QUEUES = ( ) CELERY_TASK_ROUTES = {} -CELERYBEAT_SCHEDULE = { +CELERY_BEAT_SCHEDULE = { 'tower_scheduler': { 'task': 'awx.main.tasks.awx_periodic_scheduler', 'schedule': timedelta(seconds=30), @@ -1123,9 +1124,11 @@ LOGGING = { }, 'awx.main.tasks': { 'handlers': ['task_system'], + 'propagate': False }, 'awx.main.scheduler': { 'handlers': ['task_system'], + 'propagate': False }, 'awx.main.consumers': { 'handlers': ['null'] diff --git a/installer/image_build/files/launch_awx_task.sh b/installer/image_build/files/launch_awx_task.sh index 88b59d63fa..ebcc8b6798 100755 --- a/installer/image_build/files/launch_awx_task.sh +++ b/installer/image_build/files/launch_awx_task.sh @@ -19,5 +19,5 @@ else awx-manage create_preload_data fi awx-manage provision_instance --hostname=$(hostname) -awx-manage register_queue --queuename=tower --hostnames=$(hostname) +awx-manage register_queue --queuename=tower --instance_percent=100 supervisord -c /supervisor_task.conf diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 1a4e613925..83107bf6e7 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,7 +3,7 @@ nodaemon = True umask = 022 [program:celery] -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -B -l debug --autoscale=4 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@$(ENV_HOSTNAME)s +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -B -l debug --autoscale=4 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} From 6e9930a45f32eff2c28104d31d63d5fa832157d6 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 29 Nov 2017 15:14:56 -0500 Subject: [PATCH 08/17] Use on_commit hook for triggering ig policy * also Apply console handlers to loggers for dev environment --- awx/main/models/ha.py | 10 ++++----- awx/settings/local_settings.py.docker_compose | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 12e8bf23e9..c7a10b5e00 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -1,7 +1,7 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -from django.db import models +from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ @@ -140,26 +140,26 @@ class JobOrigin(models.Model): def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs): if created: from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_save, sender=Instance) def on_instance_saved(sender, instance, created=False, raw=False, **kwargs): if created: from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_delete, sender=InstanceGroup) def on_instance_group_deleted(sender, instance, using, **kwargs): from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_delete, sender=Instance) def on_instance_deleted(sender, instance, using, **kwargs): from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) # Unfortunately, the signal can't just be connected against UnifiedJob; it diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index ff0048e102..84592c21bd 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -198,6 +198,27 @@ LOGGING['handlers']['syslog'] = { 'formatter': 'simple', } +LOGGING['loggers']['django.request']['handlers'] = ['console'] +LOGGING['loggers']['rest_framework.request']['handlers'] = ['console'] +LOGGING['loggers']['awx']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.commands.run_callback_receiver']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.commands.inventory_import']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.tasks']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.scheduler']['handlers'] = ['console'] +LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console'] +LOGGING['loggers']['social']['handlers'] = ['console'] +LOGGING['loggers']['system_tracking_migrations']['handlers'] = ['console'] +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'} +LOGGING['handlers']['system_tracking_migrations'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'} + + # Enable the following lines to also log to a file. #LOGGING['handlers']['file'] = { # 'class': 'logging.FileHandler', From 6a85fc38dd861633bc795e14155271f73b816b40 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 11 Jan 2018 12:16:14 -0500 Subject: [PATCH 09/17] Add scalable cluster kubernetes support --- installer/kubernetes/tasks/main.yml | 9 ++++ .../kubernetes/templates/configmap.yml.j2 | 2 + .../kubernetes/templates/deployment.yml.j2 | 30 +++++++++++-- installer/kubernetes/templates/etcd.yml.j2 | 44 +++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 installer/kubernetes/templates/etcd.yml.j2 diff --git a/installer/kubernetes/tasks/main.yml b/installer/kubernetes/tasks/main.yml index bd9d63677d..53916f56ba 100644 --- a/installer/kubernetes/tasks/main.yml +++ b/installer/kubernetes/tasks/main.yml @@ -96,6 +96,12 @@ path: "{{ kubernetes_base_path }}" state: directory +- name: Template Kubernetes AWX etcd2 + template: + src: etcd.yml.j2 + dest: "{{ kubernetes_base_path }}/etcd.yml" + mode: '0600' + - name: Template Kubernetes AWX Config template: src: configmap.yml.j2 @@ -108,6 +114,9 @@ dest: "{{ kubernetes_base_path }}/deployment.yml" mode: '0600' +- name: Apply etcd deployment + shell: "kubectl apply -f {{ kubernetes_base_path }}/etcd.yml" + - name: Apply Configmap shell: "kubectl apply -f {{ kubernetes_base_path }}/configmap.yml" diff --git a/installer/kubernetes/templates/configmap.yml.j2 b/installer/kubernetes/templates/configmap.yml.j2 index 9aafb888fd..fa61fcda83 100644 --- a/installer/kubernetes/templates/configmap.yml.j2 +++ b/installer/kubernetes/templates/configmap.yml.j2 @@ -13,6 +13,8 @@ data: # Container environments don't like chroots AWX_PROOT_ENABLED = False + AWX_AUTO_DEPROVISION_INSTANCES = True + #Autoprovisioning should replace this CLUSTER_HOST_ID = socket.gethostname() SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' diff --git a/installer/kubernetes/templates/deployment.yml.j2 b/installer/kubernetes/templates/deployment.yml.j2 index 8b7b0580f8..318723181b 100644 --- a/installer/kubernetes/templates/deployment.yml.j2 +++ b/installer/kubernetes/templates/deployment.yml.j2 @@ -41,18 +41,42 @@ spec: - name: AWX_ADMIN_PASSWORD value: {{ default_admin_password|default('password') }} - name: awx-rabbit - image: rabbitmq:3 + image: ansible/awx_rabbitmq:latest + imagePullPolicy: Always env: + # For consupmption by rabbitmq-env.conf + - name: MY_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: RABBITMQ_USE_LONGNAME + value: "true" + - name: ERLANG_COOKIE + value: "test" - name: RABBITMQ_ERLANG_COOKIE - value: secretb + value: "secretb" - name: RABBITMQ_NODENAME - value: rabbitmq + value: "rabbit@$(MY_POD_IP)" + - name: AUTOCLUSTER_TYPE + value: "etcd" + - name: AUTOCLUSTER_DELAY + value: "60" + - name: ETCD_HOST + value: "etcd" + - name: AUTOCLUSTER_CLEANUP + value: "true" + - name: CLEANUP_WARN_ONLY + value: "false" + - name: CLEANUP_INTERVAL + value: "30" - name: RABBITMQ_DEFAULT_USER value: awx - name: RABBITMQ_DEFAULT_PASS value: abcdefg - name: RABBITMQ_DEFAULT_VHOST value: awx + - name: RABBITMQ_CONFIG_FILE + value: /etc/rabbitmq/rabbitmq - name: awx-memcached image: memcached volumes: diff --git a/installer/kubernetes/templates/etcd.yml.j2 b/installer/kubernetes/templates/etcd.yml.j2 new file mode 100644 index 0000000000..8e8977d6c6 --- /dev/null +++ b/installer/kubernetes/templates/etcd.yml.j2 @@ -0,0 +1,44 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: etcd + namespace: {{ awx_kubernetes_project }} +spec: + replicas: 1 + template: + metadata: + labels: + name: awx-etcd2 + service: etcd + spec: + containers: + - name: etcd + image: elcolio/etcd:latest + ports: + - containerPort: 4001 + volumeMounts: + - mountPath: /data + name: datadir + volumes: + - name: datadir + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + labels: + name: awx-etcd + name: etcd + namespace: {{ awx_kubernetes_project }} +spec: + ports: + - name: etcd + port: 4001 + protocol: TCP + targetPort: 4001 + selector: + name: awx-etcd2 + sessionAffinity: None + type: ClusterIP From 70bf78e29fd8b3eb0bc581e9c6ae58ac923f63a9 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 11 Jan 2018 13:33:35 -0500 Subject: [PATCH 10/17] Apply capacity algorithm changes * This also adds fields to the instance view for tracking cpu and memory usage as well as information on what the capacity ranges are * Also adds a flag for enabling/disabling instances which removes them from all queues and has them stop processing new work * The capacity is now based almost exclusively on some value relative to forks * capacity_adjustment allows you to commit an instance to a certain amount of forks, cpu focused or memory focused * Each job run adds a single fork overhead (that's the reasoning behind the +1) --- awx/api/serializers.py | 6 +- awx/api/views.py | 18 +++++- awx/main/managers.py | 3 - .../0018_v330_instancegroup_policies.py | 31 ---------- .../0020_v330_instancegroup_policies.py | 62 +++++++++++++++++++ awx/main/models/ad_hoc_commands.py | 2 +- awx/main/models/ha.py | 42 +++++++++++++ awx/main/models/inventory.py | 2 +- awx/main/models/jobs.py | 4 +- awx/main/models/projects.py | 2 +- awx/main/tasks.py | 21 ++++--- awx/main/tests/functional/test_jobs.py | 26 +++++--- awx/main/tests/unit/utils/test_ha.py | 1 + awx/main/utils/common.py | 55 +++++++++++++--- awx/main/utils/ha.py | 3 +- awx/settings/defaults.py | 3 - docs/clustering.md | 43 +++++++++++++ 17 files changed, 248 insertions(+), 76 deletions(-) delete mode 100644 awx/main/migrations/0018_v330_instancegroup_policies.py create mode 100644 awx/main/migrations/0020_v330_instancegroup_policies.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f134d393e6..003a80e317 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3977,8 +3977,10 @@ class InstanceSerializer(BaseSerializer): class Meta: model = Instance - fields = ("id", "type", "url", "related", "uuid", "hostname", "created", "modified", - "version", "capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running") + read_only_fields = ('uuid', 'hostname', 'version') + fields = ("id", "type", "url", "related", "uuid", "hostname", "created", "modified", 'capacity_adjustment', + "version", "capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running", + "cpu", "memory", "cpu_capacity", "mem_capacity", "enabled") def get_related(self, obj): res = super(InstanceSerializer, self).get_related(obj) diff --git a/awx/api/views.py b/awx/api/views.py index 56bee58c21..4a87204248 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -57,7 +57,7 @@ import pytz from wsgiref.util import FileWrapper # AWX -from awx.main.tasks import send_notifications +from awx.main.tasks import send_notifications, handle_ha_toplogy_changes from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TokenGetAuthentication @@ -560,7 +560,7 @@ class InstanceList(ListAPIView): new_in_320 = True -class InstanceDetail(RetrieveAPIView): +class InstanceDetail(RetrieveUpdateAPIView): view_name = _("Instance Detail") model = Instance @@ -568,6 +568,20 @@ class InstanceDetail(RetrieveAPIView): new_in_320 = True + def update(self, request, *args, **kwargs): + r = super(InstanceDetail, self).update(request, *args, **kwargs) + if status.is_success(r.status_code): + obj = self.get_object() + if obj.enabled: + obj.refresh_capacity() + else: + obj.capacity = 0 + obj.save() + handle_ha_toplogy_changes.apply_async() + r.data = InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj) + return r + + class InstanceUnifiedJobsList(SubListAPIView): view_name = _("Instance Running Jobs") diff --git a/awx/main/managers.py b/awx/main/managers.py index f6f2dfd5b7..70c402f672 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -2,12 +2,9 @@ # All Rights Reserved. import sys -from datetime import timedelta import logging from django.db import models -from django.utils.timezone import now -from django.db.models import Sum from django.conf import settings from awx.main.utils.filters import SmartFilter diff --git a/awx/main/migrations/0018_v330_instancegroup_policies.py b/awx/main/migrations/0018_v330_instancegroup_policies.py deleted file mode 100644 index 63403f6766..0000000000 --- a/awx/main/migrations/0018_v330_instancegroup_policies.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import awx.main.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0017_v330_move_deprecated_stdout'), - ] - - operations = [ - migrations.AddField( - model_name='instancegroup', - name='policy_instance_list', - field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', - blank=True), - ), - migrations.AddField( - model_name='instancegroup', - name='policy_instance_minimum', - field=models.IntegerField(default=0, help_text='Static minimum number of Instances to automatically assign to this group'), - ), - migrations.AddField( - model_name='instancegroup', - name='policy_instance_percentage', - field=models.IntegerField(default=0, help_text='Percentage of Instances to automatically assign to this group'), - ), - ] diff --git a/awx/main/migrations/0020_v330_instancegroup_policies.py b/awx/main/migrations/0020_v330_instancegroup_policies.py new file mode 100644 index 0000000000..a6716352e9 --- /dev/null +++ b/awx/main/migrations/0020_v330_instancegroup_policies.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from decimal import Decimal +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0019_v330_custom_virtualenv'), + ] + + operations = [ + migrations.AddField( + model_name='instancegroup', + name='policy_instance_list', + field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', + blank=True), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_minimum', + field=models.IntegerField(default=0, help_text='Static minimum number of Instances to automatically assign to this group'), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_percentage', + field=models.IntegerField(default=0, help_text='Percentage of Instances to automatically assign to this group'), + ), + migrations.AddField( + model_name='instance', + name='capacity_adjustment', + field=models.DecimalField(decimal_places=2, default=Decimal('1.0'), max_digits=3), + ), + migrations.AddField( + model_name='instance', + name='cpu', + field=models.IntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='memory', + field=models.BigIntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='cpu_capacity', + field=models.IntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='mem_capacity', + field=models.IntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='enabled', + field=models.BooleanField(default=True) + ) + ] diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 56137378d6..3913a4ace7 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -184,7 +184,7 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin): # NOTE: We sorta have to assume the host count matches and that forks default to 5 from awx.main.models.inventory import Host count_hosts = Host.objects.filter( enabled=True, inventory__ad_hoc_commands__pk=self.pk).count() - return min(count_hosts, 5 if self.forks == 0 else self.forks) * 10 + return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1 def copy(self): data = {} diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index c7a10b5e00..bf1d7f8266 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -1,6 +1,8 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +from decimal import Decimal + from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -10,6 +12,7 @@ from django.utils.timezone import now, timedelta from solo.models import SingletonModel +from awx import __version__ as awx_application_version from awx.api.versioning import reverse from awx.main.managers import InstanceManager, InstanceGroupManager from awx.main.fields import JSONField @@ -17,6 +20,7 @@ from awx.main.models.inventory import InventoryUpdate from awx.main.models.jobs import Job from awx.main.models.projects import ProjectUpdate from awx.main.models.unified_jobs import UnifiedJob +from awx.main.utils import get_cpu_capacity, get_mem_capacity, get_system_task_capacity __all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',) @@ -39,6 +43,30 @@ class Instance(models.Model): default=100, editable=False, ) + capacity_adjustment = models.DecimalField( + default=Decimal(1.0), + max_digits=3, + decimal_places=2, + ) + enabled = models.BooleanField( + default=True + ) + cpu = models.IntegerField( + default=0, + editable=False, + ) + memory = models.BigIntegerField( + default=0, + editable=False, + ) + cpu_capacity = models.IntegerField( + default=0, + editable=False, + ) + mem_capacity = models.IntegerField( + default=0, + editable=False, + ) class Meta: app_label = 'main' @@ -68,6 +96,20 @@ class Instance(models.Model): return Instance.objects.filter(rampart_groups__controller__instances=self).exists() + def refresh_capacity(self): + cpu = get_cpu_capacity() + mem = get_mem_capacity() + self.capacity = get_system_task_capacity(self.capacity_adjustment) + self.cpu = cpu[0] + self.memory = mem[0] + self.cpu_capacity = cpu[1] + self.mem_capacity = mem[1] + self.version = awx_application_version + self.save(update_fields=['capacity', 'version', 'modified', 'cpu', + 'memory', 'cpu_capacity', 'mem_capacity']) + + + class InstanceGroup(models.Model): """A model representing a Queue/Group of AWX Instances.""" objects = InstanceGroupManager() diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index fc437e236e..832e66ea04 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1602,7 +1602,7 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, @property def task_impact(self): - return 50 + return 1 # InventoryUpdate credential required # Custom and SCM InventoryUpdate credential not required diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 4d3213b12c..a50c222b70 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -623,7 +623,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana count_hosts = 1 else: count_hosts = Host.objects.filter(inventory__jobs__pk=self.pk).count() - return min(count_hosts, 5 if self.forks == 0 else self.forks) * 10 + return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1 @property def successful_hosts(self): @@ -1190,7 +1190,7 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin): @property def task_impact(self): - return 150 + return 5 @property def preferred_instance_groups(self): diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 5794d170d7..0ee6aac241 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -492,7 +492,7 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage @property def task_impact(self): - return 0 if self.job_type == 'run' else 20 + return 0 if self.job_type == 'run' else 1 @property def result_stdout(self): diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 698fda006e..d41f21b087 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2,7 +2,6 @@ # All Rights Reserved. # Python -import codecs from collections import OrderedDict, namedtuple import ConfigParser import cStringIO @@ -54,9 +53,8 @@ from awx.main.queue import CallbackQueueDispatcher from awx.main.expect import run, isolated_manager from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, check_proot_installed, build_proot_temp_dir, get_licenser, - wrap_args_with_proot, get_system_task_capacity, OutputEventFilter, - ignore_inventory_computed_fields, ignore_inventory_group_removal, - get_type_for_model, extract_ansible_vars) + wrap_args_with_proot, OutputEventFilter, ignore_inventory_computed_fields, + ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars) from awx.main.utils.reload import restart_local_services, stop_local_services from awx.main.utils.pglock import advisory_lock from awx.main.utils.ha import update_celery_worker_routes, register_celery_worker_queues @@ -307,6 +305,7 @@ def cluster_node_heartbeat(self): instance_list = list(Instance.objects.filter(rampart_groups__controller__isnull=True).distinct()) this_inst = None lost_instances = [] + for inst in list(instance_list): if inst.hostname == settings.CLUSTER_HOST_ID: this_inst = inst @@ -316,11 +315,15 @@ def cluster_node_heartbeat(self): instance_list.remove(inst) if this_inst: startup_event = this_inst.is_lost(ref_time=nowtime) - if this_inst.capacity == 0: + if this_inst.capacity == 0 and this_inst.enabled: logger.warning('Rejoining the cluster as instance {}.'.format(this_inst.hostname)) - this_inst.capacity = get_system_task_capacity() - this_inst.version = awx_application_version - this_inst.save(update_fields=['capacity', 'version', 'modified']) + if this_inst.enabled: + this_inst.refresh_capacity() + handle_ha_toplogy_changes.apply_async() + elif this_inst.capacity != 0 and not this_inst.enabled: + this_inst.capacity = 0 + this_inst.save(update_fields=['capacity']) + handle_ha_toplogy_changes.apply_async() if startup_event: return else: @@ -329,7 +332,7 @@ def cluster_node_heartbeat(self): for other_inst in instance_list: if other_inst.version == "": continue - if Version(other_inst.version.split('-', 1)[0]) > Version(awx_application_version) and not settings.DEBUG: + if Version(other_inst.version.split('-', 1)[0]) > Version(awx_application_version.split('-', 1)[0]) and not settings.DEBUG: logger.error("Host {} reports version {}, but this node {} is at {}, shutting down".format(other_inst.hostname, other_inst.version, this_inst.hostname, diff --git a/awx/main/tests/functional/test_jobs.py b/awx/main/tests/functional/test_jobs.py index e9504d1232..aa95574b36 100644 --- a/awx/main/tests/functional/test_jobs.py +++ b/awx/main/tests/functional/test_jobs.py @@ -1,9 +1,11 @@ -from awx.main.models import Job, Instance -from django.test.utils import override_settings import pytest - +import mock import json +from awx.main.models import Job, Instance +from awx.main.tasks import cluster_node_heartbeat +from django.test.utils import override_settings + @pytest.mark.django_db def test_orphan_unified_job_creation(instance, inventory): @@ -17,13 +19,19 @@ def test_orphan_unified_job_creation(instance, inventory): @pytest.mark.django_db +@mock.patch('awx.main.utils.common.get_cpu_capacity', lambda: (2,8)) +@mock.patch('awx.main.utils.common.get_mem_capacity', lambda: (8000,62)) +@mock.patch('awx.main.tasks.handle_ha_toplogy_changes.apply_async', lambda: True) def test_job_capacity_and_with_inactive_node(): - Instance.objects.create(hostname='test-1', capacity=50) - assert Instance.objects.total_capacity() == 50 - Instance.objects.create(hostname='test-2', capacity=50) - assert Instance.objects.total_capacity() == 100 - with override_settings(AWX_ACTIVE_NODE_TIME=0): - assert Instance.objects.total_capacity() < 100 + i = Instance.objects.create(hostname='test-1') + i.refresh_capacity() + assert i.capacity == 62 + i.enabled = False + i.save() + with override_settings(CLUSTER_HOST_ID=i.hostname): + cluster_node_heartbeat() + i = Instance.objects.get(id=i.id) + assert i.capacity == 0 @pytest.mark.django_db diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index b71ca454e1..3dd9adfc35 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -60,6 +60,7 @@ class TestAddRemoveCeleryWorkerQueues(): static_queues, _worker_queues, groups, hostname, added_expected, removed_expected): + added_expected.append('tower_instance_router') instance = instance_generator(groups=groups, hostname=hostname) worker_queues = worker_queues_generator(_worker_queues) with mock.patch('awx.main.utils.ha.settings.AWX_CELERY_QUEUES_STATIC', static_queues): diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 443da1f1f3..f2ea63c4ed 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -20,6 +20,8 @@ import six import psutil from StringIO import StringIO +from decimal import Decimal + # Decorator from decorator import decorator @@ -45,7 +47,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'NoDefaultProvided', 'get_current_apps', 'set_current_apps', 'OutputEventFilter', - 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', + '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', 'model_instance_diff', 'timestamp_apiformat', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices'] @@ -632,19 +634,52 @@ def parse_yaml_or_json(vars_str, silent_failure=True): return vars_dict -@memoize() -def get_system_task_capacity(): +def get_cpu_capacity(): + from django.conf import settings + settings_forkcpu = getattr(settings, 'SYSTEM_TASK_FORKS_CPU', None) + env_forkcpu = os.getenv('SYSTEM_TASK_FORKS_CPU', None) + cpu = psutil.cpu_count() + + if env_forkcpu: + forkcpu = int(env_forkcpu) + elif settings_forkcpu: + forkcpu = int(settings_forkcpu) + else: + forkcpu = 4 + return (cpu, cpu * forkcpu) + + +def get_mem_capacity(): + from django.conf import settings + settings_forkmem = getattr(settings, 'SYSTEM_TASK_FORKS_MEM', None) + env_forkmem = os.getenv('SYSTEM_TASK_FORKS_MEM', None) + if env_forkmem: + forkmem = int(env_forkmem) + elif settings_forkmem: + forkmem = int(settings_forkmem) + else: + forkmem = 100 + + mem = psutil.virtual_memory().total + return (mem, max(1, ((mem / 1024 / 1024) - 2048) / forkmem)) + + +def get_system_task_capacity(scale=Decimal(1.0)): ''' Measure system memory and use it as a baseline for determining the system's capacity ''' from django.conf import settings - if hasattr(settings, 'SYSTEM_TASK_CAPACITY'): - return settings.SYSTEM_TASK_CAPACITY - mem = psutil.virtual_memory() - total_mem_value = mem.total / 1024 / 1024 - if total_mem_value <= 2048: - return 50 - return 50 + ((total_mem_value / 1024) - 2) * 75 + settings_forks = getattr(settings, 'SYSTEM_TASK_FORKS_CAPACITY', None) + env_forks = os.getenv('SYSTEM_TASK_FORKS_CAPACITY', None) + + if env_forks: + return int(env_forks) + elif settings_forks: + return int(settings_forks) + + _, cpu_cap = get_cpu_capacity() + _, mem_cap = get_mem_capacity() + return min(mem_cap, cpu_cap) + ((max(mem_cap, cpu_cap) - min(mem_cap, cpu_cap)) * scale) _inventory_updates = threading.local() diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index 376faf9eb9..bb3a0a73cc 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -24,7 +24,7 @@ def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): queue['alias'] in settings.AWX_CELERY_QUEUES_STATIC: continue - if queue['name'] not in ig_names | set([instance.hostname]): + if queue['name'] not in ig_names | set([instance.hostname]) or not instance.enabled: app.control.cancel_consumer(queue['name'], reply=True, destination=[worker_name]) removed_queues.append(queue['name']) @@ -43,7 +43,6 @@ def update_celery_worker_routes(instance, conf): 'awx.main.tasks.purge_old_stdout_files', ] routes_updated = {} - # Instance is, effectively, a controller node if instance.is_controller(): tasks.append('awx.main.tasks.awx_isolated_heartbeat') diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 5fb4edfe69..a1223c5ed1 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -639,9 +639,6 @@ AWX_PROOT_BASE_PATH = "/tmp" # Note: This setting may be overridden by database settings. AWX_ANSIBLE_CALLBACK_PLUGINS = "" -# Time at which an HA node is considered active -AWX_ACTIVE_NODE_TIME = 7200 - # Automatically remove nodes that have missed their heartbeats after some time AWX_AUTO_DEPROVISION_INSTANCES = False diff --git a/docs/clustering.md b/docs/clustering.md index df52cbadd8..9ab61bba56 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -28,6 +28,8 @@ It's important to point out a few existing things: * Existing old-style HA deployments will be transitioned automatically to the new HA system during the upgrade process to 3.1. * Manual projects will need to be synced to all instances by the customer +Ansible Tower 3.3 adds support for container-based clusters using Openshift or Kubernetes + ## Important Changes * There is no concept of primary/secondary in the new Tower system. *All* systems are primary. @@ -226,6 +228,47 @@ show up in api endpoints and stats monitoring. These groups can be removed with $ awx-manage unregister_queue --queuename= ``` +### Configuring Instances and Instance Groups from the API + +Instance Groups can be created by posting to `/api/v2/instance_groups` as a System Admin. + +Once created, `Instances` can be associated with an Instance Group with: + +``` +HTTP POST /api/v2/instance_groups/x/instances/ {'id': y}` +``` + +An `Instance` that is added to an `InstanceGroup` will automatically reconfigure itself to listen on the group's work queue. See the following +section `Instance Group Policies` for more details. + +### Instance Group Policies + +Tower `Instances` can be configured to automatically join `Instance Groups` when they come online by defining a policy. These policies are evaluated for +every new Instance that comes online. + +Instance Group Policies are controlled by 3 optional fields on an `Instance Group`: + +* `policy_instance_percentage`: This is a number between 0 - 100. It gaurantees that this percentage of active Tower instances will be added + to this `Instance Group`. As new instances come online, if the number of Instances in this group relative to the total number of instances + is less than the given percentage then new ones will be added until the percentage condition is satisfied. +* `policy_instance_minimum`: This policy attempts to keep at least this many `Instances` in the `Instance Group`. If the number of + available instances is lower than this minimum then all `Instances` will be placed in this `Instance Group`. +* `policy_instance_list`: This is a fixed list of `Instance` names. These `Instances` will *always* be added to this `Instance Group`. + Further, by adding Instances to this list you are declaring that you will manually manage those Instances and they will not be eligible under any other + policy. This means they will not be automatically added to any other `Instance Group` even if the policy would cause them to be matched. + +> NOTES + +* `Instances` that are assigned directly to `Instance Groups` by posting to `/api/v2/instance_groups/x/instances` or + `/api/v2/instances/x/instance_groups` are automatically added to the `policy_instance_list`. This means they are subject to the + normal caveats for `policy_instance_list` and must be manually managed. +* `policy_instance_percentage` and `policy_instance_minimum` work together. For example, if you have a `policy_instance_percentage` of + 50% and a `policy_instance_minimum` of 2 and you start 6 `Instances`. 3 of them would be assigned to the `Instance Group`. If you reduce the number + of `Instances` to 2 then both of them would be assigned to the `Instance Group` to satisfy `policy_instance_minimum`. In this way, you can set a lower + bound on the amount of available resources. +* Policies don't actively prevent `Instances` from being associated with multiple `Instance Groups` but this can effectively be achieved by making the percentages + sum to 100. If you have 4 `Instance Groups` assign each a percentage value of 25 and the `Instances` will be distributed among them with no overlap. + ### Status and Monitoring Tower itself reports as much status as it can via the api at `/api/v2/ping` in order to provide validation of the health From 368101812cfa7c21d8611faf67f0a92ba3f92be1 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 10 Jan 2018 12:59:53 -0500 Subject: [PATCH 11/17] Add Instance and InstanceGroup models --- .../lib/components/components.strings.js | 4 +- awx/ui/client/lib/components/index.js | 2 + .../client/lib/components/input/_index.less | 38 +- .../lib/components/input/lookup.directive.js | 10 + .../lib/components/input/lookup.partial.html | 64 ++-- .../lib/components/input/slider.directive.js | 38 ++ .../lib/components/input/slider.partial.html | 13 + awx/ui/client/lib/components/list/_index.less | 38 ++ .../lib/components/list/row-item.directive.js | 3 + .../lib/components/list/row-item.partial.html | 12 +- .../lib/components/tabs/tab.partial.html | 1 + awx/ui/client/lib/models/Base.js | 8 +- awx/ui/client/lib/models/Instance.js | 47 +++ awx/ui/client/lib/models/InstanceGroup.js | 47 +++ awx/ui/client/lib/models/Job.js | 21 ++ awx/ui/client/lib/models/index.js | 4 + awx/ui/client/lib/theme/_global.less | 15 +- awx/ui/client/lib/theme/_mixins.less | 19 + awx/ui/client/lib/theme/_variables.less | 2 + awx/ui/client/lib/theme/index.less | 1 + awx/ui/client/src/app.js | 17 +- .../add-edit-instance-groups.view.html | 34 ++ .../add-edit/add-instance-group.controller.js | 44 +++ .../add-instance-list-policy.controller.js | 40 ++ .../add-instance-list-policy.partial.html | 53 +++ .../edit-instance-group.controller.js | 55 +++ .../capacity-bar/capacity-bar.block.less | 40 +- .../capacity-bar/capacity-bar.directive.js | 79 ++-- .../capacity-bar/capacity-bar.partial.html | 15 +- .../instance-groups.partial.html | 8 +- .../instance-groups/instance-groups.route.js | 41 -- .../instance-groups.strings.js | 25 ++ .../instance-jobs/instance-jobs-list.route.js | 41 -- .../instance-jobs/instance-jobs.controller.js | 151 ++++---- .../instance-jobs/instance-jobs.list.js | 78 ---- .../instance-jobs/instance-jobs.partial.html | 32 -- .../instance-jobs/instance-jobs.route.js | 38 -- .../instances/instance-modal.block.less | 24 ++ .../instances/instance-modal.controller.js | 74 ++++ .../instances/instance-modal.partial.html | 55 +++ .../instances/instances-list.partial.html | 104 ++--- .../instances/instances-list.route.js | 35 -- .../instances/instances.controller.js | 67 +++- .../instances/instances.list.js | 29 -- .../instances/instances.route.js | 35 -- .../instance-groups/jobs/jobs-list.route.js | 41 -- .../instance-groups/jobs/jobs.controller.js | 161 ++++---- .../src/instance-groups/jobs/jobs.list.js | 150 ++++---- .../src/instance-groups/jobs/jobs.strings.js | 30 ++ .../src/instance-groups/jobs/list.view.html | 103 +++++ .../list/instance-groups-list.controller.js | 36 +- .../list/instance-groups-list.partial.html | 133 ++++--- awx/ui/client/src/instance-groups/main.js | 355 ++++++++++++++++-- .../multi-select-preview.partial.html | 1 + 54 files changed, 1759 insertions(+), 852 deletions(-) create mode 100644 awx/ui/client/lib/components/input/slider.directive.js create mode 100644 awx/ui/client/lib/components/input/slider.partial.html create mode 100644 awx/ui/client/lib/models/Instance.js create mode 100644 awx/ui/client/lib/models/InstanceGroup.js create mode 100644 awx/ui/client/lib/models/Job.js create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html create mode 100644 awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js delete mode 100644 awx/ui/client/src/instance-groups/instance-groups.route.js create mode 100644 awx/ui/client/src/instance-groups/instance-groups.strings.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js create mode 100644 awx/ui/client/src/instance-groups/instances/instance-modal.block.less create mode 100644 awx/ui/client/src/instance-groups/instances/instance-modal.controller.js create mode 100644 awx/ui/client/src/instance-groups/instances/instance-modal.partial.html delete mode 100644 awx/ui/client/src/instance-groups/instances/instances-list.route.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instances.list.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instances.route.js delete mode 100644 awx/ui/client/src/instance-groups/jobs/jobs-list.route.js create mode 100644 awx/ui/client/src/instance-groups/jobs/jobs.strings.js create mode 100644 awx/ui/client/src/instance-groups/jobs/list.view.html diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 93f5ab1416..ddc765533b 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -71,6 +71,7 @@ function ComponentsStrings (BaseString) { INVENTORY_SCRIPTS: t.s('Inventory Scripts'), NOTIFICATIONS: t.s('Notifications'), MANAGEMENT_JOBS: t.s('Management Jobs'), + INSTANCES: t.s('Instances'), INSTANCE_GROUPS: t.s('Instance Groups'), SETTINGS: t.s('Settings'), FOOTER_ABOUT: t.s('About'), @@ -78,7 +79,8 @@ function ComponentsStrings (BaseString) { }; ns.capacityBar = { - IS_OFFLINE: t.s('Unavailable to run jobs.') + IS_OFFLINE: t.s('Unavailable to run jobs.'), + IS_OFFLINE_LABEL: t.s('Unavailable') }; ns.relaunch = { diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 6f15cae762..d33e79329a 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -12,6 +12,7 @@ import inputLookup from '~components/input/lookup.directive'; import inputMessage from '~components/input/message.directive'; import inputSecret from '~components/input/secret.directive'; import inputSelect from '~components/input/select.directive'; +import inputSlider from '~components/input/slider.directive'; import inputText from '~components/input/text.directive'; import inputTextarea from '~components/input/textarea.directive'; import inputTextareaSecret from '~components/input/textarea-secret.directive'; @@ -54,6 +55,7 @@ angular .directive('atInputMessage', inputMessage) .directive('atInputSecret', inputSecret) .directive('atInputSelect', inputSelect) + .directive('atInputSlider', inputSlider) .directive('atInputText', inputText) .directive('atInputTextarea', inputTextarea) .directive('atInputTextareaSecret', inputTextareaSecret) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index b92692c34e..03589273eb 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -163,7 +163,7 @@ } .at-InputMessage--rejected { - font-size: @at-font-size-help-text; + font-size: @at-font-size-help-text; color: @at-color-error; margin: @at-margin-input-message 0 0 0; padding: 0; @@ -182,7 +182,7 @@ & > i { font-size: @at-font-size-button; - position: absolute; + position: absolute; z-index: 3; pointer-events: none; top: @at-height-input / 3; @@ -218,3 +218,37 @@ min-height: @at-height-textarea; padding: 6px @at-padding-input 0 @at-padding-input; } + +.at-InputSlider { + display: flex; + padding: 5px 0; + + p { + color: @at-color-form-label; + font-size: @at-font-size-help-text; + font-weight: @at-font-weight-body; + margin: 0 0 0 10px; + padding: 0; + width: 50px; + } + + input[type=range] { + -webkit-appearance: none; + width: 100%; + background: transparent; + height: 20px; + border-right: 1px solid @at-color-input-slider-track; + border-left: 1px solid @at-color-input-slider-track; + + &:focus { + outline: none; + } + + &::-webkit-slider-runnable-track { + .at-mixin-sliderTrack(); + } + &::-webkit-slider-thumb { + .at-mixin-sliderThumb(); + } + } +} \ No newline at end of file diff --git a/awx/ui/client/lib/components/input/lookup.directive.js b/awx/ui/client/lib/components/input/lookup.directive.js index 1e6cc13588..fcee7ad72c 100644 --- a/awx/ui/client/lib/components/input/lookup.directive.js +++ b/awx/ui/client/lib/components/input/lookup.directive.js @@ -118,6 +118,16 @@ function AtInputLookupController (baseInputController, $q, $state) { vm.searchAfterDebounce(); }; + + vm.removeTag = (i) => { + let list; + if (!i.id) { + list = _.remove(scope.state._value, i); + } else { + list = _.remove(scope.state._value, i.id); + } + scope.state._value = list; + }; } AtInputLookupController.$inject = [ diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index 21ebf03b5d..271c24212f 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -1,27 +1,45 @@
-
- +
+ -
- - - - +
+ + + + + + + + + + +
+
+ +
+
+ {{ tag.hostname }} + {{ tag }} +
+
+
+
+ +
- -
- -
-
+
+
\ No newline at end of file diff --git a/awx/ui/client/lib/components/input/slider.directive.js b/awx/ui/client/lib/components/input/slider.directive.js new file mode 100644 index 0000000000..a2e1b8c28e --- /dev/null +++ b/awx/ui/client/lib/components/input/slider.directive.js @@ -0,0 +1,38 @@ +const templateUrl = require('~components/input/slider.partial.html'); + +function atInputSliderLink (scope, element, attrs, controllers) { + const [formController, inputController] = controllers; + + inputController.init(scope, element, formController); +} + +function atInputSliderController (baseInputController) { + const vm = this || {}; + + vm.init = (_scope_, _element_, form) => { + baseInputController.call(vm, 'input', _scope_, _element_, form); + + vm.check(); + }; +} + +atInputSliderController.$inject = ['BaseInputController']; + +function atInputSlider () { + return { + restrict: 'E', + require: ['^^atForm', 'atInputSlider'], + replace: true, + templateUrl, + controller: atInputSliderController, + controllerAs: 'vm', + link: atInputSliderLink, + scope: { + state: '=?', + col: '@', + tab: '@' + } + }; +} + +export default atInputSlider; diff --git a/awx/ui/client/lib/components/input/slider.partial.html b/awx/ui/client/lib/components/input/slider.partial.html new file mode 100644 index 0000000000..e4649149f8 --- /dev/null +++ b/awx/ui/client/lib/components/input/slider.partial.html @@ -0,0 +1,13 @@ +
+
+ +
+ +

{{ state._value }}%

+
+
+
diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index cd1fa9a023..77997bbbcc 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -86,12 +86,26 @@ border-top: @at-border-default-width solid @at-color-list-border; } +.at-Row--rowLayout { + display: flex; + flex-direction: row; + + .at-RowItem { + margin-right: @at-space-4x; + + &-label { + width: auto; + } + } +} + .at-Row-actions { display: flex; } .at-Row-items { align-self: flex-start; + flex: 1; } .at-RowItem { @@ -101,6 +115,7 @@ } .at-RowItem--isHeader { + color: @at-color-body-text; margin-bottom: @at-margin-bottom-list-header; line-height: @at-line-height-list-row-item-header; } @@ -146,8 +161,26 @@ .at-RowItem-label { text-transform: uppercase; + width: auto; width: @at-width-list-row-item-label; color: @at-color-list-row-item-label; + font-size: @at-font-size; +} + +.at-RowItem-value { + font-size: @at-font-size-3x; +} + +.at-RowItem-badge { + background-color: @at-gray-848992; + border-radius: @at-border-radius; + color: @at-white; + font-size: 11px; + font-weight: normal; + height: 14px; + line-height: 10px; + margin: 0 10px; + padding: 2px 10px; } .at-RowAction { @@ -180,6 +213,11 @@ background-color: @at-color-list-row-action-hover-danger; } +.at-Row .at-Row-checkbox { + align-self: start; + margin: 2px 20px 0 0; +} + @media screen and (max-width: @at-breakpoint-compact-list) { .at-Row-actions { flex-direction: column; diff --git a/awx/ui/client/lib/components/list/row-item.directive.js b/awx/ui/client/lib/components/list/row-item.directive.js index 972008f7a2..e07820468e 100644 --- a/awx/ui/client/lib/components/list/row-item.directive.js +++ b/awx/ui/client/lib/components/list/row-item.directive.js @@ -7,10 +7,13 @@ function atRowItem () { transclude: true, templateUrl, scope: { + badge: '@', headerValue: '@', headerLink: '@', headerTag: '@', labelValue: '@', + labelLink: '@', + labelState: '@', value: '@', valueLink: '@', smartStatus: '=?', diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index a9b81ae20c..ca58947b79 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -9,13 +9,19 @@
{{ headerTag }}
-
+ +
{{ labelValue }}
+ -
- + \ No newline at end of file diff --git a/awx/ui/client/lib/components/tabs/tab.partial.html b/awx/ui/client/lib/components/tabs/tab.partial.html index 263a5d1d96..747e470571 100644 --- a/awx/ui/client/lib/components/tabs/tab.partial.html +++ b/awx/ui/client/lib/components/tabs/tab.partial.html @@ -1,6 +1,7 @@ diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js index 8845c24f82..7fafb05c75 100644 --- a/awx/ui/client/lib/models/Base.js +++ b/awx/ui/client/lib/models/Base.js @@ -129,6 +129,10 @@ function httpPost (config = {}) { data: config.data }; + if (config.url) { + req.url = `${this.path}${config.url}`; + } + return $http(req) .then(res => { this.model.GET = res.data; @@ -323,7 +327,7 @@ function has (method, keys) { return value !== undefined && value !== null; } -function extend (method, related) { +function extend (method, related, config = {}) { if (!related) { related = method; method = 'GET'; @@ -337,6 +341,8 @@ function extend (method, related) { url: this.get(`related.${related}`) }; + Object.assign(req, config); + return $http(req) .then(({ data }) => { this.set(method, `related.${related}`, data); diff --git a/awx/ui/client/lib/models/Instance.js b/awx/ui/client/lib/models/Instance.js new file mode 100644 index 0000000000..09b7df0547 --- /dev/null +++ b/awx/ui/client/lib/models/Instance.js @@ -0,0 +1,47 @@ +let Base; + +function createFormSchema (method, config) { + if (!config) { + config = method; + method = 'GET'; + } + + const schema = Object.assign({}, this.options(`actions.${method.toUpperCase()}`)); + + if (config && config.omit) { + config.omit.forEach(key => delete schema[key]); + } + + Object.keys(schema).forEach(key => { + schema[key].id = key; + + if (this.has(key)) { + schema[key]._value = this.get(key); + } + }); + + return schema; +} + +function InstanceModel (method, resource, config) { + // Base takes two args: resource and settings + // resource is the string endpoint + Base.call(this, 'instances'); + + this.Constructor = InstanceModel; + this.createFormSchema = createFormSchema.bind(this); + + return this.create(method, resource, config); +} + +function InstanceModelLoader (BaseModel) { + Base = BaseModel; + + return InstanceModel; +} + +InstanceModelLoader.$inject = [ + 'BaseModel' +]; + +export default InstanceModelLoader; diff --git a/awx/ui/client/lib/models/InstanceGroup.js b/awx/ui/client/lib/models/InstanceGroup.js new file mode 100644 index 0000000000..cc82432c42 --- /dev/null +++ b/awx/ui/client/lib/models/InstanceGroup.js @@ -0,0 +1,47 @@ +let Base; + +function createFormSchema (method, config) { + if (!config) { + config = method; + method = 'GET'; + } + + const schema = Object.assign({}, this.options(`actions.${method.toUpperCase()}`)); + + if (config && config.omit) { + config.omit.forEach(key => delete schema[key]); + } + + Object.keys(schema).forEach(key => { + schema[key].id = key; + + if (this.has(key)) { + schema[key]._value = this.get(key); + } + }); + + return schema; +} + +function InstanceGroupModel (method, resource, config) { + // Base takes two args: resource and settings + // resource is the string endpoint + Base.call(this, 'instance_groups'); + + this.Constructor = InstanceGroupModel; + this.createFormSchema = createFormSchema.bind(this); + + return this.create(method, resource, config); +} + +function InstanceGroupModelLoader (BaseModel) { + Base = BaseModel; + + return InstanceGroupModel; +} + +InstanceGroupModelLoader.$inject = [ + 'BaseModel' +]; + +export default InstanceGroupModelLoader; diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js new file mode 100644 index 0000000000..9be420b2f9 --- /dev/null +++ b/awx/ui/client/lib/models/Job.js @@ -0,0 +1,21 @@ +let Base; + +function JobModel (method, resource, config) { + Base.call(this, 'jobs'); + + this.Constructor = JobModel; + + return this.create(method, resource, config); +} + +function JobModelLoader (BaseModel) { + Base = BaseModel; + + return JobModel; +} + +JobModelLoader.$inject = [ + 'BaseModel' +]; + +export default JobModelLoader; diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js index 6dbea4c954..3c43af7915 100644 --- a/awx/ui/client/lib/models/index.js +++ b/awx/ui/client/lib/models/index.js @@ -9,6 +9,8 @@ import Organization from '~models/Organization'; import Project from '~models/Project'; import JobTemplate from '~models/JobTemplate'; import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode'; +import Instance from '~models/Instance'; +import InstanceGroup from '~models/InstanceGroup'; import InventorySource from '~models/InventorySource'; import Inventory from '~models/Inventory'; import InventoryScript from '~models/InventoryScript'; @@ -32,6 +34,8 @@ angular .service('ProjectModel', Project) .service('JobTemplateModel', JobTemplate) .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode) + .service('InstanceModel', Instance) + .service('InstanceGroupModel', InstanceGroup) .service('InventorySourceModel', InventorySource) .service('InventoryModel', Inventory) .service('InventoryScriptModel', InventoryScript) diff --git a/awx/ui/client/lib/theme/_global.less b/awx/ui/client/lib/theme/_global.less index b1e46927ef..b62f501c33 100644 --- a/awx/ui/client/lib/theme/_global.less +++ b/awx/ui/client/lib/theme/_global.less @@ -15,7 +15,16 @@ background: @at-color-disabled; } } - + +.at-Button--add { + &:extend(.at-Button--success all); + &:before { + content: "+"; + font-size: 20px; + } + border-color: transparent; +} + .at-Button--info { .at-mixin-Button(); .at-mixin-ButtonColor('at-color-info', 'at-color-default'); @@ -26,7 +35,7 @@ .at-mixin-ButtonColor('at-color-error', 'at-color-default'); } -.at-ButtonHollow--default { +.at-ButtonHollow--default { .at-mixin-Button(); .at-mixin-ButtonHollow( 'at-color-default', @@ -41,5 +50,5 @@ } .at-Button--expand { - width: 100%; + width: 100%; } diff --git a/awx/ui/client/lib/theme/_mixins.less b/awx/ui/client/lib/theme/_mixins.less index 6dc36a7b24..d40373c836 100644 --- a/awx/ui/client/lib/theme/_mixins.less +++ b/awx/ui/client/lib/theme/_mixins.less @@ -21,6 +21,7 @@ } .at-mixin-Button () { + border-radius: @at-border-radius; height: @at-height-input; padding: @at-padding-button-vertical @at-padding-button-horizontal; font-size: @at-font-size-body; @@ -102,3 +103,21 @@ .at-mixin-FontFixedWidth () { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } + +.at-mixin-sliderTrack() { + background: @at-color-input-slider-track; + cursor: pointer; + height: 1px; + width: 100%; +} + +.at-mixin-sliderThumb() { + -webkit-appearance: none; + background: @at-color-input-slider-thumb; + border-radius: 50%; + border: none; + cursor: pointer; + height: 16px; + margin-top: -7px; + width: 16px; +} \ No newline at end of file diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less index be5cde41ee..cd3a8a6675 100644 --- a/awx/ui/client/lib/theme/_variables.less +++ b/awx/ui/client/lib/theme/_variables.less @@ -147,6 +147,8 @@ @at-color-input-icon: @at-gray-b7; @at-color-input-placeholder: @at-gray-848992; @at-color-input-text: @at-gray-161b1f; +@at-color-input-slider-thumb: @at-blue; +@at-color-input-slider-track: @at-gray-b7; @at-color-icon-dismiss: @at-gray-d7; @at-color-icon-popover: @at-gray-848992; diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index 1f0d3dd254..6b3f241ffd 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -73,6 +73,7 @@ @import '../../src/home/dashboard/dashboard.block.less'; @import '../../src/instance-groups/capacity-bar/capacity-bar.block.less'; @import '../../src/instance-groups/instance-group.block.less'; +@import '../../src/instance-groups/instances/instance-modal.block.less'; @import '../../src/inventories-hosts/inventories/insights/insights.block.less'; @import '../../src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.block.less'; @import '../../src/inventories-hosts/inventories/related/hosts/related-groups-labels/relatedGroupsLabelsList.block.less'; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index e1667e6fb8..069ee2dd80 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -97,7 +97,6 @@ angular users.name, projects.name, scheduler.name, - instanceGroups.name, 'Utilities', 'templates', @@ -105,6 +104,7 @@ angular 'AWDirectives', 'features', + instanceGroups, atFeatures, atLibComponents, atLibModels, @@ -316,6 +316,21 @@ angular activateTab(); }); + $transitions.onCreate({}, function(trans) { + console.log('$onCreate ' +trans.to().name); + }); + + $transitions.onBefore({}, function(trans) { + console.log('$onBefore ' +trans.to().name); + }); + $transitions.onError({}, function(trans) { + + console.log('$onError ' +trans.to().name); + }); + $transitions.onExit({}, function(trans) { + console.log('$onExit ' +trans.to().name); + }); + $transitions.onSuccess({}, function(trans) { if(trans.to() === trans.from()) { diff --git a/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html new file mode 100644 index 0000000000..a980d74ec8 --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html @@ -0,0 +1,34 @@ + + + {{ vm.panelTitle }} + + + + {{:: vm.strings.get('tab.DETAILS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
\ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js new file mode 100644 index 0000000000..9fc838115b --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js @@ -0,0 +1,44 @@ +function AddController ($scope, $state, models, strings) { + const vm = this || {}; + + const { instanceGroup, instance } = models; + + vm.mode = 'add'; + vm.strings = strings; + vm.panelTitle = "New Instance Group"; + + vm.tab = { + details: { _active: true }, + instances: {_disabled: true }, + jobs: {_disabled: true } + }; + + vm.form = instanceGroup.createFormSchema('post'); + + vm.form.policy_instance_percentage._value = 0; + + vm.form.policy_instance_list._lookupTags = true; + vm.form.policy_instance_list._model = instance; + vm.form.policy_instance_list._placeholder = "Policy Instance List"; + vm.form.policy_instance_list._resource = 'instances'; + vm.form.policy_instance_list._route = 'instanceGroups.add.modal.instances'; + vm.form.policy_instance_list._value = []; + + vm.form.save = data => { + data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname); + return instanceGroup.request('post', { data }); + }; + + vm.form.onSaveSuccess = res => { + $state.go('instanceGroups.edit', { instance_group_id: res.data.id }, { reload: true }); + }; +} + +AddController.$inject = [ + '$scope', + '$state', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default AddController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js new file mode 100644 index 0000000000..c7819ba797 --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js @@ -0,0 +1,40 @@ +function InstanceModalController ($scope, $state, $http, $q, models, strings) { + const { instance } = models; + const vm = this || {}; + + vm.setInstances = () => { + vm.instances = instance.get('results').map(instance => { + instance.isSelected = false; + return instance; + }); + } + + init(); + + function init() { + vm.strings = strings; + vm.panelTitle = strings.get('instance.PANEL_TITLE'); + vm.setInstances(); + }; + + $scope.$watch('vm.instances', function() { + vm.selectedRows = _.filter(vm.instances, 'isSelected') + vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); + }, true); + + vm.submit = () => { + $scope.$parent.$parent.$parent.state.policy_instance_list._value = vm.selectedRows; + $state.go("^.^"); + }; +} + +InstanceModalController.$inject = [ + '$scope', + '$state', + '$http', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default InstanceModalController; diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html new file mode 100644 index 0000000000..29493add34 --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html @@ -0,0 +1,53 @@ + \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js new file mode 100644 index 0000000000..49b197c6ec --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js @@ -0,0 +1,55 @@ +function EditController ($rootScope, $state, models, strings) { + const vm = this || {}; + + const { instanceGroup, instance } = models; + + $rootScope.breadcrumb.instance_group_name = instanceGroup.get('name'); + + vm.mode = 'edit'; + vm.strings = strings; + vm.panelTitle = instanceGroup.get('name'); + + vm.tab = { + details: { + _active: true, + _go: 'instanceGroups.edit', + _params: { instance_group_id: instanceGroup.get('id') } + }, + instances: { + _go: 'instanceGroups.instances', + _params: { instance_group_id: instanceGroup.get('id') } + }, + jobs: { + _go: 'instanceGroups.jobs', + _params: { instance_group_id: instanceGroup.get('id') } + } + }; + + vm.form = instanceGroup.createFormSchema('put'); + + vm.form.policy_instance_list._lookupTags = true; + vm.form.policy_instance_list._model = instance; + vm.form.policy_instance_list._placeholder = "Policy Instance List"; + vm.form.policy_instance_list._resource = 'instances'; + vm.form.policy_instance_list._route = 'instanceGroups.edit.modal.instances'; + vm.form.policy_instance_list._value = instanceGroup.get('policy_instance_list'); + + vm.form.save = data => { + instanceGroup.unset('policy_instance_list'); + data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname); + return instanceGroup.request('put', { data }); + }; + + vm.form.onSaveSuccess = res => { + $state.go('instanceGroups.edit', { instance_group_id: res.data.id }, { reload: true }); + }; +} + +EditController.$inject = [ + '$rootScope', + '$state', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default EditController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less index 1caba245f0..5668eef8ad 100644 --- a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less +++ b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less @@ -1,21 +1,22 @@ capacity-bar { - - width: 50%; - margin-right: 25px; - min-width: 100px; - display: flex; align-items: center; + color: @at-gray-70; + display: flex; + font-size: @at-font-size; + min-width: 100px; + white-space: nowrap; .CapacityBar { background-color: @default-bg; - display: flex; - flex: 0 0 auto; - height: 10px; - border: 1px solid @default-link; - width: 100%; border-radius: 100vw; + border: 1px solid @default-link; + display: flex; + flex: 1; + height: 10px; + margin-right: @at-space-2x; + min-width: 100px; overflow: hidden; - margin-right: 10px; + width: 100%; } .CapacityBar-remaining { @@ -28,14 +29,21 @@ capacity-bar { } .CapacityBar--offline { - border-color: @d7grey; + color: @at-red; + border-color: @at-gray-a9; .CapacityBar-remaining { - background-color: @d7grey; + background-color: @at-gray-b7; } } - .Capacity-details--percentage { - color: @default-data-txt; + .Capacity-details--label { + margin-right: @at-space-2x; + text-align: right; + text-transform: uppercase; } -} + + .Capacity-details--percentage { + width: 40px; + } +} \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js index 5ea07d2dd3..7301d6e898 100644 --- a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js +++ b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js @@ -1,44 +1,47 @@ export default ['templateUrl', 'ComponentsStrings', - function (templateUrl, strings) { - return { - scope: { - capacity: '=', - totalCapacity: '=' - }, - templateUrl: templateUrl('instance-groups/capacity-bar/capacity-bar'), - restrict: 'E', - link: function(scope) { - scope.isOffline = false; +function (templateUrl, strings) { + return { + scope: { + capacity: '=', + totalCapacity: '=', + labelValue: '@', + badge: '=' + }, + templateUrl: templateUrl('instance-groups/capacity-bar/capacity-bar'), + restrict: 'E', + link: function(scope) { + scope.isOffline = false; - scope.$watch('totalCapacity', function(val) { - if (val === 0) { - scope.isOffline = true; - scope.offlineTip = strings.get(`capacityBar.IS_OFFLINE`); - } else { - scope.isOffline = false; - scope.offlineTip = null; - } - }, true); + scope.$watch('totalCapacity', function(val) { + if (val === 0) { + scope.isOffline = true; + scope.labelValue = strings.get(`capacityBar.IS_OFFLINE_LABEL`); + scope.offlineTip = strings.get(`capacityBar.IS_OFFLINE`); + } else { + scope.isOffline = false; + scope.offlineTip = null; + } + }, true); - scope.$watch('capacity', function() { - if (scope.totalCapacity !== 0) { - var percentageCapacity = Math - .round(scope.capacity / scope.totalCapacity * 1000) / 10; + scope.$watch('capacity', function() { + if (scope.totalCapacity !== 0) { + var percentageCapacity = Math + .round(scope.capacity / scope.totalCapacity * 1000) / 10; - scope.CapacityStyle = { - 'flex-grow': percentageCapacity * 0.01 - }; + scope.CapacityStyle = { + 'flex-grow': percentageCapacity * 0.01 + }; - scope.consumedCapacity = `${percentageCapacity}%`; - } else { - scope.CapacityStyle = { - 'flex-grow': 1 - }; + scope.consumedCapacity = `${percentageCapacity}%`; + } else { + scope.CapacityStyle = { + 'flex-grow': 1 + }; - scope.consumedCapacity = null; - } - }, true); - } - }; - } -]; + scope.consumedCapacity = null; + } + }, true); + } + }; +} +]; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html index d80ff84bc0..6c1fadb823 100644 --- a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html +++ b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html @@ -1,11 +1,20 @@ + + {{labelValue}} + +
-
-
+
+
-{{ consumedCapacity }} + + + {{ consumedCapacity }} + \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instance-groups.partial.html b/awx/ui/client/src/instance-groups/instance-groups.partial.html index baeaf59f00..217efd98d0 100644 --- a/awx/ui/client/src/instance-groups/instance-groups.partial.html +++ b/awx/ui/client/src/instance-groups/instance-groups.partial.html @@ -1,11 +1,13 @@
+
+
-
-
-
+
+ +
diff --git a/awx/ui/client/src/instance-groups/instance-groups.route.js b/awx/ui/client/src/instance-groups/instance-groups.route.js deleted file mode 100644 index c265b35cd9..0000000000 --- a/awx/ui/client/src/instance-groups/instance-groups.route.js +++ /dev/null @@ -1,41 +0,0 @@ -import {templateUrl} from '../shared/template-url/template-url.factory'; -import { N_ } from '../i18n'; - -export default { - name: 'instanceGroups', - url: '/instance_groups', - searchPrefix: 'instance_group', - ncyBreadcrumb: { - label: N_('INSTANCE GROUPS') - }, - params: { - instance_group_search: { - value: { - page_size: '20', - order_by: 'name' - }, - dynamic: true - } - }, - data: { - alwaysShowRefreshButton: true, - }, - views: { - '@': { - templateUrl: templateUrl('./instance-groups/instance-groups'), - }, - 'list@instanceGroups': { - templateUrl: templateUrl('./instance-groups/list/instance-groups-list'), - controller: 'InstanceGroupsList' - - } - }, - resolve: { - Dataset: ['InstanceGroupList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = GetBasePath(list.basePath) || GetBasePath(list.name); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/instance-groups/instance-groups.strings.js b/awx/ui/client/src/instance-groups/instance-groups.strings.js new file mode 100644 index 0000000000..a351fe0ca8 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instance-groups.strings.js @@ -0,0 +1,25 @@ +function InstanceGroupsStrings (BaseString) { + BaseString.call(this, 'instanceGroups'); + + const { t } = this; + const ns = this.instanceGroups; + + ns.state = { + ADD_BREADCRUMB_LABEL: t.s('CREATE INSTANCE GROUP'), + EDIT_BREADCRUMB_LABEL: t.s('EDIT INSTANCE GROUP') + }; + + ns.tab = { + DETAILS: t.s('DETAILS'), + INSTANCES: t.s('INSTANCES'), + JOBS: t.s('JOBS') + }; + + ns.instance = { + PANEL_TITLE: t.s('SELECT INSTANCE') + } +} + +InstanceGroupsStrings.$inject = ['BaseStringService']; + +export default InstanceGroupsStrings; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js deleted file mode 100644 index 1d82ca854e..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js +++ /dev/null @@ -1,41 +0,0 @@ -import { N_ } from '../../../i18n'; - -export default { - name: 'instanceGroups.instances.list.job.list', - url: '/jobs', - searchPrefix: 'instance_job', - ncyBreadcrumb: { - parent: 'instanceGroups.instances.list', - label: N_('{{ breadcrumb.instance_name }}') - }, - params: { - instance_job_search: { - value: { - page_size: '20', - order_by: '-finished', - not__launch_type: 'sync' - }, - dynamic: true - } - }, - views: { - 'list@instanceGroups.instances.list.job': { - templateProvider: function(InstanceJobsList, generateList) { - let html = generateList.build({ - list: InstanceJobsList - }); - return html; - }, - controller: 'InstanceJobsController' - } - }, - - resolve: { - Dataset: ['InstanceJobsList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = `${GetBasePath('instances')}${$stateParams.instance_id}/jobs`; - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ], - } -}; diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js index a7d50764f5..5fbce21d4b 100644 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js @@ -1,82 +1,81 @@ -export default ['$scope','InstanceJobsList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', - function($scope, InstanceJobsList, GetBasePath, Rest, Dataset, Find, $state, $q) { - let list = InstanceJobsList; +function InstanceJobsController ($scope, GetBasePath, Rest, Dataset, Find, $filter, $state, $q, model, strings, jobStrings) { + const vm = this || {}; + const { instance } = model; - init(); + init(); - function init(){ - $scope.optionsDefer = $q.defer(); - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - } + function init(){ + vm.strings = strings; + vm.jobStrings = jobStrings; + vm.queryset = { page_size: '10', order_by: '-finished'}; + vm.jobs = instance.get('related.jobs.results'); + vm.dataset = instance.get('related.jobs'); + vm.count = instance.get('related.jobs.count'); + vm.panelTitle = `${jobStrings.get('list.PANEL_TITLE')} | ${instance.get('hostname')}` - $scope.$on(`${list.iterator}_options`, function(event, data){ - $scope.options = data.data.actions.GET; - optionsRequestDataProcessing(); - }); - - // iterate over the list and add fields like type label, after the - // OPTIONS request returns, or the list is sorted/paginated/searched - function optionsRequestDataProcessing(){ - - if($scope[list.name] && $scope[list.name].length > 0) { - $scope[list.name].forEach(function(item, item_idx) { - var itm = $scope[list.name][item_idx]; - - if(item.summary_fields && item.summary_fields.source_workflow_job && - item.summary_fields.source_workflow_job.id){ - item.workflow_result_link = `/#/workflows/${item.summary_fields.source_workflow_job.id}`; - } - - // Set the item type label - if (list.fields.type && $scope.options && - $scope.options.hasOwnProperty('type')) { - $scope.options.type.choices.forEach(function(choice) { - if (choice[0] === item.type) { - itm.type_label = choice[1]; - } - }); - } - buildTooltips(itm); - }); - } - } - - function buildTooltips(job) { - job.status_tip = 'Job ' + job.status + ". Click for details."; - } - - $scope.viewjobResults = function(job) { - var goTojobResults = function(state) { - $state.go(state, { id: job.id }, { reload: true }); - }; - switch (job.type) { - case 'job': - goTojobResults('jobResult'); - break; - case 'ad_hoc_command': - goTojobResults('adHocJobStdout'); - break; - case 'system_job': - goTojobResults('managementJobStdout'); - break; - case 'project_update': - goTojobResults('scmUpdateStdout'); - break; - case 'inventory_update': - goTojobResults('inventorySyncStdout'); - break; - case 'workflow_job': - goTojobResults('workflowResults'); - break; - } + vm.tab = { + details: {_hide: true}, + instances: {_hide: true}, + jobs: {_hide: true} }; - - $scope.$watchCollection(`${$scope.list.name}`, function() { - optionsRequestDataProcessing(); - } - ); } -]; \ No newline at end of file + + vm.getTime = function(time) { + let val = ""; + if (time) { + val += $filter('longDate')(time); + } + if (val === "") { + val = undefined; + } + return val; + }; + + $scope.isSuccessful = function (status) { + return (status === "successful"); + }; + + $scope.viewjobResults = function(job) { + var goTojobResults = function(state) { + $state.go(state, { id: job.id }, { reload: true }); + }; + switch (job.type) { + case 'job': + goTojobResults('jobResult'); + break; + case 'ad_hoc_command': + goTojobResults('adHocJobStdout'); + break; + case 'system_job': + goTojobResults('managementJobStdout'); + break; + case 'project_update': + goTojobResults('scmUpdateStdout'); + break; + case 'inventory_update': + goTojobResults('inventorySyncStdout'); + break; + case 'workflow_job': + goTojobResults('workflowResults'); + break; + } + }; + +} + +InstanceJobsController.$inject = [ + '$scope', + 'GetBasePath', + 'Rest', + 'Dataset', + 'Find', + '$filter', + '$state', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings', + 'JobStrings' +]; + +export default InstanceJobsController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js deleted file mode 100644 index 58476f0054..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js +++ /dev/null @@ -1,78 +0,0 @@ -export default ['i18n', function(i18n) { - return { - - name: 'instance_jobs', - iterator: 'instance_job', - index: false, - hover: false, - well: false, - emptyListText: i18n._('No jobs have yet run.'), - title: false, - basePath: 'api/v2/instances/{{$stateParams.instance_id}}/jobs', - - fields: { - status: { - label: '', - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', - dataTipWatch: 'instance_job.status_tip', - awToolTip: "{{ instance_job.status_tip }}", - awTipPlacement: "right", - dataTitle: "{{ instance_job.status_popover_title }}", - icon: 'icon-job-{{ instance_job.status }}', - iconOnly: true, - ngClick:"viewjobResults(instance_job)", - nosort: true - }, - id: { - label: i18n._('ID'), - ngClick:"viewjobResults(instance_job)", - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumnAdjacent', - awToolTip: "{{ instance_job.status_tip }}", - dataPlacement: 'top', - noLink: true - }, - name: { - label: i18n._('Name'), - columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', - ngClick: "viewjobResults(instance_job)", - nosort: true, - badgePlacement: 'right', - badgeCustom: true, - badgeIcon: ` - - W - - ` - }, - type: { - label: i18n._('Type'), - ngBind: 'instance_job.type_label', - link: false, - columnClass: "col-lg-2 hidden-md hidden-sm hidden-xs", - nosort: true - }, - finished: { - label: i18n._('Finished'), - noLink: true, - filter: "longDate", - columnClass: "col-lg-2 col-md-3 col-sm-3 hidden-xs", - key: true, - desc: true, - nosort: true - }, - labels: { - label: i18n._('Labels'), - type: 'labels', - nosort: true, - showDelete: false, - columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs', - sourceModel: 'labels', - sourceField: 'name', - }, - } - }; -}]; diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html deleted file mode 100644 index 9c40fe931f..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html +++ /dev/null @@ -1,32 +0,0 @@ -
-
-
-
-
-
{{ instanceName }}
-
-
-
-

Used Capacity

- -
-
-

Running Jobs

- - {{ instanceJobsRunning }} - -
-
-
- -
-
-
-
JOBS
-
-
-
-
-
diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js deleted file mode 100644 index 7e9be9a9de..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js +++ /dev/null @@ -1,38 +0,0 @@ -import { templateUrl } from '../../../shared/template-url/template-url.factory'; - -export default { - name: 'instanceGroups.instances.list.job', - url: '/:instance_id', - abstract: true, - ncyBreadcrumb: { - skip: true - }, - views: { - 'instanceJobs@instanceGroups': { - templateUrl: templateUrl('./instance-groups/instances/instance-jobs/instance-jobs'), - controller: function($scope, $rootScope, instance) { - $scope.instanceName = instance.hostname; - $scope.instanceCapacity = instance.consumed_capacity; - $scope.instanceTotalCapacity = instance.capacity; - $scope.instanceJobsRunning = instance.jobs_running; - $rootScope.breadcrumb.instance_name = instance.hostname; - } - } - }, - resolve: { - instance: ['GetBasePath', 'Rest', 'ProcessErrors', '$stateParams', function(GetBasePath, Rest, ProcessErrors, $stateParams) { - let url = GetBasePath('instances') + $stateParams.instance_id; - Rest.setUrl(url); - return Rest.get() - .then(({data}) => { - return data; - }) - .catch(({data, status}) => { - ProcessErrors(null, data, status, null, { - hdr: 'Error!', - msg: 'Failed to get instance groups info. GET returned status: ' + status - }); - }); - }] - } -}; diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.block.less b/awx/ui/client/src/instance-groups/instances/instance-modal.block.less new file mode 100644 index 0000000000..49ea9e10e7 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.block.less @@ -0,0 +1,24 @@ +.Modal-backdrop { + position: fixed; + top: 0px; + left: 0px; + height:100%; + width:100%; + background: #000; + z-index: 2; + opacity: 0.5; +} + +.Modal-holder { + position: fixed; + top: 1; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; + z-index: 3; + + .modal-dialog { + padding-top: 100px; + } +} diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js b/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js new file mode 100644 index 0000000000..dcb2c436d6 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js @@ -0,0 +1,74 @@ +function InstanceModalController ($scope, $state, $http, $q, models, strings) { + const { instance, instanceGroup } = models; + const vm = this || {}; + + vm.setInstances = () => { + vm.instances = instance.get('results').map(instance => { + instance.isSelected = false; + return instance; + }); + } + + vm.setRelatedInstances = () => { + vm.instanceGroupName = instanceGroup.get('name'); + vm.relatedInstances = instanceGroup.get('related.instances.results'); + vm.relatedInstanceIds = vm.relatedInstances.map(instance => instance.id); + vm.instances = instance.get('results').map(instance => { + instance.isSelected = vm.relatedInstanceIds.includes(instance.id); + return instance; + }); + } + + init(); + + function init() { + vm.strings = strings; + vm.panelTitle = strings.get('instance.PANEL_TITLE'); + vm.instanceGroupId = instanceGroup.get('id'); + + if (vm.instanceGroupId === undefined) { + vm.setInstances(); + } else { + vm.setRelatedInstances(); + } + }; + + $scope.$watch('vm.instances', function() { + vm.selectedRows = _.filter(vm.instances, 'isSelected') + vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); + }, true); + + vm.submit = () => { + let associate = vm.selectedRows + .map(instance => ({id: instance.id})); + let disassociate = vm.deselectedRows + .map(instance => ({id: instance.id, disassociate: true})); + + let all = associate.concat(disassociate); + let defers = all.map((data) => { + let config = { + url: `${vm.instanceGroupId}/instances/`, + data: data + } + return instanceGroup.http.post(config); + }); + + Promise.all(defers) + .then(vm.onSaveSuccess); + }; + + vm.onSaveSuccess = () => { + $state.go('instanceGroups.instances', {}, {reload: 'instanceGroups.instances'}); + }; +} + +InstanceModalController.$inject = [ + '$scope', + '$state', + '$http', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default InstanceModalController; diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html b/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html new file mode 100644 index 0000000000..0eb9187064 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html @@ -0,0 +1,55 @@ + \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html index da8f052423..814588a101 100644 --- a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html @@ -1,44 +1,62 @@ -
- - + + + {{ vm.panelTitle }} + -
PLEASE ADD ITEMS TO THIS LIST
-
- - - - - - - - - - - - - - - - -
- "{{'Name' | translate}}" - - - Running Jobs - - Used Capacity -
- {{ instance.hostname }} - - - {{ instance.jobs_running }} - - - -
-
-
+ + {{:: vm.strings.get('tab.DETAILS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} + + + +
+ + + +
+ +
+
+
+ + + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.route.js b/awx/ui/client/src/instance-groups/instances/instances-list.route.js deleted file mode 100644 index 16549d9d1e..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instances-list.route.js +++ /dev/null @@ -1,35 +0,0 @@ -import {templateUrl} from '../../shared/template-url/template-url.factory'; -import { N_ } from '../../i18n'; - -export default { - name: 'instanceGroups.instances.list', - url: '/instances', - searchPrefix: 'instance', - ncyBreadcrumb: { - parent: 'instanceGroups', - label: N_('{{breadcrumb.instance_group_name}}') - }, - params: { - instance_search: { - value: { - page_size: '20', - order_by: 'hostname' - }, - dynamic: true - } - }, - views: { - 'list@instanceGroups.instances': { - templateUrl: templateUrl('./instance-groups/instances/instances-list'), - controller: 'InstanceListController' - } - }, - resolve: { - Dataset: ['InstanceList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = `${GetBasePath('instance_groups')}${$stateParams.instance_group_id}/instances`; - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/instance-groups/instances/instances.controller.js b/awx/ui/client/src/instance-groups/instances/instances.controller.js index 0481d84263..6e8de76864 100644 --- a/awx/ui/client/src/instance-groups/instances/instances.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instances.controller.js @@ -1,20 +1,55 @@ -export default ['$scope', 'InstanceList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', - function($scope, InstanceList, GetBasePath, Rest, Dataset, Find, $state, $q) { - let list = InstanceList; +function InstancesController ($scope, $state, models, strings, Dataset) { + const { instanceGroup } = models; + const vm = this || {}; + vm.strings = strings; + vm.panelTitle = instanceGroup.get('name'); + vm.instances = instanceGroup.get('related.instances.results'); + vm.instance_group_id = instanceGroup.get('id'); - init(); + init(); - function init(){ - $scope.optionsDefer = $q.defer(); - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - } - - $scope.isActive = function(id) { - let selected = parseInt($state.params.instance_id); - return id === selected; + function init() { + $scope.list = { + iterator: 'instance', + name: 'instances' }; - + $scope.collection = { + basepath: 'instances', + iterator: 'instance' + }; + $scope[`${$scope.list.iterator}_dataset`] = Dataset.data; + $scope[$scope.list.name] = $scope[`${$scope.list.iterator}_dataset`].results; } -]; \ No newline at end of file + + vm.tab = { + details: { + _go: 'instanceGroups.edit', + _params: { instance_group_id: vm.instance_group_id } + }, + instances: { + _active: true, + _go: 'instanceGroups.instances', + _params: { instance_group_id: vm.instance_group_id } + }, + jobs: { + _go: 'instanceGroups.jobs', + _params: { instance_group_id: vm.instance_group_id } + } + }; + + + $scope.isActive = function(id) { + let selected = parseInt($state.params.instance_id); + return id === selected; + }; +} + +InstancesController.$inject = [ + '$scope', + '$state', + 'resolvedModels', + 'InstanceGroupsStrings', + 'Dataset' +]; + +export default InstancesController; diff --git a/awx/ui/client/src/instance-groups/instances/instances.list.js b/awx/ui/client/src/instance-groups/instances/instances.list.js deleted file mode 100644 index 048279d6c8..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instances.list.js +++ /dev/null @@ -1,29 +0,0 @@ -export default ['i18n', function(i18n) { - return { - name: 'instances' , - iterator: 'instance', - listTitle: false, - index: false, - hover: false, - tabs: true, - well: true, - - fields: { - hostname: { - key: true, - label: i18n._('Name'), - columnClass: 'col-md-3 col-sm-9 col-xs-9', - modalColumnClass: 'col-md-8', - uiSref: 'instanceGroups.instances.list.job({instance_id: instance.id})' - }, - consumed_capacity: { - label: i18n._('Capacity'), - nosort: true, - }, - jobs_running: { - label: i18n._('Running Jobs'), - nosort: true, - }, - } - }; -}]; diff --git a/awx/ui/client/src/instance-groups/instances/instances.route.js b/awx/ui/client/src/instance-groups/instances/instances.route.js deleted file mode 100644 index 8890171b58..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instances.route.js +++ /dev/null @@ -1,35 +0,0 @@ -import {templateUrl} from '../../shared/template-url/template-url.factory'; - -export default { - name: 'instanceGroups.instances', - url: '/:instance_group_id', - abstract: true, - views: { - 'instances@instanceGroups': { - templateUrl: templateUrl('./instance-groups/instance-group'), - controller: function($scope, $rootScope, instanceGroup) { - $scope.instanceGroupName = instanceGroup.name; - $scope.instanceGroupCapacity = instanceGroup.consumed_capacity; - $scope.instanceGroupTotalCapacity = instanceGroup.capacity; - $scope.instanceGroupJobsRunning = instanceGroup.jobs_running; - $rootScope.breadcrumb.instance_group_name = instanceGroup.name; - } - } - }, - resolve: { - instanceGroup: ['GetBasePath', 'Rest', 'ProcessErrors', '$stateParams', function(GetBasePath, Rest, ProcessErrors, $stateParams) { - let url = GetBasePath('instance_groups') + $stateParams.instance_group_id; - Rest.setUrl(url); - return Rest.get() - .then(({data}) => { - return data; - }) - .catch(({data, status}) => { - ProcessErrors(null, data, status, null, { - hdr: 'Error!', - msg: 'Failed to get instance groups info. GET returned status: ' + status - }); - }); - }] - } -}; diff --git a/awx/ui/client/src/instance-groups/jobs/jobs-list.route.js b/awx/ui/client/src/instance-groups/jobs/jobs-list.route.js deleted file mode 100644 index 03854eca20..0000000000 --- a/awx/ui/client/src/instance-groups/jobs/jobs-list.route.js +++ /dev/null @@ -1,41 +0,0 @@ -import { N_ } from '../../i18n'; - -export default { - name: 'instanceGroups.instances.jobs', - url: '/jobs', - searchPrefix: 'job', - ncyBreadcrumb: { - parent: 'instanceGroups.instances.list', - label: N_('JOBS') - }, - params: { - job_search: { - value: { - page_size: '20', - order_by: '-finished', - not__launch_type: 'sync' - }, - dynamic: true - }, - instance_group_id: null - }, - views: { - 'list@instanceGroups.instances': { - templateProvider: function(JobsList, generateList) { - let html = generateList.build({ - list: JobsList - }); - return html; - }, - controller: 'JobsListController' - } - }, - resolve: { - Dataset: ['JobsList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = `${GetBasePath('instance_groups')}${$stateParams.instance_group_id}/jobs`; - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/instance-groups/jobs/jobs.controller.js b/awx/ui/client/src/instance-groups/jobs/jobs.controller.js index cfe2f73327..a6054805a0 100644 --- a/awx/ui/client/src/instance-groups/jobs/jobs.controller.js +++ b/awx/ui/client/src/instance-groups/jobs/jobs.controller.js @@ -1,82 +1,93 @@ -export default ['$scope','JobsList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', - function($scope, JobsList, GetBasePath, Rest, Dataset, Find, $state, $q) { - let list = JobsList; +function InstanceGroupJobsController ($scope, GetBasePath, Rest, Dataset, Find, $filter, $state, $q, model, strings, jobStrings) { + const vm = this || {}; + const { instanceGroup } = model; - init(); + init(); - function init(){ - $scope.optionsDefer = $q.defer(); - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - } + function init(){ + let instance_group_id = instanceGroup.get('id'); + vm.strings = strings; + vm.jobStrings = jobStrings; + vm.queryset = { page_size: '10', order_by: '-finished', instance_group_id: instance_group_id }; + vm.jobs = instanceGroup.get('related.jobs.results'); + vm.dataset = instanceGroup.get('related.jobs'); + vm.count = instanceGroup.get('related.jobs.count'); + vm.panelTitle = instanceGroup.get('name'); - $scope.$on(`${list.iterator}_options`, function(event, data){ - $scope.options = data.data.actions.GET; - optionsRequestDataProcessing(); - }); - - // iterate over the list and add fields like type label, after the - // OPTIONS request returns, or the list is sorted/paginated/searched - function optionsRequestDataProcessing(){ - - if($scope[list.name] && $scope[list.name].length > 0) { - $scope[list.name].forEach(function(item, item_idx) { - var itm = $scope[list.name][item_idx]; - if(item.summary_fields && item.summary_fields.source_workflow_job && - item.summary_fields.source_workflow_job.id){ - item.workflow_result_link = `/#/workflows/${item.summary_fields.source_workflow_job.id}`; - } - - // Set the item type label - if (list.fields.type && $scope.options && - $scope.options.hasOwnProperty('type')) { - $scope.options.type.choices.forEach(function(choice) { - if (choice[0] === item.type) { - itm.type_label = choice[1]; - } - }); - } - buildTooltips(itm); - }); + vm.tab = { + details: { + _go: 'instanceGroups.edit', + _params: { instance_group_id }, + _label: strings.get('tab.DETAILS') + }, + instances: { + _go: 'instanceGroups.instances', + _params: { instance_group_id }, + _label: strings.get('tab.INSTANCES') + }, + jobs: { + _active: true, + _label: strings.get('tab.JOBS') } - } - - function buildTooltips(job) { - job.status_tip = 'Job ' + job.status + ". Click for details."; - } - - $scope.viewjobResults = function(job) { - var goTojobResults = function(state) { - $state.go(state, { id: job.id }, { reload: true }); - }; - switch (job.type) { - case 'job': - goTojobResults('jobResult'); - break; - case 'ad_hoc_command': - goTojobResults('adHocJobStdout'); - break; - case 'system_job': - goTojobResults('managementJobStdout'); - break; - case 'project_update': - goTojobResults('scmUpdateStdout'); - break; - case 'inventory_update': - goTojobResults('inventorySyncStdout'); - break; - case 'workflow_job': - goTojobResults('workflowResults'); - break; - } - }; - - $scope.$watchCollection(`${$scope.list.name}`, function() { - optionsRequestDataProcessing(); - } - ); } -]; \ No newline at end of file + + vm.getTime = function(time) { + let val = ""; + if (time) { + val += $filter('longDate')(time); + } + if (val === "") { + val = undefined; + } + return val; + }; + + $scope.isSuccessful = function (status) { + return (status === "successful"); + }; + + $scope.viewjobResults = function(job) { + var goTojobResults = function(state) { + $state.go(state, { id: job.id }, { reload: true }); + }; + switch (job.type) { + case 'job': + goTojobResults('jobResult'); + break; + case 'ad_hoc_command': + goTojobResults('adHocJobStdout'); + break; + case 'system_job': + goTojobResults('managementJobStdout'); + break; + case 'project_update': + goTojobResults('scmUpdateStdout'); + break; + case 'inventory_update': + goTojobResults('inventorySyncStdout'); + break; + case 'workflow_job': + goTojobResults('workflowResults'); + break; + } + + }; +} + +InstanceGroupJobsController.$inject = [ + '$scope', + 'GetBasePath', + 'Rest', + 'Dataset', + 'Find', + '$filter', + '$state', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings', + 'JobStrings' +]; + +export default InstanceGroupJobsController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/jobs/jobs.list.js b/awx/ui/client/src/instance-groups/jobs/jobs.list.js index 59e14ba19b..061674054d 100644 --- a/awx/ui/client/src/instance-groups/jobs/jobs.list.js +++ b/awx/ui/client/src/instance-groups/jobs/jobs.list.js @@ -1,76 +1,76 @@ -export default ['i18n', function (i18n) { - return { - name: 'jobs', - iterator: 'job', - basePath: 'api/v2/instance_groups/{{$stateParams.instance_group_id}}/jobs/', - index: false, - hover: false, - well: true, - emptyListText: i18n._('No jobs have yet run.'), - listTitle: false, +// export default ['i18n', function (i18n) { +// return { +// name: 'jobs', +// iterator: 'job', +// basePath: 'api/v2/instance_groups/{{$stateParams.instance_group_id}}/jobs/', +// index: false, +// hover: false, +// well: true, +// emptyListText: i18n._('No jobs have yet run.'), +// listTitle: false, - fields: { - status: { - label: '', - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', - dataTipWatch: 'job.status_tip', - awToolTip: "{{ job.status_tip }}", - awTipPlacement: "right", - dataTitle: "{{ job.status_popover_title }}", - icon: 'icon-job-{{ job.status }}', - iconOnly: true, - ngClick: "viewjobResults(job)", - nosort: true - }, - id: { - label: i18n._('ID'), - ngClick: "viewjobResults(job)", - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumnAdjacent', - awToolTip: "{{ job.status_tip }}", - dataPlacement: 'top', - noLink: true - }, - name: { - label: i18n._('Name'), - columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', - ngClick: "viewjobResults(job)", - badgePlacement: 'right', - badgeCustom: true, - nosort: true, - badgeIcon: ` - - W - - ` - }, - type: { - label: i18n._('Type'), - ngBind: 'job.type_label', - columnClass: "col-lg-2 hidden-md hidden-sm hidden-xs", - nosort: true - }, - finished: { - label: i18n._('Finished'), - noLink: true, - filter: "longDate", - columnClass: "col-lg-2 col-md-3 col-sm-3 hidden-xs", - key: true, - desc: true, - nosort: true - }, - labels: { - label: i18n._('Labels'), - type: 'labels', - nosort: true, - showDelete: false, - columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs', - sourceModel: 'labels', - sourceField: 'name' - }, - } - }; -}]; +// fields: { +// status: { +// label: '', +// columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', +// dataTipWatch: 'job.status_tip', +// awToolTip: "{{ job.status_tip }}", +// awTipPlacement: "right", +// dataTitle: "{{ job.status_popover_title }}", +// icon: 'icon-job-{{ job.status }}', +// iconOnly: true, +// ngClick: "viewjobResults(job)", +// nosort: true +// }, +// id: { +// label: i18n._('ID'), +// ngClick: "viewjobResults(job)", +// columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumnAdjacent', +// awToolTip: "{{ job.status_tip }}", +// dataPlacement: 'top', +// noLink: true +// }, +// name: { +// label: i18n._('Name'), +// columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', +// ngClick: "viewjobResults(job)", +// badgePlacement: 'right', +// badgeCustom: true, +// nosort: true, +// badgeIcon: ` +// +// W +// +// ` +// }, +// type: { +// label: i18n._('Type'), +// ngBind: 'job.type_label', +// columnClass: "col-lg-2 hidden-md hidden-sm hidden-xs", +// nosort: true +// }, +// finished: { +// label: i18n._('Finished'), +// noLink: true, +// filter: "longDate", +// columnClass: "col-lg-2 col-md-3 col-sm-3 hidden-xs", +// key: true, +// desc: true, +// nosort: true +// }, +// labels: { +// label: i18n._('Labels'), +// type: 'labels', +// nosort: true, +// showDelete: false, +// columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs', +// sourceModel: 'labels', +// sourceField: 'name' +// }, +// } +// }; +// }]; diff --git a/awx/ui/client/src/instance-groups/jobs/jobs.strings.js b/awx/ui/client/src/instance-groups/jobs/jobs.strings.js new file mode 100644 index 0000000000..099f7a61b9 --- /dev/null +++ b/awx/ui/client/src/instance-groups/jobs/jobs.strings.js @@ -0,0 +1,30 @@ +function JobStrings (BaseString) { + BaseString.call(this, 'jobs'); + + const { t } = this; + const ns = this.jobs; + + ns.state = { + LIST_BREADCRUMB_LABEL: t.s('JOBS') + } + + ns.list = { + PANEL_TITLE: t.s('JOBS'), + ADD_BUTTON_LABEL: t.s('ADD'), + ADD_DD_JT_LABEL: t.s('Job Template'), + ADD_DD_WF_LABEL: t.s('Workflow Template'), + ROW_ITEM_LABEL_ACTIVITY: t.s('Activity'), + ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'), + ROW_ITEM_LABEL_PROJECT: t.s('Project'), + ROW_ITEM_LABEL_TEMPLATE: t.s('Template'), + ROW_ITEM_LABEL_CREDENTIALS: t.s('Credentials'), + ROW_ITEM_LABEL_MODIFIED: t.s('Last Modified'), + ROW_ITEM_LABEL_RAN: t.s('Last Ran'), + ROW_ITEM_LABEL_STARTED: t.s('Started'), + ROW_ITEM_LABEL_FINISHED: t.s('Finished') + } +} + +JobStrings.$inject = ['BaseStringService']; + +export default JobStrings; diff --git a/awx/ui/client/src/instance-groups/jobs/list.view.html b/awx/ui/client/src/instance-groups/jobs/list.view.html new file mode 100644 index 0000000000..bd36b97548 --- /dev/null +++ b/awx/ui/client/src/instance-groups/jobs/list.view.html @@ -0,0 +1,103 @@ + + + {{ vm.panelTitle }} + + + {{:: vm.strings.get('tab.DETAILS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} + + + +
+ + +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+
+
+ + +
+
diff --git a/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js b/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js index 381e2419bf..4f1145ee1c 100644 --- a/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js +++ b/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js @@ -1,19 +1,43 @@ -export default ['$scope', 'InstanceGroupList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', - function($scope, InstanceGroupList, GetBasePath, Rest, Dataset, Find, $state) { +export default ['$scope', 'InstanceGroupList', 'resolvedModels', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', 'ComponentsStrings', + function($scope, InstanceGroupList, resolvedModels, GetBasePath, Rest, Dataset, Find, $state, $q, strings) { let list = InstanceGroupList; + const vm = this; + const { instanceGroup } = resolvedModels; init(); function init(){ + vm.panelTitle = strings.get('layout.INSTANCE_GROUPS'); $scope.list = list; $scope[`${list.iterator}_dataset`] = Dataset.data; $scope[list.name] = $scope[`${list.iterator}_dataset`].results; $scope.instanceGroupCount = Dataset.data.count; } - $scope.isActive = function(id) { - let selected = parseInt($state.params.instance_group_id); - return id === selected; + $scope.selection = {}; + + $scope.$watch('$state.params.instance_group_id', () => { + vm.activeId = parseInt($state.params.instance_group_id); + }); + + vm.delete = () => { + let deletables = $scope.selection; + deletables = Object.keys(deletables).filter((n) => deletables[n]); + //refactor + deletables.forEach((data) => { + let promise = instanceGroup.http.delete({resource: data}) + Promise.resolve(promise).then(vm.onSaveSuccess); + }); + } + + vm.onSaveSuccess = () => { + $state.transitionTo($state.current, $state.params, { + reload: true, location: true, inherit: false, notify: true + }); + } + + $scope.createInstanceGroup = () => { + $state.go('instanceGroups.add'); }; } -]; \ No newline at end of file +]; diff --git a/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html b/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html index f3d470afd9..ae2f5df32d 100644 --- a/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html +++ b/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html @@ -1,63 +1,84 @@ -
-
-
- INSTANCE GROUPS -
+ + + {{ vm.panelTitle }} {{ instanceGroupCount }} -
-
+ - - + +
+ + + +
+
+ +
+ +
+
-
PLEASE ADD ITEMS TO THIS LIST
+ + -
- - - - - - - - - - - - - - - - -
- "{{'Name' | translate}}" - - - Running Jobs - - Used Capacity -
- {{ instance_group.name }} - {{ instance_group.instances }} - - - {{ instance_group.jobs_running }} - - - -
-
+ + +
+ + + +
+ + + + + +
+ +
+ +
+ +
+
+
+
+ + + - - diff --git a/awx/ui/client/src/instance-groups/main.js b/awx/ui/client/src/instance-groups/main.js index 4f9410f0e0..8489cf83f5 100644 --- a/awx/ui/client/src/instance-groups/main.js +++ b/awx/ui/client/src/instance-groups/main.js @@ -1,58 +1,331 @@ import InstanceGroupsList from './list/instance-groups-list.controller'; import instanceGroupsMultiselect from '../shared/instance-groups-multiselect/instance-groups.directive'; import instanceGroupsModal from '../shared/instance-groups-multiselect/instance-groups-modal/instance-groups-modal.directive'; -import instanceGroupsRoute from './instance-groups.route'; -import instancesListRoute from './instances/instances-list.route'; -import JobsList from './jobs/jobs.list'; -import jobsListRoute from './jobs/jobs-list.route'; -import JobsListController from './jobs/jobs.controller'; -import InstanceList from './instances/instances.list'; -import instancesRoute from './instances/instances.route'; +import InstanceGroupJobsListController from './jobs/jobs.controller'; import InstanceListController from './instances/instances.controller'; -import InstanceJobsList from './instances/instance-jobs/instance-jobs.list'; -import instanceJobsRoute from './instances/instance-jobs/instance-jobs.route'; -import instanceJobsListRoute from './instances/instance-jobs/instance-jobs-list.route'; import InstanceJobsController from './instances/instance-jobs/instance-jobs.controller'; import CapacityBar from './capacity-bar/main'; import list from './instance-groups.list'; import service from './instance-groups.service'; -export default -angular.module('instanceGroups', [CapacityBar.name]) +import { templateUrl } from '../shared/template-url/template-url.factory'; + +import addEditTemplate from './add-edit/add-edit-instance-groups.view.html'; +import addInstanceModalTemplate from './add-edit/add-instance-list-policy.partial.html'; +import addInstanceModalController from './add-edit/add-instance-list-policy.controller.js'; +import instancesTemplate from './instances/instances-list.partial.html'; +import instanceModalTemplate from './instances/instance-modal.partial.html'; +import instanceModalController from './instances/instance-modal.controller.js'; +import AddInstanceGroupController from './add-edit/add-instance-group.controller'; +import EditInstanceGroupController from './add-edit/edit-instance-group.controller'; +import InstanceGroupsStrings from './instance-groups.strings'; +import JobStrings from './jobs/jobs.strings'; + +import jobsTemplate from './jobs/list.view.html'; + +const MODULE_NAME = 'instanceGroups'; + +function InstanceGroupsResolve ($q, $stateParams, InstanceGroup, Instance, Job) { + const instanceGroupId = $stateParams.instance_group_id; + const instanceId = $stateParams.instance_id; + let promises = {}; + + if (!instanceGroupId && !instanceId) { + promises.instanceGroup = new InstanceGroup(['get', 'options']) + promises.instance = new Instance(['get', 'options']); + + return $q.all(promises); + } + + if (instanceGroupId && instanceId) { + promises.instance = new Instance(['get', 'options'], [instanceId, instanceId]) + .then((instance) => instance.extend('get', 'jobs', {params: {page_size: "10", order_by: "-finished"}})) + return $q.all(promises); + } + + promises.instanceGroup = new InstanceGroup(['get', 'options'], [instanceGroupId, instanceGroupId]) + .then((instanceGroup) => instanceGroup.extend('get', 'jobs', {params: {page_size: "10", order_by: "-finished"}})) + .then((instanceGroup) => instanceGroup.extend('get', 'instances')) + + promises.instance = new Instance('get'); + + return $q.all(promises) + .then(models => models); +} + +InstanceGroupsResolve.$inject = [ + '$q', + '$stateParams', + 'InstanceGroupModel', + 'InstanceModel', + 'JobModel' +]; + +function InstanceGroupsRun ($stateExtender, strings, ComponentsStrings) { + $stateExtender.addState({ + name: 'instanceGroups', + url: '/instance_groups', + searchPrefix: 'instance_group', + ncyBreadcrumb: { + label: ComponentsStrings.get('layout.INSTANCE_GROUPS') + }, + params: { + instance_group_search: { + value: { + page_size: '10', + order_by: 'name' + }, + dynamic: true + } + }, + data: { + alwaysShowRefreshButton: true, + }, + views: { + '@': { + templateUrl: templateUrl('./instance-groups/instance-groups'), + }, + 'list@instanceGroups': { + templateUrl: templateUrl('./instance-groups/list/instance-groups-list'), + controller: 'InstanceGroupsList', + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve, + Dataset: ['InstanceGroupList', 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ] + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.add', + url: '/add', + ncyBreadcrumb: { + label: strings.get('state.ADD_BREADCRUMB_LABEL') + }, + views: { + 'add@instanceGroups': { + templateUrl: addEditTemplate, + controller: AddInstanceGroupController, + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.add.modal', + abstract: true, + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + template: ` + `, + } + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.add.modal.instances', + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + templateUrl: addInstanceModalTemplate, + controller: addInstanceModalController, + controllerAs: 'vm' + } + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.edit', + route: '/:instance_group_id', + ncyBreadcrumb: { + label: strings.get('state.EDIT_BREADCRUMB_LABEL') + }, + views: { + 'edit@instanceGroups': { + templateUrl: addEditTemplate, + controller: EditInstanceGroupController, + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }); + + + $stateExtender.addState({ + name: 'instanceGroups.edit.modal', + abstract: true, + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + template: ` + `, + } + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.edit.modal.instances', + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + templateUrl: addInstanceModalTemplate, + controller: addInstanceModalController, + controllerAs: 'vm' + } + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.instances', + url: '/:instance_group_id/instances', + ncyBreadcrumb: { + parent: 'instanceGroups.edit', + label: ComponentsStrings.get('layout.INSTANCES') + }, + params: { + instance_search: { + value: { + page_size: '10', + order_by: 'hostname' + }, + dynamic: true + } + }, + views: { + 'instances@instanceGroups': { + templateUrl: instancesTemplate, + controller: 'InstanceListController', + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.instances.modal', + abstract: true, + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + template: ` + `, + } + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.instances.modal.add', + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + templateUrl: instanceModalTemplate, + controller: instanceModalController, + controllerAs: 'vm' + } + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.instanceJobs', + url: '/:instance_group_id/instances/:instance_id/jobs', + ncyBreadcrumb: { + parent: 'instanceGroups.instances', + label: ComponentsStrings.get('layout.JOBS') + }, + views: { + 'instanceJobs@instanceGroups': { + templateUrl: jobsTemplate, + controller: 'InstanceJobsController', + controllerAs: 'vm' + }, + }, + params: { + job_search: { + value: { + page_size: '10', + order_by: '-finished' + }, + dynamic: true + }, + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.jobs', + url: '/:instance_group_id/jobs', + ncyBreadcrumb: { + parent: 'instanceGroups.edit', + label: ComponentsStrings.get('layout.JOBS') + }, + params: { + job_search: { + value: { + page_size: '10', + order_by: '-finished' + }, + dynamic: true + } + }, + views: { + 'jobs@instanceGroups': { + templateUrl: jobsTemplate, + controller: 'InstanceGroupJobsListController', + controllerAs: 'vm' + }, + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }) +} + +InstanceGroupsRun.$inject = [ + '$stateExtender', + 'InstanceGroupsStrings', + 'ComponentsStrings' +]; + +angular.module(MODULE_NAME, [CapacityBar.name]) .service('InstanceGroupsService', service) .factory('InstanceGroupList', list) - .factory('JobsList', JobsList) - .factory('InstanceList', InstanceList) - .factory('InstanceJobsList', InstanceJobsList) .controller('InstanceGroupsList', InstanceGroupsList) - .controller('JobsListController', JobsListController) + .controller('InstanceGroupJobsListController', InstanceGroupJobsListController) .controller('InstanceListController', InstanceListController) .controller('InstanceJobsController', InstanceJobsController) .directive('instanceGroupsMultiselect', instanceGroupsMultiselect) .directive('instanceGroupsModal', instanceGroupsModal) - .config(['$stateProvider', 'stateDefinitionsProvider', '$stateExtenderProvider', - function($stateProvider, stateDefinitionsProvider, $stateExtenderProvider) { - let stateExtender = $stateExtenderProvider.$get(); + .service('InstanceGroupsStrings', InstanceGroupsStrings) + .service('JobStrings', JobStrings) + .run(InstanceGroupsRun); - - function generateInstanceGroupsStates() { - return new Promise((resolve) => { - resolve({ - states: [ - stateExtender.buildDefinition(instanceGroupsRoute), - stateExtender.buildDefinition(instancesRoute), - stateExtender.buildDefinition(instancesListRoute), - stateExtender.buildDefinition(jobsListRoute), - stateExtender.buildDefinition(instanceJobsRoute), - stateExtender.buildDefinition(instanceJobsListRoute) - ] - }); - }); - } - - $stateProvider.state({ - name: 'instanceGroups.**', - url: '/instance_groups', - lazyLoad: () => generateInstanceGroupsStates() - }); - }]); +export default MODULE_NAME; diff --git a/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html b/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html index ba96157381..48eb41b1b5 100644 --- a/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html +++ b/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html @@ -11,6 +11,7 @@
{{selectedRow.name}} + {{selectedRow.hostname}}
From 342958ece38f1df13cb65587b4260662d174fe18 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 29 Jan 2018 11:09:00 -0500 Subject: [PATCH 12/17] Add stringToNumber directive --- awx/ui/client/src/shared/directives.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index 2013c4392c..5df24c2450 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -38,7 +38,7 @@ angular.module('AWDirectives', ['RestServices', 'Utilities']) }; }) -// caplitalize Add to any input field where the first letter of each +// capitalize Add to any input field where the first letter of each // word should be capitalized. Use in place of css test-transform. // For some reason "text-transform: capitalize" in breadcrumbs // causes a break at each blank space. And of course, @@ -65,6 +65,26 @@ angular.module('AWDirectives', ['RestServices', 'Utilities']) }; }) +// stringToNumber +// +// If your model does not contain actual numbers then this directive +// will do the conversion in the ngModel $formatters and $parsers pipeline. +// +.directive('stringToNumber', function() { + return { + require: 'ngModel', + restrict: 'A', + link: function(scope, element, attrs, ngModel) { + ngModel.$parsers.push(function(value) { + return '' + value; + }); + ngModel.$formatters.push(function(value) { + return parseFloat(value); + }); + } + }; +}) + // imageUpload // // Accepts image and returns base64 information with basic validation From 70786c53a77443d944b3e34a62274f7fc57e1847 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 29 Jan 2018 13:11:45 -0500 Subject: [PATCH 13/17] Add capacity adjuster directive --- .../capacity-adjuster.directive.js | 45 +++++++++++++++++++ .../capacity-adjuster.partial.html | 14 ++++++ .../instances/instances-list.partial.html | 1 + .../instances/instances.controller.js | 5 ++- awx/ui/client/src/instance-groups/main.js | 2 + 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js create mode 100644 awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html diff --git a/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js new file mode 100644 index 0000000000..65ce8755b2 --- /dev/null +++ b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js @@ -0,0 +1,45 @@ +function CapacityAdjuster (templateUrl, $http) { + return { + scope: { + state: '=' + }, + templateUrl: templateUrl('instance-groups/capacity-adjuster/capacity-adjuster'), + restrict: 'E', + link: function(scope) { + let adjustment_values = [{ + label: 'CPU', + value: scope.state.cpu_capacity, + },{ + label: 'RAM', + value: scope.state.mem_capacity + }]; + + scope.min_capacity = _.min(adjustment_values, 'value'); + scope.max_capacity = _.max(adjustment_values, 'value'); + + }, + controller: function($http, $scope) { + const vm = this || {}; + + vm.slide = (state) => { + let data = { + "capacity_adjustment": state.capacity_adjustment + }; + let req = { + method: 'PUT', + url: state.url, + data + }; + $http(req); + } + }, + controllerAs: 'vm' + }; +} + +CapacityAdjuster.$inject = [ + 'templateUrl', + '$http' +]; + +export default CapacityAdjuster; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html new file mode 100644 index 0000000000..d3200f49eb --- /dev/null +++ b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html @@ -0,0 +1,14 @@ +
+

{{min_capacity.label}}

+

{{min_capacity.value}}

+ +

{{max_capacity.label}}

+

{{max_capacity.value}}

+
diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html index 814588a101..8f97077cf8 100644 --- a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html @@ -54,6 +54,7 @@
+
diff --git a/awx/ui/client/src/instance-groups/instances/instances.controller.js b/awx/ui/client/src/instance-groups/instances/instances.controller.js index 6e8de76864..c8c7b3d3af 100644 --- a/awx/ui/client/src/instance-groups/instances/instances.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instances.controller.js @@ -1,4 +1,4 @@ -function InstancesController ($scope, $state, models, strings, Dataset) { +function InstancesController ($scope, $state, $http, models, Instance, strings, Dataset) { const { instanceGroup } = models; const vm = this || {}; vm.strings = strings; @@ -37,7 +37,6 @@ function InstancesController ($scope, $state, models, strings, Dataset) { } }; - $scope.isActive = function(id) { let selected = parseInt($state.params.instance_id); return id === selected; @@ -47,7 +46,9 @@ function InstancesController ($scope, $state, models, strings, Dataset) { InstancesController.$inject = [ '$scope', '$state', + '$http', 'resolvedModels', + 'InstanceModel', 'InstanceGroupsStrings', 'Dataset' ]; diff --git a/awx/ui/client/src/instance-groups/main.js b/awx/ui/client/src/instance-groups/main.js index 8489cf83f5..06afc31c66 100644 --- a/awx/ui/client/src/instance-groups/main.js +++ b/awx/ui/client/src/instance-groups/main.js @@ -5,6 +5,7 @@ import InstanceGroupJobsListController from './jobs/jobs.controller'; import InstanceListController from './instances/instances.controller'; import InstanceJobsController from './instances/instance-jobs/instance-jobs.controller'; import CapacityBar from './capacity-bar/main'; +import CapacityAdjuster from './capacity-adjuster/capacity-adjuster.directive'; import list from './instance-groups.list'; import service from './instance-groups.service'; @@ -324,6 +325,7 @@ angular.module(MODULE_NAME, [CapacityBar.name]) .controller('InstanceJobsController', InstanceJobsController) .directive('instanceGroupsMultiselect', instanceGroupsMultiselect) .directive('instanceGroupsModal', instanceGroupsModal) + .directive('capacityAdjuster', CapacityAdjuster) .service('InstanceGroupsStrings', InstanceGroupsStrings) .service('JobStrings', JobStrings) .run(InstanceGroupsRun); From e07f441e3236055f3f4b2e4cbc74cae7a735d8f6 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 30 Jan 2018 15:25:36 -0500 Subject: [PATCH 14/17] Add Instance enable/disable toggle to list --- .../lib/components/components.strings.js | 5 - .../lib/components/input/lookup.directive.js | 10 +- .../lib/components/input/lookup.partial.html | 73 ++++----- awx/ui/client/lib/components/list/_index.less | 17 ++ awx/ui/client/lib/components/tabs/_index.less | 4 + awx/ui/client/lib/models/Job.js | 21 --- .../lib/services/base-string.service.js | 2 + awx/ui/client/lib/theme/_global.less | 1 + awx/ui/client/lib/theme/index.less | 1 + awx/ui/client/src/app.js | 15 -- .../add-edit-instance-groups.view.html | 8 +- .../add-edit/add-instance-group.controller.js | 4 +- .../edit-instance-group.controller.js | 3 +- ....js => instance-list-policy.controller.js} | 28 +++- ...html => instance-list-policy.partial.html} | 30 ++-- .../capacity-adjuster.block.less | 11 ++ .../capacity-adjuster.directive.js | 9 +- .../capacity-adjuster.partial.html | 9 +- .../capacity-bar/capacity-bar.directive.js | 2 +- .../capacity-bar/capacity-bar.partial.html | 2 +- .../instance-group.partial.html | 33 ---- .../instance-groups.strings.js | 7 +- .../instance-jobs/instance-jobs.controller.js | 2 +- .../instances/instance-modal.controller.js | 10 +- .../instances/instance-modal.partial.html | 26 +-- .../instances/instances-list.partial.html | 26 ++- .../instances/instances.controller.js | 23 +++ ...{list.view.html => jobs-list.partial.html} | 28 +--- .../instance-groups/jobs/jobs.controller.js | 2 +- .../src/instance-groups/jobs/jobs.list.js | 150 +++++++++--------- .../src/instance-groups/jobs/jobs.strings.js | 4 +- .../list/instance-groups-list.controller.js | 11 +- .../list/instance-groups-list.partial.html | 6 +- awx/ui/client/src/instance-groups/main.js | 87 +++++----- 34 files changed, 328 insertions(+), 342 deletions(-) delete mode 100644 awx/ui/client/lib/models/Job.js rename awx/ui/client/src/instance-groups/add-edit/{add-instance-list-policy.controller.js => instance-list-policy.controller.js} (52%) rename awx/ui/client/src/instance-groups/add-edit/{add-instance-list-policy.partial.html => instance-list-policy.partial.html} (68%) create mode 100644 awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.block.less delete mode 100644 awx/ui/client/src/instance-groups/instance-group.partial.html rename awx/ui/client/src/instance-groups/jobs/{list.view.html => jobs-list.partial.html} (76%) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index ddc765533b..810fe5338a 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -78,11 +78,6 @@ function ComponentsStrings (BaseString) { FOOTER_COPYRIGHT: t.s('Copyright © 2017 Red Hat, Inc.') }; - ns.capacityBar = { - IS_OFFLINE: t.s('Unavailable to run jobs.'), - IS_OFFLINE_LABEL: t.s('Unavailable') - }; - ns.relaunch = { DEFAULT: t.s('Relaunch using the same parameters'), HOSTS: t.s('Relaunch using host parameters'), diff --git a/awx/ui/client/lib/components/input/lookup.directive.js b/awx/ui/client/lib/components/input/lookup.directive.js index fcee7ad72c..0447d6b448 100644 --- a/awx/ui/client/lib/components/input/lookup.directive.js +++ b/awx/ui/client/lib/components/input/lookup.directive.js @@ -119,14 +119,8 @@ function AtInputLookupController (baseInputController, $q, $state) { vm.searchAfterDebounce(); }; - vm.removeTag = (i) => { - let list; - if (!i.id) { - list = _.remove(scope.state._value, i); - } else { - list = _.remove(scope.state._value, i.id); - } - scope.state._value = list; + vm.removeTag = (tagToRemove) => { + _.remove(scope.state._value, (tag) => tag === tagToRemove); }; } diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index 271c24212f..e3633aa743 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -1,45 +1,40 @@
-
- +
+ -
- - - +
+ + + + - - - - - - -
-
- -
-
- {{ tag.hostname }} - {{ tag }} -
-
-
-
- - + +
+
+ +
+
+ {{ tag.hostname }} + {{ tag }} +
+
-
+ +
+ +
\ No newline at end of file diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index 77997bbbcc..a4daa3248b 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -99,6 +99,15 @@ } } +.at-RowStatus { + align-self: flex-start; + margin: 0 10px 0 0; +} + +.at-Row-firstColumn { + margin-right: @at-space-4x; +} + .at-Row-actions { display: flex; } @@ -120,6 +129,14 @@ line-height: @at-line-height-list-row-item-header; } +.at-RowItem--isHeaderLink { + color: @at-blue; + cursor: pointer; +} +.at-RowItem--isHeaderLink:hover { + color: @at-blue-hover; +} + .at-RowItem--labels { line-height: @at-line-height-list-row-item-labels; } diff --git a/awx/ui/client/lib/components/tabs/_index.less b/awx/ui/client/lib/components/tabs/_index.less index 4956444576..84b33c2134 100644 --- a/awx/ui/client/lib/components/tabs/_index.less +++ b/awx/ui/client/lib/components/tabs/_index.less @@ -26,3 +26,7 @@ cursor: not-allowed; } } + +.at-TabGroup + .at-Panel-body { + margin-top: 20px; +} \ No newline at end of file diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js deleted file mode 100644 index 9be420b2f9..0000000000 --- a/awx/ui/client/lib/models/Job.js +++ /dev/null @@ -1,21 +0,0 @@ -let Base; - -function JobModel (method, resource, config) { - Base.call(this, 'jobs'); - - this.Constructor = JobModel; - - return this.create(method, resource, config); -} - -function JobModelLoader (BaseModel) { - Base = BaseModel; - - return JobModel; -} - -JobModelLoader.$inject = [ - 'BaseModel' -]; - -export default JobModelLoader; diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index a14871ae68..6c2622879d 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -60,6 +60,8 @@ function BaseStringService (namespace) { this.CANCEL = t.s('CANCEL'); this.SAVE = t.s('SAVE'); this.OK = t.s('OK'); + this.ON = t.s('ON'); + this.OFF = t.s('OFF'); this.deleteResource = { HEADER: t.s('Delete'), USED_BY: resourceType => t.s('The {{ resourceType }} is currently being used by other resources.', { resourceType }), diff --git a/awx/ui/client/lib/theme/_global.less b/awx/ui/client/lib/theme/_global.less index b62f501c33..6995b224a5 100644 --- a/awx/ui/client/lib/theme/_global.less +++ b/awx/ui/client/lib/theme/_global.less @@ -23,6 +23,7 @@ font-size: 20px; } border-color: transparent; + margin-left: @at-space-2x; } .at-Button--info { diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index 6b3f241ffd..0a3d936978 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -72,6 +72,7 @@ @import '../../src/home/dashboard/lists/dashboard-list.block.less'; @import '../../src/home/dashboard/dashboard.block.less'; @import '../../src/instance-groups/capacity-bar/capacity-bar.block.less'; +@import '../../src/instance-groups/capacity-adjuster/capacity-adjuster.block.less'; @import '../../src/instance-groups/instance-group.block.less'; @import '../../src/instance-groups/instances/instance-modal.block.less'; @import '../../src/inventories-hosts/inventories/insights/insights.block.less'; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 069ee2dd80..e95779eb38 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -316,21 +316,6 @@ angular activateTab(); }); - $transitions.onCreate({}, function(trans) { - console.log('$onCreate ' +trans.to().name); - }); - - $transitions.onBefore({}, function(trans) { - console.log('$onBefore ' +trans.to().name); - }); - $transitions.onError({}, function(trans) { - - console.log('$onError ' +trans.to().name); - }); - $transitions.onExit({}, function(trans) { - console.log('$onExit ' +trans.to().name); - }); - $transitions.onSuccess({}, function(trans) { if(trans.to() === trans.from()) { diff --git a/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html index a980d74ec8..8567f095ce 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html +++ b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html @@ -5,23 +5,19 @@ {{:: vm.strings.get('tab.DETAILS') }} - {{:: vm.strings.get('tab.INSTANCES') }} - {{:: vm.strings.get('tab.JOBS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} - - - -
diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js index 9fc838115b..bb0b3ea413 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js @@ -1,11 +1,10 @@ function AddController ($scope, $state, models, strings) { const vm = this || {}; - const { instanceGroup, instance } = models; vm.mode = 'add'; vm.strings = strings; - vm.panelTitle = "New Instance Group"; + vm.panelTitle = strings.get('state.ADD_BREADCRUMB_LABEL'); vm.tab = { details: { _active: true }, @@ -15,6 +14,7 @@ function AddController ($scope, $state, models, strings) { vm.form = instanceGroup.createFormSchema('post'); + // Default policy instance percentage value is 0 vm.form.policy_instance_percentage._value = 0; vm.form.policy_instance_list._lookupTags = true; diff --git a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js index 49b197c6ec..ee0802bbe2 100644 --- a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js @@ -1,6 +1,5 @@ function EditController ($rootScope, $state, models, strings) { const vm = this || {}; - const { instanceGroup, instance } = models; $rootScope.breadcrumb.instance_group_name = instanceGroup.get('name'); @@ -36,7 +35,7 @@ function EditController ($rootScope, $state, models, strings) { vm.form.save = data => { instanceGroup.unset('policy_instance_list'); - data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname); + data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname || instance); return instanceGroup.request('put', { data }); }; diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.controller.js similarity index 52% rename from awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js rename to awx/ui/client/src/instance-groups/add-edit/instance-list-policy.controller.js index c7819ba797..f70150a79c 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.controller.js @@ -1,5 +1,5 @@ function InstanceModalController ($scope, $state, $http, $q, models, strings) { - const { instance } = models; + const { instance, instanceGroup } = models; const vm = this || {}; vm.setInstances = () => { @@ -7,18 +7,34 @@ function InstanceModalController ($scope, $state, $http, $q, models, strings) { instance.isSelected = false; return instance; }); - } + }; + + vm.setRelatedInstances = () => { + vm.instanceGroupName = instanceGroup.get('name'); + vm.relatedInstances = instanceGroup.get('policy_instance_list'); + + vm.instances = instance.get('results').map(instance => { + instance.isSelected = vm.relatedInstances.includes(instance.hostname); + return instance; + }); + }; init(); function init() { vm.strings = strings; - vm.panelTitle = strings.get('instance.PANEL_TITLE'); - vm.setInstances(); - }; + vm.instanceGroupId = instanceGroup.get('id'); + vm.defaultParams = { page_size: '10', order_by: 'hostname' }; + + if (vm.instanceGroupId === undefined) { + vm.setInstances(); + } else { + vm.setRelatedInstances(); + } + } $scope.$watch('vm.instances', function() { - vm.selectedRows = _.filter(vm.instances, 'isSelected') + vm.selectedRows = _.filter(vm.instances, 'isSelected'); vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); }, true); diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html similarity index 68% rename from awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html rename to awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html index 29493add34..218e61421d 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html +++ b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html @@ -1,17 +1,17 @@