From 0780fc131d1484601318eb0ec39f14955c731e16 Mon Sep 17 00:00:00 2001 From: Nizamudeen A Date: Tue, 2 Feb 2021 17:56:13 +0530 Subject: [PATCH] mgr/dashboard: Host Maintenance Feature In Cluster -> Hosts, I've added additional button to put the selected host on maintenance or exit out of the maintenance mode. Also for some hosts the ok-to-stop tests may trigger some warnings which requires a --force command to pass along with the maintenance enter command to enter a host into maintenance. In UI this is achieved using a confirmation Modal. In addition to this if the check error is It is NOT safe to stop the host then the host wont be able to put into maintenance mode. Fixes: https://tracker.ceph.com/issues/49101 Signed-off-by: Nizamudeen A --- src/pybind/mgr/dashboard/controllers/host.py | 55 ++++++--- .../cypress/integration/cluster/hosts.po.ts | 43 ++++++- .../orchestrator/01-hosts.e2e-spec.ts | 10 ++ .../ceph/cluster/hosts/hosts.component.html | 10 ++ .../app/ceph/cluster/hosts/hosts.component.ts | 115 +++++++++++++++++- .../src/app/shared/api/host.service.spec.ts | 9 +- .../src/app/shared/api/host.service.ts | 15 ++- .../confirmation-modal.component.html | 9 +- .../confirmation-modal.component.ts | 2 + .../notifications-sidebar.component.scss | 4 + .../src/app/shared/constants/app.constants.ts | 4 + .../table-actions.component.html | 1 - .../src/app/shared/enum/icons.enum.ts | 2 + .../app/shared/models/orchestrator.enum.ts | 2 + src/pybind/mgr/dashboard/openapi.yaml | 20 ++- .../mgr/dashboard/services/orchestrator.py | 10 ++ src/pybind/mgr/dashboard/tests/test_host.py | 32 ++++- 17 files changed, 308 insertions(+), 35 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index d0129777d15..aeb4b437428 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -373,38 +373,59 @@ class Host(RESTController): """ return get_host(hostname) - @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD, OrchFeature.HOST_LABEL_REMOVE]) + @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD, + OrchFeature.HOST_LABEL_REMOVE, + OrchFeature.HOST_MAINTENANCE_ENTER, + OrchFeature.HOST_MAINTENANCE_EXIT]) @handle_orchestrator_error('host') @EndpointDoc('', parameters={ 'hostname': (str, 'Hostname'), + 'update_labels': (bool, 'Update Labels'), 'labels': ([str], 'Host Labels'), + 'maintenance': (bool, 'Enter/Exit Maintenance'), + 'force': (bool, 'Force Enter Maintenance') }, responses={200: None, 204: None}) - def set(self, hostname: str, labels: List[str]): + def set(self, hostname: str, update_labels: bool = False, + labels: List[str] = None, maintenance: bool = False, + force: bool = False): """ 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 update_labels: To update the labels. :param labels: List of labels. + :param maintenance: Enter/Exit maintenance mode. + :param force: Force enter maintenance mode. """ orch = OrchClient.instance() host = get_host(hostname) - # only allow List[str] type for labels - if not isinstance(labels, list): - raise DashboardException( - msg='Expected list of labels. Please check API documentation.', - http_status_code=400, - component='orchestrator') - 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) + + if maintenance: + status = host['status'] + if status != 'maintenance': + orch.hosts.enter_maintenance(hostname, force) + + if status == 'maintenance': + orch.hosts.exit_maintenance(hostname) + + if update_labels: + # only allow List[str] type for labels + if not isinstance(labels, list): + raise DashboardException( + msg='Expected list of labels. Please check API documentation.', + http_status_code=400, + component='orchestrator') + 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) diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts index ae7b901f44e..0785f510727 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts @@ -10,7 +10,9 @@ export class HostsPageHelper extends PageHelper { columnIndex = { hostname: 2, - labels: 4 + services: 3, + labels: 4, + status: 5 }; check_for_host() { @@ -116,4 +118,43 @@ export class HostsPageHelper extends PageHelper { } }); } + + @PageHelper.restrictTo(pages.index.url) + maintenance(hostname: string, exit = false) { + let services: string[]; + let runTest = false; + this.getTableCell(this.columnIndex.hostname, hostname) + .parent() + .find(`datatable-body-cell:nth-child(${this.columnIndex.services}) a`) + .should(($el) => { + services = $el.text().split(', '); + if (services.length < 2 && services[0].includes('osd')) { + runTest = true; + } + }); + if (runTest) { + this.getTableCell(this.columnIndex.hostname, hostname).click(); + if (exit) { + this.clickActionButton('exit-maintenance'); + + this.getTableCell(this.columnIndex.hostname, hostname) + .parent() + .find(`datatable-body-cell:nth-child(${this.columnIndex.status}) .badge`) + .should(($ele) => { + const status = $ele.toArray().map((v) => v.innerText); + expect(status).to.not.include('maintenance'); + }); + } else { + this.clickActionButton('enter-maintenance'); + + this.getTableCell(this.columnIndex.hostname, hostname) + .parent() + .find(`datatable-body-cell:nth-child(${this.columnIndex.status}) .badge`) + .should(($ele) => { + const status = $ele.toArray().map((v) => v.innerText); + expect(status).to.include('maintenance'); + }); + } + } + } } diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts index 3e7e6733312..473ca2c55f3 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts @@ -54,5 +54,15 @@ describe('Hosts page', () => { hosts.editLabels(hostname, labels, true); hosts.editLabels(hostname, labels, false); }); + + it('should enter host into maintenance', function () { + const hostname = Cypress._.sample(this.hosts).name; + hosts.maintenance(hostname); + }); + + it('should exit host from maintenance', function () { + const hostname = Cypress._.sample(this.hosts).name; + hosts.maintenance(hostname, true); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html index e4c16080b65..f31adf9e5c0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html @@ -56,3 +56,13 @@ , + + +
+
    +
  • {{ msg }}
  • +
+
+ Are you sure you want to continue? +
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 86b8a6fd3b8..640d39d1fdd 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,6 +7,7 @@ import _ from 'lodash'; import { HostService } from '~/app/shared/api/host.service'; import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; +import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component'; import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component'; import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; @@ -43,6 +44,8 @@ export class HostsComponent extends ListWithDetails implements OnInit { table: TableComponent; @ViewChild('servicesTpl', { static: true }) public servicesTpl: TemplateRef; + @ViewChild('maintenanceConfirmTpl', { static: true }) + maintenanceConfirmTpl: TemplateRef; permissions: Permissions; columns: Array = []; @@ -52,6 +55,11 @@ export class HostsComponent extends ListWithDetails implements OnInit { tableActions: CdTableAction[]; selection = new CdTableSelection(); modalRef: NgbModalRef; + isExecuting = false; + errorMessage: string; + enableButton: boolean; + + icons = Icons; messages = { nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.` @@ -61,7 +69,11 @@ export class HostsComponent extends ListWithDetails implements OnInit { actionOrchFeatures = { create: [OrchestratorFeature.HOST_CREATE], edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE], - delete: [OrchestratorFeature.HOST_DELETE] + delete: [OrchestratorFeature.HOST_DELETE], + maintenance: [ + OrchestratorFeature.HOST_MAINTENANCE_ENTER, + OrchestratorFeature.HOST_MAINTENANCE_EXIT + ] }; constructor( @@ -99,6 +111,22 @@ export class HostsComponent extends ListWithDetails implements OnInit { icon: Icons.destroy, click: () => this.deleteAction(), disable: (selection: CdTableSelection) => this.getDisable('delete', selection) + }, + { + name: this.actionLabels.ENTER_MAINTENANCE, + permission: 'update', + icon: Icons.enter, + click: () => this.hostMaintenance(), + disable: (selection: CdTableSelection) => + this.getDisable('maintenance', selection) || this.isExecuting || this.enableButton + }, + { + name: this.actionLabels.EXIT_MAINTENANCE, + permission: 'update', + icon: Icons.exit, + click: () => this.hostMaintenance(), + disable: (selection: CdTableSelection) => + this.getDisable('maintenance', selection) || this.isExecuting || !this.enableButton } ]; } @@ -125,6 +153,17 @@ export class HostsComponent extends ListWithDetails implements OnInit { class: 'badge-dark' } }, + { + name: $localize`Status`, + prop: 'status', + flexGrow: 1, + cellTransformation: CellTemplate.badge, + customTemplateConfig: { + map: { + maintenance: { class: 'badge-warning' } + } + } + }, { name: $localize`Version`, prop: 'ceph_version', @@ -139,6 +178,12 @@ export class HostsComponent extends ListWithDetails implements OnInit { updateSelection(selection: CdTableSelection) { this.selection = selection; + this.enableButton = false; + if (this.selection.hasSelection) { + if (this.selection.first().status === 'maintenance') { + this.enableButton = true; + } + } } editAction() { @@ -168,7 +213,7 @@ export class HostsComponent extends ListWithDetails implements OnInit { ], submitButtonText: $localize`Edit Host`, onSubmit: (values: any) => { - this.hostService.update(host['hostname'], values.labels).subscribe(() => { + this.hostService.update(host['hostname'], true, values.labels).subscribe(() => { this.notificationService.show( NotificationType.success, $localize`Updated Host "${host.hostname}"` @@ -181,8 +226,70 @@ export class HostsComponent extends ListWithDetails implements OnInit { }); } - getDisable(action: 'create' | 'edit' | 'delete', selection: CdTableSelection): boolean | string { - if (action === 'delete' || action === 'edit') { + hostMaintenance() { + this.isExecuting = true; + const host = this.selection.first(); + if (host['status'] !== 'maintenance') { + this.hostService.update(host['hostname'], false, [], true).subscribe( + () => { + this.isExecuting = false; + this.notificationService.show( + NotificationType.success, + $localize`"${host.hostname}" moved to maintenance` + ); + this.table.refreshBtn(); + }, + (error) => { + this.isExecuting = false; + this.errorMessage = error.error['detail'].split(/\n/); + error.preventDefault(); + if ( + error.error['detail'].includes('WARNING') && + !error.error['detail'].includes('It is NOT safe to stop') && + !error.error['detail'].includes('ALERT') + ) { + const modalVarialbes = { + titleText: $localize`Warning`, + buttonText: $localize`Continue`, + warning: true, + bodyTpl: this.maintenanceConfirmTpl, + showSubmit: true, + onSubmit: () => { + this.hostService.update(host['hostname'], false, [], true, true).subscribe( + () => { + this.modalRef.close(); + }, + () => this.modalRef.close() + ); + } + }; + this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVarialbes); + } else { + this.notificationService.show( + NotificationType.error, + $localize`"${host.hostname}" cannot be put into maintenance`, + $localize`${error.error['detail']}` + ); + } + } + ); + } else { + this.hostService.update(host['hostname'], false, [], true).subscribe(() => { + this.isExecuting = false; + this.notificationService.show( + NotificationType.success, + $localize`"${host.hostname}" has exited maintenance` + ); + this.table.refreshBtn(); + }); + } + } + + getDisable( + action: 'create' | 'edit' | 'delete' | 'maintenance', + selection: CdTableSelection + ): boolean | string { + if (action === 'delete' || action === 'edit' || action === 'maintenance') { if (!selection?.hasSingleSelection) { return true; } 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 babddca3e61..8a7de5e25d6 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 @@ -44,10 +44,15 @@ describe('HostService', () => { }); it('should update host', fakeAsync(() => { - service.update('mon0', ['foo', 'bar']).subscribe(); + service.update('mon0', true, ['foo', 'bar']).subscribe(); const req = httpTesting.expectOne('api/host/mon0'); expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ labels: ['foo', 'bar'] }); + expect(req.request.body).toEqual({ + force: false, + labels: ['foo', 'bar'], + maintenance: false, + update_labels: true + }); })); it('should call getInventory', () => { 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 5f34d96af4d..74c0c30d449 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 @@ -51,8 +51,19 @@ export class HostService { return this.http.get(`${this.baseUIURL}/labels`); } - update(hostname: string, labels: string[]) { - return this.http.put(`${this.baseURL}/${hostname}`, { labels: labels }); + update( + hostname: string, + updateLabels = false, + labels: string[] = [], + maintenance = false, + force = false + ) { + return this.http.put(`${this.baseURL}/${hostname}`, { + update_labels: updateLabels, + labels: labels, + maintenance: maintenance, + force: force + }); } identifyDevice(hostname: string, device: string, duration: number) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html index 3e0d1d29900..294d43f775b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html @@ -1,5 +1,9 @@ - {{ titleText }} + + + + {{ titleText }}
+ [submitText]="buttonText" + [showSubmit]="showSubmit">
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts index bdd233cefc1..fe56249816a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts @@ -19,9 +19,11 @@ export class ConfirmationModalComponent implements OnInit, OnDestroy { description?: TemplateRef; // Optional + warning = false; bodyData?: object; onCancel?: Function; bodyContext?: object; + showSubmit = true; // Component only boundCancel = this.cancel.bind(this); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss index f307edd4be6..baa64fa1fcd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss @@ -62,3 +62,7 @@ hr { margin-bottom: 2px; margin-top: 2px; } + +.card-text { + margin-right: 15px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index 7747f146836..05d6b5c53a9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -115,6 +115,8 @@ export class ActionLabelsI18n { UNSET: string; UPDATE: string; FLAGS: string; + ENTER_MAINTENANCE: string; + EXIT_MAINTENANCE: string; constructor() { /* Create a new item */ @@ -166,6 +168,8 @@ export class ActionLabelsI18n { this.UNPROTECT = $localize`Unprotect`; this.CHANGE = $localize`Change`; this.FLAGS = $localize`Flags`; + this.ENTER_MAINTENANCE = $localize`Enter Maintenance`; + this.EXIT_MAINTENANCE = $localize`Exit Maintenance`; /* Prometheus wording */ this.RECREATE = $localize`Recreate`; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html index f8096a4657a..b60c9b1ddb4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html @@ -13,7 +13,6 @@
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 9a07d4dec94..2478ecd1289 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -66,6 +66,8 @@ export enum Icons { json = 'fa fa-file-code-o', // JSON file text = 'fa fa-file-text', // Text file wrench = 'fa fa-wrench', // Configuration Error + enter = 'fa fa-sign-in', // Enter + exit = 'fa fa-sign-out', // Exit /* Icons for special effect */ large = 'fa fa-lg', // icon becomes 33% larger diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts index 50a70df8633..8c740f4e017 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts @@ -4,6 +4,8 @@ export enum OrchestratorFeature { HOST_DELETE = 'remove_host', HOST_LABEL_ADD = 'add_host_label', HOST_LABEL_REMOVE = 'remove_host_label', + HOST_MAINTENANCE_ENTER = 'enter_host_maintenance', + HOST_MAINTENANCE_EXIT = 'exit_host_maintenance', SERVICE_LIST = 'describe_service', SERVICE_CREATE = 'apply', diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 8672a3271cf..5ecd9d5df86 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -3331,8 +3331,10 @@ paths: put: description: "\n Update the specified host.\n Note, this is only\ \ supported when Ceph Orchestrator is enabled.\n :param hostname: The\ - \ name of the host to be processed.\n :param labels: List of labels.\n\ - \ " + \ name of the host to be processed.\n :param update_labels: To update\ + \ the labels.\n :param labels: List of labels.\n :param maintenance:\ + \ Enter/Exit maintenance mode.\n :param force: Force enter maintenance\ + \ mode.\n " parameters: - description: Hostname in: path @@ -3345,13 +3347,23 @@ paths: application/json: schema: properties: + force: + default: false + description: Force Enter Maintenance + type: boolean labels: description: Host Labels items: type: string type: array - required: - - labels + maintenance: + default: false + description: Enter/Exit Maintenance + type: boolean + update_labels: + default: false + description: Update Labels + type: boolean type: object responses: '200': diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index 5463c4e7a75..1b472932c93 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -51,6 +51,14 @@ class HostManger(ResourceManager): def list(self) -> List[HostSpec]: return self.api.get_hosts() + @wait_api_result + def enter_maintenance(self, hostname: str, force: bool = False): + return self.api.enter_host_maintenance(hostname, force) + + @wait_api_result + def exit_maintenance(self, hostname: str): + return self.api.exit_host_maintenance(hostname) + def get(self, hostname: str) -> Optional[HostSpec]: hosts = [host for host in self.list() if host.hostname == hostname] return hosts[0] if hosts else None @@ -185,6 +193,8 @@ class OrchFeature(object): HOST_DELETE = 'remove_host' HOST_LABEL_ADD = 'add_host_label' HOST_LABEL_REMOVE = 'remove_host_label' + HOST_MAINTENANCE_ENTER = 'enter_host_maintenance' + HOST_MAINTENANCE_EXIT = 'exit_host_maintenance' SERVICE_LIST = 'describe_service' SERVICE_CREATE = 'apply' diff --git a/src/pybind/mgr/dashboard/tests/test_host.py b/src/pybind/mgr/dashboard/tests/test_host.py index bd0deb5f821..6093ba8f431 100644 --- a/src/pybind/mgr/dashboard/tests/test_host.py +++ b/src/pybind/mgr/dashboard/tests/test_host.py @@ -131,15 +131,43 @@ class HostControllerTest(ControllerTestCase): fake_client.hosts.remove_label = mock.Mock() fake_client.hosts.add_label = mock.Mock() - self._put('{}/node0'.format(self.URL_HOST), {'labels': ['bbb', 'ccc']}) + payload = {'update_labels': True, 'labels': ['bbb', 'ccc']} + self._put('{}/node0'.format(self.URL_HOST), payload) 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') # return 400 if type other than List[str] - self._put('{}/node0'.format(self.URL_HOST), {'labels': 'ddd'}) + self._put('{}/node0'.format(self.URL_HOST), {'update_labels': True, + 'labels': 'ddd'}) self.assertStatus(400) + def test_host_maintenance(self): + mgr.list_servers.return_value = [] + orch_hosts = [ + HostSpec('node0'), + HostSpec('node1') + ] + with patch_orch(True, hosts=orch_hosts): + # enter maintenance mode + self._put('{}/node0'.format(self.URL_HOST), {'maintenance': True}) + self.assertStatus(200) + + # force enter maintenance mode + self._put('{}/node1'.format(self.URL_HOST), {'maintenance': True, 'force': True}) + self.assertStatus(200) + + # exit maintenance mode + self._put('{}/node0'.format(self.URL_HOST), {'maintenance': True}) + self.assertStatus(200) + self._put('{}/node1'.format(self.URL_HOST), {'maintenance': True}) + self.assertStatus(200) + + # maintenance without orchestrator service + with patch_orch(False): + self._put('{}/node0'.format(self.URL_HOST), {'maintenance': True}) + self.assertStatus(503) + @mock.patch('dashboard.controllers.host.time') def test_identify_device(self, mock_time): url = '{}/host-0/identify_device'.format(self.URL_HOST) -- 2.39.5