diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index a74135e67d..ab47c88abd 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -690,15 +690,42 @@ def service_list(request): return novaclient(request).services.list() -def aggregate_list(request): +def aggregate_details_list(request): result = [] c = novaclient(request) for aggregate in c.aggregates.list(): result.append(c.aggregates.get_details(aggregate.id)) - return result +def aggregate_create(request, name, availability_zone=None): + return novaclient(request).aggregates.create(name, availability_zone) + + +def aggregate_delete(request, aggregate_id): + return novaclient(request).aggregates.delete(aggregate_id) + + +def aggregate_get(request, aggregate_id): + return novaclient(request).aggregates.get(aggregate_id) + + +def aggregate_update(request, aggregate_id, values): + return novaclient(request).aggregates.update(aggregate_id, values) + + +def host_list(request): + return novaclient(request).hosts.list() + + +def add_host_to_aggregate(request, aggregate_id, host): + return novaclient(request).aggregates.add_host(aggregate_id, host) + + +def remove_host_from_aggregate(request, aggregate_id, host): + return novaclient(request).aggregates.remove_host(aggregate_id, host) + + @memoized def list_extensions(request): return nova_list_extensions.ListExtManager(novaclient(request)).show_all() diff --git a/openstack_dashboard/dashboards/admin/aggregates/__init__.py b/openstack_dashboard/dashboards/admin/aggregates/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/admin/aggregates/constants.py b/openstack_dashboard/dashboards/admin/aggregates/constants.py new file mode 100644 index 0000000000..4b915c2798 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/constants.py @@ -0,0 +1,21 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +AGGREGATES_TEMPLATE_NAME = 'admin/aggregates/index.html' +AGGREGATES_INDEX_URL = 'horizon:admin:aggregates:index' +AGGREGATES_INDEX_VIEW_TEMPLATE = 'admin/aggregates/index.html' +AGGREGATES_CREATE_URL = 'horizon:admin:aggregates:create' +AGGREGATES_CREATE_VIEW_TEMPLATE = 'admin/aggregates/create.html' +AGGREGATES_MANAGE_HOSTS_URL = 'horizon:admin:aggregates:manage_hosts' +AGGREGATES_MANAGE_HOSTS_TEMPLATE = 'admin/aggregates/manage_hosts.html' +AGGREGATES_UPDATE_URL = 'horizon:admin:aggregates:update' +AGGREGATES_UPDATE_VIEW_TEMPLATE = 'admin/aggregates/update.html' diff --git a/openstack_dashboard/dashboards/admin/aggregates/forms.py b/openstack_dashboard/dashboards/admin/aggregates/forms.py new file mode 100644 index 0000000000..752772fbed --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/forms.py @@ -0,0 +1,48 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.aggregates import constants + +INDEX_URL = constants.AGGREGATES_INDEX_URL + + +class UpdateAggregateForm(forms.SelfHandlingForm): + name = forms.CharField(max_length="255", label=_("Name")) + availability_zone = forms.CharField(label=_("Availability zones"), + required=False) + + def __init__(self, request, *args, **kwargs): + super(UpdateAggregateForm, self).__init__(request, *args, **kwargs) + + def handle(self, request, data): + id = self.initial['id'] + name = data['name'] + availability_zone = data['availability_zone'] + aggregate = {'name': name} + if availability_zone: + aggregate['availability_zone'] = availability_zone + try: + api.nova.aggregate_update(request, id, aggregate) + message = _('Successfully updated aggregate: "%s."') \ + % data['name'] + messages.success(request, message) + except Exception: + exceptions.handle(request, + _('Unable to update the aggregate.')) + return True diff --git a/openstack_dashboard/dashboards/admin/aggregates/panel.py b/openstack_dashboard/dashboards/admin/aggregates/panel.py new file mode 100644 index 0000000000..e6e7c42aab --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/panel.py @@ -0,0 +1,25 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +import horizon + +from openstack_dashboard.dashboards.admin import dashboard + + +class Aggregates(horizon.Panel): + name = _("Host Aggregates") + slug = 'aggregates' + + +dashboard.Admin.register(Aggregates) diff --git a/openstack_dashboard/dashboards/admin/aggregates/tables.py b/openstack_dashboard/dashboards/admin/aggregates/tables.py new file mode 100644 index 0000000000..4241142d08 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/tables.py @@ -0,0 +1,127 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.template import defaultfilters as filters +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.aggregates import constants + + +class DeleteAggregateAction(tables.DeleteAction): + data_type_singular = _("Host Aggregate") + data_type_plural = _("Host Aggregates") + + def delete(self, request, obj_id): + api.nova.aggregate_delete(request, obj_id) + + +class CreateAggregateAction(tables.LinkAction): + name = "create" + verbose_name = _("Create Host Aggregate") + url = constants.AGGREGATES_CREATE_URL + classes = ("ajax-modal", "btn-create") + + +class ManageHostsAction(tables.LinkAction): + name = "manage" + verbose_name = _("Manage Hosts") + url = constants.AGGREGATES_MANAGE_HOSTS_URL + classes = ("ajax-modal", "btn-create") + + +class UpdateAggregateAction(tables.LinkAction): + name = "update" + verbose_name = _("Edit Host Aggregate") + url = constants.AGGREGATES_UPDATE_URL + classes = ("ajax-modal", "btn-edit") + + +class AggregateFilterAction(tables.FilterAction): + def filter(self, table, aggregates, filter_string): + q = filter_string.lower() + + def comp(aggregate): + return q in aggregate.name.lower() + + return filter(comp, aggregates) + + +class AvailabilityZoneFilterAction(tables.FilterAction): + def filter(self, table, availability_zones, filter_string): + q = filter_string.lower() + + def comp(availabilityZone): + return q in availabilityZone.name.lower() + + return filter(comp, availability_zones) + + +def get_aggregate_hosts(aggregate): + return [host for host in aggregate.hosts] + + +def get_available(zone): + return zone.zoneState['available'] + + +def get_zone_hosts(zone): + hosts = zone.hosts + host_details = [] + for name, services in hosts.items(): + up = all([s['active'] and s['available'] for k, s in services.items()]) + up = _("Services Up") if up else _("Services Down") + host_details.append("%(host)s (%(up)s)" % {'host': name, 'up': up}) + return host_details + + +class HostAggregatesTable(tables.DataTable): + name = tables.Column('name', verbose_name=_('Name')) + availability_zone = tables.Column('availability_zone', + verbose_name=_('Availability Zone')) + hosts = tables.Column(get_aggregate_hosts, + verbose_name=_("Hosts"), + wrap_list=True, + filters=(filters.unordered_list,)) + + class Meta: + name = "host_aggregates" + verbose_name = _("Host Aggregates") + table_actions = (AggregateFilterAction, + CreateAggregateAction, + DeleteAggregateAction) + row_actions = (UpdateAggregateAction, + ManageHostsAction, + DeleteAggregateAction) + + +class AvailabilityZonesTable(tables.DataTable): + name = tables.Column('zoneName', + verbose_name=_('Availability Zone Name')) + hosts = tables.Column(get_zone_hosts, + verbose_name=_('Hosts'), + wrap_list=True, + filters=(filters.unordered_list,)) + available = tables.Column(get_available, + verbose_name=_('Available'), + status=True, + filters=(filters.yesno, filters.capfirst)) + + def get_object_id(self, zone): + return zone.zoneName + + class Meta: + name = "availability_zones" + verbose_name = _("Availability Zones") + table_actions = (AggregateFilterAction,) diff --git a/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/_manage_hosts.html b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/_manage_hosts.html new file mode 100644 index 0000000000..bafc137fa7 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/_manage_hosts.html @@ -0,0 +1,29 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:admin:aggregates:manage_hosts' id%}{% endblock %} + +{% block modal_id %}add_aggregate_modal{% endblock %} +{% block modal-header %}{% trans "Manage Hosts" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description" %}:

+

{% blocktrans %} + Here you can add/remove hosts to the selected aggregate host. + Note that while a host can be a member of multiple aggregates, it can belong to one availability zone at most. + {% endblocktrans %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/_update.html b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/_update.html new file mode 100644 index 0000000000..f6f2edb5f6 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/_update.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}edit_aggregate_form{% endblock %} +{% block form_action %}{% url 'horizon:admin:aggregates:update' id %}{% endblock %} + +{% block modal_id %}edit_aggregate_modal{% endblock %} +{% block modal-header %}{% trans "Edit Host Aggregate" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description" %}:

+

{% trans "From here you can edit the aggregate name and availability zone" %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/create.html b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/create.html new file mode 100644 index 0000000000..72792c1f7c --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/create.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Host Aggregate" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create Host Aggregate") %} +{% endblock page_header %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/index.html b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/index.html new file mode 100644 index 0000000000..260d342624 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/index.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Host Aggregates" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Host Aggregates") %} +{% endblock page_header %} + +{% block main %} +
+ {{ host_aggregates_table.render }} +
+ +
+ {{ availability_zones_table.render }} +
+{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/manage_hosts.html b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/manage_hosts.html new file mode 100644 index 0000000000..382efb5431 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/manage_hosts.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Manage Hosts Aggregate" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Manage Hosts Aggregate") %} +{% endblock page_header %} + +{% block main %} + {% include 'admin/aggregates/_manage_hosts.html' %} +{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/update.html b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/update.html new file mode 100644 index 0000000000..45b6b51400 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/templates/aggregates/update.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Edit Host Aggregate" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Edit Host Aggregate") %} +{% endblock page_header %} + + +{% block main %} + {% include 'admin/aggregates/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/aggregates/tests.py b/openstack_dashboard/dashboards/admin/aggregates/tests.py new file mode 100644 index 0000000000..cfe5c7af60 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/tests.py @@ -0,0 +1,256 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from django.core.urlresolvers import reverse +from django import http +from mox import IsA # noqa + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.aggregates import constants +from openstack_dashboard.dashboards.admin.aggregates import workflows +from openstack_dashboard.test import helpers as test + + +class BaseAggregateWorkflowTests(test.BaseAdminViewTests): + + def _get_create_workflow_data(self, aggregate, hosts=None): + aggregate_info = {"name": aggregate.name, + "availability_zone": aggregate.availability_zone} + + if hosts: + compute_hosts = [] + for host in hosts: + if host.service == 'compute': + compute_hosts.append(host) + + host_field_name = 'add_host_to_aggregate_role_member' + aggregate_info[host_field_name] = \ + [h.host_name for h in compute_hosts] + + return aggregate_info + + def _get_manage_workflow_data(self, aggregate, hosts=None, ): + aggregate_info = {"id": aggregate.id} + + if hosts: + compute_hosts = [] + for host in hosts: + if host.service == 'compute': + compute_hosts.append(host) + + host_field_name = 'add_host_to_aggregate_role_member' + aggregate_info[host_field_name] = \ + [h.host_name for h in compute_hosts] + + return aggregate_info + + +class CreateAggregateWorkflowTests(BaseAggregateWorkflowTests): + + @test.create_stubs({api.nova: ('host_list', ), }) + def test_workflow_get(self): + + api.nova.host_list(IsA(http.HttpRequest)).AndReturn(self.hosts.list()) + self.mox.ReplayAll() + + url = reverse(constants.AGGREGATES_CREATE_URL) + res = self.client.get(url) + workflow = res.context['workflow'] + + self.assertTemplateUsed(res, constants.AGGREGATES_CREATE_VIEW_TEMPLATE) + self.assertEqual(workflow.name, workflows.CreateAggregateWorkflow.name) + self.assertQuerysetEqual(workflow.steps, + ['', + '']) + + @test.create_stubs({api.nova: ('host_list', 'aggregate_details_list', + 'aggregate_create'), }) + def test_create_aggregate(self): + + aggregate = self.aggregates.first() + + api.nova.host_list(IsA(http.HttpRequest)).AndReturn(self.hosts.list()) + api.nova.aggregate_details_list(IsA(http.HttpRequest)).AndReturn([]) + + workflow_data = self._get_create_workflow_data(aggregate) + api.nova.aggregate_create(IsA(http.HttpRequest), + name=workflow_data['name'], + availability_zone= + workflow_data['availability_zone'])\ + .AndReturn(aggregate) + + self.mox.ReplayAll() + + url = reverse(constants.AGGREGATES_CREATE_URL) + res = self.client.post(url, workflow_data) + + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, + reverse(constants.AGGREGATES_INDEX_URL)) + + @test.create_stubs({api.nova: ('host_list', + 'aggregate_details_list', + 'aggregate_create', + 'add_host_to_aggregate'), }) + def test_create_aggregate_with_hosts(self): + + aggregate = self.aggregates.first() + hosts = self.hosts.list() + + api.nova.host_list(IsA(http.HttpRequest)).AndReturn(self.hosts.list()) + api.nova.aggregate_details_list(IsA(http.HttpRequest)).AndReturn([]) + + workflow_data = self._get_create_workflow_data(aggregate, hosts) + api.nova.aggregate_create(IsA(http.HttpRequest), + name=workflow_data['name'], + availability_zone= + workflow_data['availability_zone'])\ + .AndReturn(aggregate) + + compute_hosts = [] + for host in hosts: + if host.service == 'compute': + compute_hosts.append(host) + + for host in compute_hosts: + api.nova.add_host_to_aggregate(IsA(http.HttpRequest), + aggregate.id, host.host_name) + + self.mox.ReplayAll() + + url = reverse(constants.AGGREGATES_CREATE_URL) + res = self.client.post(url, workflow_data) + + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, + reverse(constants.AGGREGATES_INDEX_URL)) + + @test.create_stubs({api.nova: ('host_list', 'aggregate_details_list', ), }) + def test_host_list_nova_compute(self): + + hosts = self.hosts.list() + compute_hosts = [] + + for host in hosts: + if host.service == 'compute': + compute_hosts.append(host) + + api.nova.host_list(IsA(http.HttpRequest)).AndReturn(self.hosts.list()) + + self.mox.ReplayAll() + + url = reverse(constants.AGGREGATES_CREATE_URL) + res = self.client.get(url) + workflow = res.context['workflow'] + step = workflow.get_step("add_host_to_aggregate") + field_name = step.get_member_field_name('member') + self.assertEqual(len(step.action.fields[field_name].choices), + len(compute_hosts)) + + +class AggregatesViewTests(test.BaseAdminViewTests): + + @test.create_stubs({api.nova: ('aggregate_details_list', + 'availability_zone_list',), }) + def test_index(self): + api.nova.aggregate_details_list(IsA(http.HttpRequest)) \ + .AndReturn(self.aggregates.list()) + api.nova.availability_zone_list(IsA(http.HttpRequest), detailed=True) \ + .AndReturn(self.availability_zones.list()) + self.mox.ReplayAll() + + res = self.client.get(reverse(constants.AGGREGATES_INDEX_URL)) + self.assertTemplateUsed(res, constants.AGGREGATES_INDEX_VIEW_TEMPLATE) + self.assertItemsEqual(res.context['host_aggregates_table'].data, + self.aggregates.list()) + self.assertItemsEqual(res.context['availability_zones_table'].data, + self.availability_zones.list()) + + @test.create_stubs({api.nova: ('aggregate_update', 'aggregate_get',), }) + def _test_generic_update_aggregate(self, form_data, aggregate, + error_count=0, + expected_error_message=None): + api.nova.aggregate_get(IsA(http.HttpRequest), str(aggregate.id))\ + .AndReturn(aggregate) + if not expected_error_message: + az = form_data['availability_zone'] + aggregate_data = {'name': form_data['name'], + 'availability_zone': az} + api.nova.aggregate_update(IsA(http.HttpRequest), str(aggregate.id), + aggregate_data) + self.mox.ReplayAll() + + res = self.client.post(reverse(constants.AGGREGATES_UPDATE_URL, + args=[aggregate.id]), + form_data) + + if not expected_error_message: + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, + reverse(constants.AGGREGATES_INDEX_URL)) + else: + self.assertFormErrors(res, error_count, expected_error_message) + + def test_update_aggregate(self): + aggregate = self.aggregates.first() + form_data = {'id': aggregate.id, + 'name': 'my_new_name', + 'availability_zone': 'my_new_zone'} + + self._test_generic_update_aggregate(form_data, aggregate) + + def test_update_aggregate_fails_missing_fields(self): + aggregate = self.aggregates.first() + form_data = {'id': aggregate.id} + + self._test_generic_update_aggregate(form_data, aggregate, 1, + u'This field is required') + + +class ManageHostsTests(test.BaseAdminViewTests): + + def test_manage_hosts(self): + aggregate = self.aggregates.first() + res = self.client.get(reverse(constants.AGGREGATES_MANAGE_HOSTS_URL, + args=[aggregate.id])) + self.assertEqual(res.status_code, 200) + self.assertTemplateUsed(res, + constants.AGGREGATES_MANAGE_HOSTS_TEMPLATE) + + @test.create_stubs({api.nova: ('aggregate_get', 'add_host_to_aggregate', + 'host_list')}) + def test_manage_hosts_update_empty_aggregate(self): + aggregate = self.aggregates.first() + aggregate.hosts = [] + host = self.hosts.get(service="compute") + + form_data = {'manageaggregatehostsaction_role_member': + [host.host_name]} + + api.nova.aggregate_get(IsA(http.HttpRequest), str(aggregate.id)) \ + .AndReturn(aggregate) + api.nova.host_list(IsA(http.HttpRequest)) \ + .AndReturn(self.hosts.list()) + api.nova.aggregate_get(IsA(http.HttpRequest), str(aggregate.id)) \ + .AndReturn(aggregate) + api.nova.add_host_to_aggregate(IsA(http.HttpRequest), + str(aggregate.id), host.host_name) + self.mox.ReplayAll() + + res = self.client.post(reverse(constants.AGGREGATES_MANAGE_HOSTS_URL, + args=[aggregate.id]), + form_data) + + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, + reverse(constants.AGGREGATES_INDEX_URL)) diff --git a/openstack_dashboard/dashboards/admin/aggregates/urls.py b/openstack_dashboard/dashboards/admin/aggregates/urls.py new file mode 100644 index 0000000000..2318754b75 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/urls.py @@ -0,0 +1,29 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.conf.urls import patterns # noqa +from django.conf.urls import url # noqa + +from openstack_dashboard.dashboards.admin.aggregates \ + import views + + +urlpatterns = patterns('openstack_dashboard.dashboards.admin.aggregates.views', + url(r'^$', + views.IndexView.as_view(), name='index'), + url(r'^create/$', + views.CreateView.as_view(), name='create'), + url(r'^(?P[^/]+)/update/$', + views.UpdateView.as_view(), name='update'), + url(r'^(?P[^/]+)/manage_hosts/$', + views.ManageHostsView.as_view(), name='manage_hosts'), +) diff --git a/openstack_dashboard/dashboards/admin/aggregates/views.py b/openstack_dashboard/dashboards/admin/aggregates/views.py new file mode 100644 index 0000000000..aeaf43b497 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/views.py @@ -0,0 +1,108 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tables +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.aggregates \ + import constants +from openstack_dashboard.dashboards.admin.aggregates \ + import forms as aggregate_forms +from openstack_dashboard.dashboards.admin.aggregates \ + import tables as project_tables +from openstack_dashboard.dashboards.admin.aggregates \ + import workflows as aggregate_workflows + + +INDEX_URL = constants.AGGREGATES_INDEX_URL + + +class IndexView(tables.MultiTableView): + table_classes = (project_tables.HostAggregatesTable, + project_tables.AvailabilityZonesTable) + template_name = constants.AGGREGATES_TEMPLATE_NAME + + def get_host_aggregates_data(self): + request = self.request + aggregates = [] + try: + aggregates = api.nova.aggregate_details_list(self.request) + except Exception: + exceptions.handle(request, + _('Unable to retrieve host aggregates list.')) + aggregates.sort(key=lambda aggregate: aggregate.name.lower()) + return aggregates + + def get_availability_zones_data(self): + request = self.request + availability_zones = [] + try: + availability_zones = \ + api.nova.availability_zone_list(self.request, detailed=True) + except Exception: + exceptions.handle(request, + _('Unable to retrieve availability zone list.')) + availability_zones.sort(key=lambda az: az.zoneName.lower()) + return availability_zones + + +class CreateView(workflows.WorkflowView): + workflow_class = aggregate_workflows.CreateAggregateWorkflow + template_name = constants.AGGREGATES_CREATE_VIEW_TEMPLATE + + +class UpdateView(forms.ModalFormView): + template_name = constants.AGGREGATES_UPDATE_VIEW_TEMPLATE + form_class = aggregate_forms.UpdateAggregateForm + success_url = reverse_lazy(constants.AGGREGATES_INDEX_URL) + + def get_initial(self): + aggregate = self.get_object() + return {'id': self.kwargs["id"], + 'name': aggregate.name, + 'availability_zone': aggregate.availability_zone} + + def get_context_data(self, **kwargs): + context = super(UpdateView, self).get_context_data(**kwargs) + context['id'] = self.kwargs['id'] + return context + + def get_object(self): + if not hasattr(self, "_object"): + aggregate_id = self.kwargs['id'] + try: + self._object = \ + api.nova.aggregate_get(self.request, aggregate_id) + except Exception: + msg = _('Unable to retrieve the aggregate to be updated') + exceptions.handle(self.request, msg) + return self._object + + +class ManageHostsView(workflows.WorkflowView): + template_name = constants.AGGREGATES_MANAGE_HOSTS_TEMPLATE + workflow_class = aggregate_workflows.ManageAggregateHostsWorkflow + success_url = reverse_lazy(constants.AGGREGATES_INDEX_URL) + + def get_initial(self): + return {'id': self.kwargs["id"]} + + def get_context_data(self, **kwargs): + context = super(ManageHostsView, self).get_context_data(**kwargs) + context['id'] = self.kwargs['id'] + return context diff --git a/openstack_dashboard/dashboards/admin/aggregates/workflows.py b/openstack_dashboard/dashboards/admin/aggregates/workflows.py new file mode 100644 index 0000000000..c35a87ba7f --- /dev/null +++ b/openstack_dashboard/dashboards/admin/aggregates/workflows.py @@ -0,0 +1,238 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.aggregates import constants + + +class SetAggregateInfoAction(workflows.Action): + name = forms.CharField(label=_("Name"), + max_length=255) + + availability_zone = forms.CharField(label=_("Availability Zone"), + max_length=255, + required=False) + + class Meta: + name = _("Host Aggregate Info") + help_text = _("From here you can create a new " + "host aggregate to organize instances.") + slug = "set_aggregate_info" + + def clean(self): + cleaned_data = super(SetAggregateInfoAction, self).clean() + name = cleaned_data.get('name') + + try: + aggregates = api.nova.aggregate_details_list(self.request) + except Exception: + msg = _('Unable to get host aggregate list') + exceptions.check_message(["Connection", "refused"], msg) + raise + if aggregates is not None: + for aggregate in aggregates: + if aggregate.name.lower() == name.lower(): + raise forms.ValidationError( + _('The name "%s" is already used by ' + 'another host aggregate.') + % name + ) + return cleaned_data + + +class SetAggregateInfoStep(workflows.Step): + action_class = SetAggregateInfoAction + contributes = ("availability_zone", + "name") + + +class AddHostsToAggregateAction(workflows.MembershipAction): + def __init__(self, request, *args, **kwargs): + super(AddHostsToAggregateAction, self).__init__(request, + *args, + **kwargs) + err_msg = _('Unable to get the available hosts') + + default_role_field_name = self.get_default_role_field_name() + self.fields[default_role_field_name] = forms.CharField(required=False) + self.fields[default_role_field_name].initial = 'member' + + field_name = self.get_member_field_name('member') + self.fields[field_name] = forms.MultipleChoiceField(required=False) + + hosts = [] + try: + hosts = api.nova.host_list(request) + except Exception: + exceptions.handle(request, err_msg) + + host_names = [] + for host in hosts: + if host.host_name not in host_names and host.service == u'compute': + host_names.append(host.host_name) + host_names.sort() + + self.fields[field_name].choices = \ + [(host_name, host_name) for host_name in host_names] + + class Meta: + name = _("Hosts within aggregate") + slug = "add_host_to_aggregate" + + +class ManageAggregateHostsAction(workflows.MembershipAction): + def __init__(self, request, *args, **kwargs): + super(ManageAggregateHostsAction, self).__init__(request, + *args, + **kwargs) + err_msg = _('Unable to get the available hosts') + + default_role_field_name = self.get_default_role_field_name() + self.fields[default_role_field_name] = forms.CharField(required=False) + self.fields[default_role_field_name].initial = 'member' + + field_name = self.get_member_field_name('member') + self.fields[field_name] = forms.MultipleChoiceField(required=False) + + aggregate_id = self.initial['id'] + aggregate = api.nova.aggregate_get(request, aggregate_id) + aggregate_hosts = aggregate.hosts + + hosts = [] + try: + hosts = api.nova.host_list(request) + except Exception: + exceptions.handle(request, err_msg) + + host_names = [] + for host in hosts: + if host.host_name not in host_names and host.service == u'compute': + host_names.append(host.host_name) + host_names.sort() + + self.fields[field_name].choices = \ + [(host_name, host_name) for host_name in host_names] + + self.fields[field_name].initial = aggregate_hosts + + class Meta: + name = _("Hosts within aggregate") + + +class AddHostsToAggregateStep(workflows.UpdateMembersStep): + action_class = AddHostsToAggregateAction + help_text = _("You can add hosts to this aggregate. One host can be added " + "to one or more aggregate. You can also add the hosts later " + "by editing the aggregate.") + available_list_title = _("All available hosts") + members_list_title = _("Selected hosts") + no_available_text = _("No hosts found.") + no_members_text = _("No host selected.") + show_roles = False + contributes = ("hosts_aggregate",) + + def contribute(self, data, context): + if data: + member_field_name = self.get_member_field_name('member') + context['hosts_aggregate'] = data.get(member_field_name, []) + return context + + +class ManageAggregateHostsStep(workflows.UpdateMembersStep): + action_class = ManageAggregateHostsAction + help_text = _("You can add hosts to this aggregate, as well as remove " + "hosts from it.") + available_list_title = _("All Available Hosts") + members_list_title = _("Selected Hosts") + no_available_text = _("No Hosts found.") + no_members_text = _("No Host selected.") + show_roles = False + depends_on = ("id",) + contributes = ("hosts_aggregate",) + + def contribute(self, data, context): + if data: + member_field_name = self.get_member_field_name('member') + context['hosts_aggregate'] = data.get(member_field_name, []) + return context + + +class CreateAggregateWorkflow(workflows.Workflow): + slug = "create_aggregate" + name = _("Create Host Aggregate") + finalize_button_name = _("Create Host Aggregate") + success_message = _('Created new host aggregate "%s".') + failure_message = _('Unable to create host aggregate "%s".') + success_url = constants.AGGREGATES_INDEX_URL + default_steps = (SetAggregateInfoStep, AddHostsToAggregateStep) + + def format_status_message(self, message): + return message % self.context['name'] + + def handle(self, request, context): + try: + self.object = \ + api.nova.aggregate_create( + request, + name=context['name'], + availability_zone=context['availability_zone']) + except Exception: + exceptions.handle(request, _('Unable to create host aggregate.')) + return False + + hosts = context['hosts_aggregate'] + for host in hosts: + try: + api.nova.add_host_to_aggregate(request, self.object.id, host) + except Exception: + exceptions.handle( + request, _('Error adding Hosts to the aggregate.')) + return False + + return True + + +class ManageAggregateHostsWorkflow(workflows.Workflow): + slug = "manage_hosts_aggregate" + name = _("Add/Remove Hosts to Aggregate") + finalize_button_name = _("Save") + success_message = _('The Aggregate was updated.') + failure_message = _('Unable to update the aggregate.') + success_url = constants.AGGREGATES_INDEX_URL + default_steps = (ManageAggregateHostsStep, ) + + def format_status_message(self, message): + return message + + def handle(self, request, context): + hosts_aggregate = context['hosts_aggregate'] + aggregate_id = context['id'] + aggregate = api.nova.aggregate_get(request, aggregate_id) + aggregate_hosts = aggregate.hosts + for host in aggregate_hosts: + api.nova.remove_host_from_aggregate(request, aggregate_id, host) + + for host in hosts_aggregate: + try: + api.nova.add_host_to_aggregate(request, aggregate_id, host) + except Exception: + exceptions.handle( + request, _('Error updating the aggregate.')) + return False + + return True diff --git a/openstack_dashboard/dashboards/admin/dashboard.py b/openstack_dashboard/dashboards/admin/dashboard.py index 06c9d9d3a8..a4ad7a588f 100644 --- a/openstack_dashboard/dashboards/admin/dashboard.py +++ b/openstack_dashboard/dashboards/admin/dashboard.py @@ -22,8 +22,9 @@ import horizon class SystemPanels(horizon.PanelGroup): slug = "admin" name = _("System Panel") - panels = ('overview', 'metering', 'hypervisors', 'instances', 'volumes', - 'flavors', 'images', 'networks', 'routers', 'defaults', 'info') + panels = ('overview', 'metering', 'hypervisors', 'aggregates', + 'instances', 'volumes', 'flavors', 'images', + 'networks', 'routers', 'defaults', 'info') class IdentityPanels(horizon.PanelGroup): diff --git a/openstack_dashboard/dashboards/admin/info/constants.py b/openstack_dashboard/dashboards/admin/info/constants.py new file mode 100644 index 0000000000..f88419dd21 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/info/constants.py @@ -0,0 +1,17 @@ +# Copyright 2014 Intel Corporation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +INFO_TEMPLATE_NAME = 'admin/info/index.html' +INFO_DETAIL_TEMPLATE_NAME = 'horizon/common/_detail_table.html' diff --git a/openstack_dashboard/dashboards/admin/info/tables.py b/openstack_dashboard/dashboards/admin/info/tables.py index 953c9a3a3e..3250395adc 100644 --- a/openstack_dashboard/dashboards/admin/info/tables.py +++ b/openstack_dashboard/dashboards/admin/info/tables.py @@ -66,37 +66,6 @@ def get_available(zone): return zone.zoneState['available'] -def get_zone_hosts(zone): - hosts = zone.hosts - host_details = [] - for name, services in hosts.items(): - up = all([s['active'] and s['available'] for k, s in services.items()]) - up = _("Services Up") if up else _("Services Down") - host_details.append("%(host)s (%(up)s)" % {'host': name, 'up': up}) - return host_details - - -class ZonesTable(tables.DataTable): - name = tables.Column('zoneName', verbose_name=_('Name')) - hosts = tables.Column(get_zone_hosts, - verbose_name=_('Hosts'), - wrap_list=True, - filters=(filters.unordered_list,)) - available = tables.Column(get_available, - verbose_name=_('Available'), - status=True, - filters=(filters.yesno, filters.capfirst)) - - def get_object_id(self, zone): - return zone.zoneName - - class Meta: - name = "zones" - verbose_name = _("Availability Zones") - multi_select = False - status_columns = ["available"] - - class NovaServiceFilterAction(tables.FilterAction): def filter(self, table, services, filter_string): q = filter_string.lower() @@ -130,34 +99,6 @@ class NovaServicesTable(tables.DataTable): multi_select = False -def get_aggregate_hosts(aggregate): - return [host for host in aggregate.hosts] - - -def get_metadata(aggregate): - return [' = '.join([key, val]) for key, val - in aggregate.metadata.iteritems()] - - -class AggregatesTable(tables.DataTable): - name = tables.Column("name", - verbose_name=_("Name")) - availability_zone = tables.Column("availability_zone", - verbose_name=_("Availability Zone")) - hosts = tables.Column(get_aggregate_hosts, - verbose_name=_("Hosts"), - wrap_list=True, - filters=(filters.unordered_list,)) - metadata = tables.Column(get_metadata, - verbose_name=_("Metadata"), - wrap_list=True, - filters=(filters.unordered_list,)) - - class Meta: - name = "aggregates" - verbose_name = _("Host Aggregates") - - class NetworkAgentsFilterAction(tables.FilterAction): def filter(self, table, agents, filter_string): q = filter_string.lower() diff --git a/openstack_dashboard/dashboards/admin/info/tabs.py b/openstack_dashboard/dashboards/admin/info/tabs.py index 0595f91450..e33b1536c8 100644 --- a/openstack_dashboard/dashboards/admin/info/tabs.py +++ b/openstack_dashboard/dashboards/admin/info/tabs.py @@ -1,5 +1,3 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - # Copyright 2012 Nebula, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -24,6 +22,7 @@ from openstack_dashboard.api import keystone from openstack_dashboard.api import neutron from openstack_dashboard.api import nova +from openstack_dashboard.dashboards.admin.info import constants from openstack_dashboard.dashboards.admin.info import tables @@ -31,7 +30,7 @@ class ServicesTab(tabs.TableTab): table_classes = (tables.ServicesTable,) name = _("Services") slug = "services" - template_name = ("horizon/common/_detail_table.html") + template_name = constants.INFO_DETAIL_TEMPLATE_NAME def get_services_data(self): request = self.tab_group.request @@ -43,50 +42,16 @@ class ServicesTab(tabs.TableTab): return services -class ZonesTab(tabs.TableTab): - table_classes = (tables.ZonesTable,) - name = _("Availability Zones") - slug = "zones" - template_name = ("horizon/common/_detail_table.html") - - def get_zones_data(self): - request = self.tab_group.request - zones = [] - try: - zones = nova.availability_zone_list(request, detailed=True) - except Exception: - msg = _('Unable to retrieve availability zone data.') - exceptions.handle(request, msg) - return zones - - -class HostAggregatesTab(tabs.TableTab): - table_classes = (tables.AggregatesTable,) - name = _("Host Aggregates") - slug = "aggregates" - template_name = ("horizon/common/_detail_table.html") - - def get_aggregates_data(self): - aggregates = [] - try: - aggregates = nova.aggregate_list(self.tab_group.request) - except Exception: - exceptions.handle(self.request, - _('Unable to retrieve host aggregates list.')) - return aggregates - - class NovaServicesTab(tabs.TableTab): table_classes = (tables.NovaServicesTable,) name = _("Compute Services") slug = "nova_services" - template_name = ("horizon/common/_detail_table.html") + template_name = constants.INFO_DETAIL_TEMPLATE_NAME def get_nova_services_data(self): try: services = nova.service_list(self.tab_group.request) except Exception: - services = [] msg = _('Unable to get nova services list.') exceptions.check_message(["Connection", "refused"], msg) raise @@ -98,7 +63,7 @@ class NetworkAgentsTab(tabs.TableTab): table_classes = (tables.NetworkAgentsTable,) name = _("Network Agents") slug = "network_agents" - template_name = ("horizon/common/_detail_table.html") + template_name = constants.INFO_DETAIL_TEMPLATE_NAME def allowed(self, request): return base.is_service_enabled(request, 'network') @@ -107,7 +72,6 @@ class NetworkAgentsTab(tabs.TableTab): try: agents = neutron.agent_list(self.tab_group.request) except Exception: - agents = [] msg = _('Unable to get network agents list.') exceptions.check_message(["Connection", "refused"], msg) raise @@ -118,6 +82,5 @@ class NetworkAgentsTab(tabs.TableTab): class SystemInfoTabs(tabs.TabGroup): slug = "system_info" tabs = (ServicesTab, NovaServicesTab, - ZonesTab, HostAggregatesTab, NetworkAgentsTab) sticky = True diff --git a/openstack_dashboard/dashboards/admin/info/tests.py b/openstack_dashboard/dashboards/admin/info/tests.py index b5bcb56bde..655ecd4e64 100644 --- a/openstack_dashboard/dashboards/admin/info/tests.py +++ b/openstack_dashboard/dashboards/admin/info/tests.py @@ -26,17 +26,11 @@ INDEX_URL = reverse('horizon:admin:info:index') class SystemInfoViewTests(test.BaseAdminViewTests): - @test.create_stubs({api.nova: ('service_list', - 'availability_zone_list', - 'aggregate_list'), + @test.create_stubs({api.nova: ('service_list',), api.neutron: ('agent_list',)}) def test_index(self): services = self.services.list() api.nova.service_list(IsA(http.HttpRequest)).AndReturn(services) - api.nova.availability_zone_list(IsA(http.HttpRequest), detailed=True) \ - .AndReturn(self.availability_zones.list()) - api.nova.aggregate_list(IsA(http.HttpRequest)) \ - .AndReturn(self.aggregates.list()) agents = self.agents.list() api.neutron.agent_list(IsA(http.HttpRequest)).AndReturn(agents) @@ -59,14 +53,6 @@ class SystemInfoViewTests(test.BaseAdminViewTests): '', '']) - zones_tab = res.context['tab_group'].get_tab('zones') - self.assertQuerysetEqual(zones_tab._tables['zones'].data, - ['']) - - aggregates_tab = res.context['tab_group'].get_tab('aggregates') - self.assertQuerysetEqual(aggregates_tab._tables['aggregates'].data, - ['', '']) - network_agents_tab = res.context['tab_group'].get_tab('network_agents') self.assertQuerysetEqual( network_agents_tab._tables['network_agents'].data, diff --git a/openstack_dashboard/dashboards/admin/info/views.py b/openstack_dashboard/dashboards/admin/info/views.py index fca4f27a4e..29b9bfc53d 100644 --- a/openstack_dashboard/dashboards/admin/info/views.py +++ b/openstack_dashboard/dashboards/admin/info/views.py @@ -1,5 +1,3 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - # Copyright 2012 United States Government as represented by the # Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. @@ -20,9 +18,10 @@ from horizon import tabs +from openstack_dashboard.dashboards.admin.info import constants from openstack_dashboard.dashboards.admin.info import tabs as project_tabs class IndexView(tabs.TabbedTableView): tab_group_class = project_tabs.SystemInfoTabs - template_name = 'admin/info/index.html' + template_name = constants.INFO_TEMPLATE_NAME diff --git a/openstack_dashboard/test/test_data/nova_data.py b/openstack_dashboard/test/test_data/nova_data.py index e15b28876d..7cd0eadb78 100644 --- a/openstack_dashboard/test/test_data/nova_data.py +++ b/openstack_dashboard/test/test_data/nova_data.py @@ -21,6 +21,7 @@ from novaclient.v1_1 import certs from novaclient.v1_1 import flavor_access from novaclient.v1_1 import flavors from novaclient.v1_1 import floating_ips +from novaclient.v1_1 import hosts from novaclient.v1_1 import hypervisors from novaclient.v1_1 import keypairs from novaclient.v1_1 import quotas @@ -170,6 +171,7 @@ def data(TEST): TEST.hypervisors = utils.TestDataContainer() TEST.services = utils.TestDataContainer() TEST.aggregates = utils.TestDataContainer() + TEST.hosts = utils.TestDataContainer() # Data return by novaclient. # It is used if API layer does data conversion. @@ -616,7 +618,7 @@ def data(TEST): aggregate_1 = aggregates.Aggregate(aggregates.AggregateManager(None), { "name": "foo", - "availability_zone": None, + "availability_zone": "testing", "deleted": 0, "created_at": "2013-07-04T13:34:38.000000", "updated_at": None, @@ -649,3 +651,22 @@ def data(TEST): TEST.aggregates.add(aggregate_1) TEST.aggregates.add(aggregate_2) + + host1 = hosts.Host(hosts.HostManager(None), + { + "host_name": "devstack001", + "service": "compute", + "zone": "testing" + } + ) + + host2 = hosts.Host(hosts.HostManager(None), + { + "host_name": "devstack002", + "service": "nova-conductor", + "zone": "testing" + } + ) + + TEST.hosts.add(host1) + TEST.hosts.add(host2)