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 d0129777d1575..aeb4b437428f1 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 ae7b901f44e13..0785f5107274f 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 3e7e6733312b2..473ca2c55f333 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 e4c16080b653c..f31adf9e5c0e7 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 86b8a6fd3b8be..640d39d1fdd53 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 babddca3e6199..8a7de5e25d634 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 5f34d96af4d64..74c0c30d44967 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 3e0d1d29900a9..294d43f775b21 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 bdd233cefc1ce..fe56249816a01 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 f307edd4be670..baa64fa1fcd9e 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 7747f146836f6..05d6b5c53a9ad 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 f8096a4657a8f..b60c9b1ddb42d 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 9a07d4dec94c9..2478ecd128987 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 50a70df8633d1..8c740f4e01756 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 8672a3271cf23..5ecd9d5df86a7 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 5463c4e7a757b..1b472932c933c 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 bd0deb5f82191..6093ba8f4314b 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