From: Volker Theile Date: Thu, 4 Jun 2020 13:57:34 +0000 (+0200) Subject: mgr/dashboard: Add host labels in UI X-Git-Tag: wip-pdonnell-testing-20200918.022351~863^2~1 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=06bad617a0866774b610dc0af06423604738f81b;p=ceph-ci.git mgr/dashboard: Add host labels in UI Fixes: https://tracker.ceph.com/issues/45897 Signed-off-by: Volker Theile s s s --- diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index 5995161bee1..0426708e7e2 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -1,12 +1,16 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import + import copy -from typing import List +from typing import List, Dict + +import cherrypy from mgr_util import merge_dicts from orchestrator import HostSpec -from . import ApiController, RESTController, Task +from . import ApiController, RESTController, Task, Endpoint, ReadPermission, \ + UiApiController, BaseController from .orchestrator import raise_if_no_orchestrator from .. import mgr from ..exceptions import DashboardException @@ -22,7 +26,8 @@ def host_task(name, metadata, wait_for=10.0): def merge_hosts_by_hostname(ceph_hosts, orch_hosts): # type: (List[dict], List[HostSpec]) -> List[dict] - """Merge Ceph hosts with orchestrator hosts by hostnames. + """ + Merge Ceph hosts with orchestrator hosts by hostnames. :param ceph_hosts: hosts returned from mgr :type ceph_hosts: list of dict @@ -31,31 +36,31 @@ def merge_hosts_by_hostname(ceph_hosts, orch_hosts): :return list of dict """ hosts = copy.deepcopy(ceph_hosts) - orch_hosts_map = { - host.hostname: { - 'labels': host.labels - } - for host in orch_hosts - } - - # Hosts in both Ceph and Orchestrator + orch_hosts_map = {host.hostname: host.to_json() for host in orch_hosts} + + # Sort labels. + for hostname in orch_hosts_map: + orch_hosts_map[hostname]['labels'].sort() + + # Hosts in both Ceph and Orchestrator. for host in hosts: hostname = host['hostname'] if hostname in orch_hosts_map: - host['labels'] = orch_hosts_map[hostname]['labels'] + host = merge_dicts(host, orch_hosts_map[hostname]) host['sources']['orchestrator'] = True orch_hosts_map.pop(hostname) - # Hosts only in Orchestrator + # Hosts only in Orchestrator. orch_hosts_only = [ - dict(hostname=hostname, - ceph_version='', - labels=orch_hosts_map[hostname]['labels'], - services=[], - sources={ - 'ceph': False, - 'orchestrator': True - }) for hostname in orch_hosts_map + merge_dicts( + { + 'ceph_version': '', + 'services': [], + 'sources': { + 'ceph': False, + 'orchestrator': True + } + }, orch_hosts_map[hostname]) for hostname in orch_hosts_map ] hosts.extend(orch_hosts_only) return hosts @@ -68,13 +73,17 @@ def get_hosts(from_ceph=True, from_orchestrator=True): ceph_hosts = [] if from_ceph: ceph_hosts = [ - merge_dicts(server, { - 'labels': [], - 'sources': { - 'ceph': True, - 'orchestrator': False - } - }) for server in mgr.list_servers() + merge_dicts( + server, { + 'addr': '', + 'labels': [], + 'service_type': '', + 'sources': { + 'ceph': True, + 'orchestrator': False + }, + 'status': '' + }) for server in mgr.list_servers() ] if from_orchestrator: orch = OrchClient.instance() @@ -83,6 +92,18 @@ def get_hosts(from_ceph=True, from_orchestrator=True): return ceph_hosts +def get_host(hostname: str) -> Dict: + """ + Get a specific host from Ceph or Orchestrator (if available). + :param hostname: The name of the host to fetch. + :raises: cherrypy.HTTPError: If host not found. + """ + for host in get_hosts(): + if host['hostname'] == hostname: + return host + raise cherrypy.HTTPError(404) + + @ApiController('/host', Scope.HOSTS) class Host(RESTController): def list(self, sources=None): @@ -145,3 +166,53 @@ class Host(RESTController): orch = OrchClient.instance() daemons = orch.services.list_daemons(None, hostname) return [d.to_json() for d in daemons] + + @handle_orchestrator_error('host') + def get(self, hostname: str) -> Dict: + """ + Get the specified host. + :raises: cherrypy.HTTPError: If host not found. + """ + return get_host(hostname) + + @raise_if_no_orchestrator + @handle_orchestrator_error('host') + def set(self, hostname: str, labels: List[str]): + """ + Update the specified host. + Note, this is only supported when Ceph Orchestrator is enabled. + :param hostname: The name of the host to be processed. + :param labels: List of labels. + """ + orch = OrchClient.instance() + host = get_host(hostname) + current_labels = set(host['labels']) + # Remove labels. + remove_labels = list(current_labels.difference(set(labels))) + for label in remove_labels: + orch.hosts.remove_label(hostname, label) + # Add labels. + add_labels = list(set(labels).difference(current_labels)) + for label in add_labels: + orch.hosts.add_label(hostname, label) + + +@UiApiController('/host', Scope.HOSTS) +class HostUi(BaseController): + @Endpoint('GET') + @ReadPermission + @handle_orchestrator_error('host') + def labels(self) -> List[str]: + """ + Get all host labels. + Note, host labels are only supported when Ceph Orchestrator is enabled. + If Ceph Orchestrator is not enabled, an empty list is returned. + :return: A list of all host labels. + """ + labels = [] + orch = OrchClient.instance() + if orch.available(): + for host in orch.hosts.list(): + labels.extend(host.labels) + labels.sort() + return list(set(labels)) # Filter duplicate labels. diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts index cb103afab6d..9cc62f48b3d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts @@ -88,4 +88,32 @@ describe('HostsComponent', () => { expect(spans[0].textContent).toBe(hostname); }); })); + + describe('getEditDisableDesc', () => { + it('should return message (not managed by Orchestrator)', () => { + component.selection.add({ + sources: { + ceph: true, + orchestrator: false + } + }); + expect(component.getEditDisableDesc(component.selection)).toBe( + 'Host editing is disabled because the host is not managed by Orchestrator.' + ); + }); + + it('should return undefined (no selection)', () => { + expect(component.getEditDisableDesc()).toBeUndefined(); + }); + + it('should return undefined (managed by Orchestrator)', () => { + component.selection.add({ + sources: { + ceph: false, + orchestrator: true + } + }); + expect(component.getEditDisableDesc()).toBeUndefined(); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index 91e9faa7f2c..50b04947c0d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -7,8 +7,12 @@ import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import { HostService } from '../../../shared/api/host.service'; import { ListWithDetails } from '../../../shared/classes/list-with-details.class'; import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component'; +import { SelectMessages } from '../../../shared/components/select/select-messages.model'; import { ActionLabelsI18n } from '../../../shared/constants/app.constants'; +import { TableComponent } from '../../../shared/datatable/table/table.component'; import { Icons } from '../../../shared/enum/icons.enum'; +import { NotificationType } from '../../../shared/enum/notification-type.enum'; import { CdTableAction } from '../../../shared/models/cd-table-action'; import { CdTableColumn } from '../../../shared/models/cd-table-column'; import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context'; @@ -19,6 +23,7 @@ import { CephShortVersionPipe } from '../../../shared/pipes/ceph-short-version.p import { JoinPipe } from '../../../shared/pipes/join.pipe'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; import { DepCheckerService } from '../../../shared/services/dep-checker.service'; +import { NotificationService } from '../../../shared/services/notification.service'; import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; import { URLBuilderService } from '../../../shared/services/url-builder.service'; @@ -31,6 +36,11 @@ const BASE_URL = 'hosts'; providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] }) export class HostsComponent extends ListWithDetails implements OnInit { + @ViewChild(TableComponent, { static: true }) + table: TableComponent; + @ViewChild('servicesTpl', { static: true }) + public servicesTpl: TemplateRef; + permissions: Permissions; columns: Array = []; hosts: Array = []; @@ -40,9 +50,6 @@ export class HostsComponent extends ListWithDetails implements OnInit { selection = new CdTableSelection(); modalRef: BsModalRef; - @ViewChild('servicesTpl', { static: true }) - public servicesTpl: TemplateRef; - constructor( private authStorageService: AuthStorageService, private hostService: HostService, @@ -54,7 +61,8 @@ export class HostsComponent extends ListWithDetails implements OnInit { private modalService: BsModalService, private taskWrapper: TaskWrapperService, private router: Router, - private depCheckerService: DepCheckerService + private depCheckerService: DepCheckerService, + private notificationService: NotificationService ) { super(); this.permissions = this.authStorageService.getPermissions(); @@ -73,6 +81,21 @@ export class HostsComponent extends ListWithDetails implements OnInit { ); } }, + { + name: this.actionLabels.EDIT, + permission: 'update', + icon: Icons.edit, + click: () => { + this.depCheckerService.checkOrchestratorOrModal( + this.actionLabels.EDIT, + this.i18n('Host'), + () => this.editAction() + ); + }, + disable: (selection: CdTableSelection) => + !selection.hasSingleSelection || !selection.first().sources.orchestrator, + disableDesc: () => this.getEditDisableDesc() + }, { name: this.actionLabels.DELETE, permission: 'delete', @@ -81,7 +104,7 @@ export class HostsComponent extends ListWithDetails implements OnInit { this.depCheckerService.checkOrchestratorOrModal( this.actionLabels.DELETE, this.i18n('Host'), - () => this.deleteHostModal() + () => this.deleteAction() ); }, disable: () => !this.selection.hasSelection @@ -121,7 +144,63 @@ export class HostsComponent extends ListWithDetails implements OnInit { this.selection = selection; } - deleteHostModal() { + editAction() { + this.hostService.getLabels().subscribe((resp: string[]) => { + const host = this.selection.first(); + const allLabels = resp.map((label) => { + return { enabled: true, name: label }; + }); + this.modalService.show(FormModalComponent, { + initialState: { + titleText: this.i18n('Edit Host: {{hostname}}', host), + fields: [ + { + type: 'select-badges', + name: 'labels', + value: host['labels'], + label: this.i18n('Labels'), + typeConfig: { + customBadges: true, + options: allLabels, + messages: new SelectMessages( + { + empty: this.i18n('There are no labels.'), + filter: this.i18n('Filter or add labels'), + add: this.i18n('Add label') + }, + this.i18n + ) + } + } + ], + submitButtonText: this.i18n('Edit Host'), + onSubmit: (values: any) => { + this.hostService.update(host['hostname'], values.labels).subscribe(() => { + this.notificationService.show( + NotificationType.success, + this.i18n('Updated Host "{{hostname}}"', host) + ); + // Reload the data table content. + this.table.refreshBtn(); + }); + } + } + }); + }); + } + + getEditDisableDesc(): string | undefined { + if ( + this.selection && + this.selection.hasSingleSelection && + !this.selection.first().sources.orchestrator + ) { + return this.i18n('Host editing is disabled because the host is not managed by Orchestrator.'); + } + return undefined; + } + + deleteAction() { const hostname = this.selection.first().hostname; this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { initialState: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts index 19d6e5cce08..1a48903510c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts @@ -193,13 +193,15 @@ export class InventoryDevicesComponent implements OnInit, OnDestroy { name: 'duration', value: 300, required: true, - options: [ - { text: this.i18n('1 minute'), value: 60 }, - { text: this.i18n('2 minutes'), value: 120 }, - { text: this.i18n('5 minutes'), value: 300 }, - { text: this.i18n('10 minutes'), value: 600 }, - { text: this.i18n('15 minutes'), value: 900 } - ] + typeConfig: { + options: [ + { text: this.i18n('1 minute'), value: 60 }, + { text: this.i18n('2 minutes'), value: 120 }, + { text: this.i18n('5 minutes'), value: 300 }, + { text: this.i18n('10 minutes'), value: 600 }, + { text: this.i18n('15 minutes'), value: 900 } + ] + } } ], submitButtonText: this.i18n('Execute'), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts index 1b80b3f7788..d4a36700e65 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts @@ -42,4 +42,11 @@ describe('HostService', () => { const req = httpTesting.expectOne(`api/host/${hostname}/devices`); expect(req.request.method).toBe('GET'); }); + + it('should update host', fakeAsync(() => { + service.update('mon0', ['foo', 'bar']).subscribe(); + const req = httpTesting.expectOne('api/host/mon0'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ labels: ['foo', 'bar'] }); + })); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts index fef43950cc7..f016f2c11da 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -42,4 +42,12 @@ export class HostService { getDaemons(hostname: string): Observable { return this.http.get(`${this.baseURL}/${hostname}/daemons`); } + + getLabels(): Observable { + return this.http.get('ui-api/host/labels'); + } + + update(hostname: string, labels: string[]) { + return this.http.put(`${this.baseURL}/${hostname}`, { labels: labels }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html index b7ce9aceab5..2ccb7893f64 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html @@ -13,6 +13,7 @@
@@ -34,15 +35,22 @@ class="form-control custom-select" [id]="field.name" [formControlName]="field.name"> - - + + {{ getError(field) }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html index 5f5712c7bfe..955f4f4e0bd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html @@ -63,16 +63,16 @@ + *ngIf="customBadges || options.length > 0"> + *ngIf="data.length === 0 && !(!customBadges && options.length === 0)"> {{ messages.empty }} + *ngIf="!customBadges && options.length === 0"> {{ messages.noOptions }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts index 7b2fe3f9412..e327be59a27 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts @@ -1,19 +1,32 @@ import { ValidatorFn } from '@angular/forms'; export class CdFormModalFieldConfig { + // --- Generic field properties --- name: string; // 'binary' will use cdDimlessBinary directive on input element // 'select' will use select element - type: 'number' | 'text' | 'binary' | 'select'; + type: 'number' | 'text' | 'binary' | 'select' | 'select-badges'; label?: string; required?: boolean; value?: any; errors?: { [errorName: string]: string }; validators: ValidatorFn[]; - // only for type select - placeholder?: string; - options?: Array<{ - text: string; - value: any; - }>; + + // --- Specific field properties --- + typeConfig?: { + [prop: string]: any; + // 'select': + // --------- + // placeholder?: string; + // options?: Array<{ + // text: string; + // value: any; + // }>; + // + // 'select-badges': + // ---------------- + // customBadges: boolean; + // options: Array; + // messages: SelectMessages; + }; } diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index cb394b8816e..ea33b9a37ee 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -66,6 +66,14 @@ class HostManger(ResourceManager): def remove(self, hostname: str): return self.api.remove_host(hostname) + @wait_api_result + def add_label(self, host: str, label: str) -> Completion: + return self.api.add_host_label(host, label) + + @wait_api_result + def remove_label(self, host: str, label: str) -> Completion: + return self.api.remove_host_label(host, label) + class InventoryManager(ResourceManager): @wait_api_result diff --git a/src/pybind/mgr/dashboard/tests/test_host.py b/src/pybind/mgr/dashboard/tests/test_host.py index c2ce6069380..17683232bb6 100644 --- a/src/pybind/mgr/dashboard/tests/test_host.py +++ b/src/pybind/mgr/dashboard/tests/test_host.py @@ -8,7 +8,7 @@ except ImportError: from orchestrator import HostSpec from . import ControllerTestCase -from ..controllers.host import get_hosts, Host +from ..controllers.host import get_hosts, Host, HostUi from .. import mgr @@ -23,26 +23,25 @@ class HostControllerTest(ControllerTestCase): @mock.patch('dashboard.controllers.host.get_hosts') def test_host_list(self, mock_get_hosts): - hosts = [ - { - 'hostname': 'host-0', - 'sources': { - 'ceph': True, 'orchestrator': False - } - }, - { - 'hostname': 'host-1', - 'sources': { - 'ceph': False, 'orchestrator': True - } - }, - { - 'hostname': 'host-2', - 'sources': { - 'ceph': True, 'orchestrator': True - } + hosts = [{ + 'hostname': 'host-0', + 'sources': { + 'ceph': True, + 'orchestrator': False } - ] + }, { + 'hostname': 'host-1', + 'sources': { + 'ceph': False, + 'orchestrator': True + } + }, { + 'hostname': 'host-2', + 'sources': { + 'ceph': True, + 'orchestrator': True + } + }] def _get_hosts(from_ceph=True, from_orchestrator=True): _hosts = [] @@ -52,6 +51,7 @@ class HostControllerTest(ControllerTestCase): _hosts.append(hosts[1]) _hosts.append(hosts[2]) return _hosts + mock_get_hosts.side_effect = _get_hosts self._get(self.URL_HOST) @@ -70,25 +70,118 @@ class HostControllerTest(ControllerTestCase): self.assertStatus(200) self.assertJsonBody(hosts) + @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') + def test_get_1(self, instance): + mgr.list_servers.return_value = [] -class TestHosts(unittest.TestCase): + fake_client = mock.Mock() + fake_client.available.return_value = False + instance.return_value = fake_client + + self._get('{}/node1'.format(self.URL_HOST)) + self.assertStatus(404) + @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') + def test_get_2(self, instance): + mgr.list_servers.return_value = [{'hostname': 'node1'}] + + fake_client = mock.Mock() + fake_client.available.return_value = False + instance.return_value = fake_client + + self._get('{}/node1'.format(self.URL_HOST)) + self.assertStatus(200) + self.assertIn('labels', self.json_body()) + + @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') + def test_get_3(self, instance): + mgr.list_servers.return_value = [] + + fake_client = mock.Mock() + fake_client.available.return_value = True + fake_client.hosts.list.return_value = [HostSpec('node1')] + instance.return_value = fake_client + + self._get('{}/node1'.format(self.URL_HOST)) + self.assertStatus(200) + self.assertIn('labels', self.json_body()) + + @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') + def test_set_labels(self, instance): + mgr.list_servers.return_value = [] + + fake_client = mock.Mock() + fake_client.available.return_value = True + fake_client.hosts.list.return_value = [ + HostSpec('node0', labels=['aaa', 'bbb']) + ] + fake_client.hosts.remove_label = mock.Mock() + fake_client.hosts.add_label = mock.Mock() + instance.return_value = fake_client + + self._put('{}/node0'.format(self.URL_HOST), {'labels': ['bbb', 'ccc']}) + self.assertStatus(200) + fake_client.hosts.remove_label.assert_called_once_with('node0', 'aaa') + fake_client.hosts.add_label.assert_called_once_with('node0', 'ccc') + + +class HostUiControllerTest(ControllerTestCase): + URL_HOST = '/ui-api/host' + + @classmethod + def setup_server(cls): + # pylint: disable=protected-access + HostUi._cp_config['tools.authenticate.on'] = False + cls.setup_controllers([HostUi]) + + @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') + def test_labels(self, instance): + fake_client = mock.Mock() + fake_client.available.return_value = True + fake_client.hosts.list.return_value = [ + HostSpec('node1', labels=['foo']), + HostSpec('node2', labels=['foo', 'bar']) + ] + instance.return_value = fake_client + + self._get('{}/labels'.format(self.URL_HOST)) + self.assertStatus(200) + labels = self.json_body() + labels.sort() + self.assertListEqual(labels, ['bar', 'foo']) + + +class TestHosts(unittest.TestCase): @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') def test_get_hosts(self, instance): - mgr.list_servers.return_value = [{'hostname': 'node1'}, {'hostname': 'localhost'}] + mgr.list_servers.return_value = [{ + 'hostname': 'node1' + }, { + 'hostname': 'localhost' + }] fake_client = mock.Mock() fake_client.available.return_value = True fake_client.hosts.list.return_value = [ - HostSpec('node1'), HostSpec('node2')] + HostSpec('node1'), HostSpec('node2') + ] instance.return_value = fake_client hosts = get_hosts() self.assertEqual(len(hosts), 3) check_sources = { - 'localhost': {'ceph': True, 'orchestrator': False}, - 'node1': {'ceph': True, 'orchestrator': True}, - 'node2': {'ceph': False, 'orchestrator': True} + 'localhost': { + 'ceph': True, + 'orchestrator': False + }, + 'node1': { + 'ceph': True, + 'orchestrator': True + }, + 'node2': { + 'ceph': False, + 'orchestrator': True + } } for host in hosts: hostname = host['hostname']