]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Host Maintenance Feature 39943/head
authorNizamudeen A <nia@redhat.com>
Tue, 2 Feb 2021 12:26:13 +0000 (17:56 +0530)
committerNizamudeen A <nia@redhat.com>
Wed, 10 Mar 2021 13:37:26 +0000 (19:07 +0530)
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 <nia@redhat.com>
(cherry picked from commit 0780fc131d1484601318eb0ec39f14955c731e16)

17 files changed:
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/01-hosts.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/orchestrator.py
src/pybind/mgr/dashboard/tests/test_host.py

index d0129777d157505441b446ec267cba6537d9e479..aeb4b437428f1b639d0bba68d6a01b7ef3294d10 100644 (file)
@@ -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)
index c7a6b91795e1af2919d33ec84b9247dbb5612842..8e215bcaf0cf6ee189207d1336c25660bed57bd0 100644 (file)
@@ -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');
+          });
+      }
+    }
+  }
 }
index 3e7e6733312b21b4ee13dace929c85e8d20ba0e0..473ca2c55f3339fd0a9d88bebecbd7f74448f8b0 100644 (file)
@@ -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);
+    });
   });
 });
index e4c16080b653c67cafbc508532cdd59598c80b7b..f31adf9e5c0e79bc21bb172c3ce629c23f9f105a 100644 (file)
     <ng-container *ngIf="!isLast">, </ng-container>
   </span>
 </ng-template>
+
+<ng-template #maintenanceConfirmTpl>
+  <div *ngFor="let msg of errorMessage; let last=last">
+    <ul *ngIf="!last || errorMessage.length == '1'">
+      <li i18n>{{ msg }}</li>
+    </ul>
+  </div>
+  <ng-container i18n
+                *ngIf="showSubmit">Are you sure you want to continue?</ng-container>
+</ng-template>
index 8ed8db0301cf0741fa11235a4f087600f5e39579..320b0cfcef112f6f447829167e078185cb78db76 100644 (file)
@@ -7,11 +7,13 @@ 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';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
 import { Icons } from '~/app/shared/enum/icons.enum';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
@@ -43,6 +45,8 @@ export class HostsComponent extends ListWithDetails implements OnInit {
   table: TableComponent;
   @ViewChild('servicesTpl', { static: true })
   public servicesTpl: TemplateRef<any>;
+  @ViewChild('maintenanceConfirmTpl', { static: true })
+  maintenanceConfirmTpl: TemplateRef<any>;
 
   permissions: Permissions;
   columns: Array<CdTableColumn> = [];
@@ -52,6 +56,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 +70,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(
@@ -100,6 +113,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
       }
     ];
   }
@@ -123,6 +152,17 @@ export class HostsComponent extends ListWithDetails implements OnInit {
         flexGrow: 1,
         pipe: this.joinPipe
       },
+      {
+        name: $localize`Status`,
+        prop: 'status',
+        flexGrow: 1,
+        cellTransformation: CellTemplate.badge,
+        customTemplateConfig: {
+          map: {
+            maintenance: { class: 'badge-warning' }
+          }
+        }
+      },
       {
         name: $localize`Version`,
         prop: 'ceph_version',
@@ -137,6 +177,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() {
@@ -166,7 +212,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}"`
@@ -179,8 +225,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;
       }
index babddca3e61990b5d656dafd1f3a594c8b129cf0..8a7de5e25d6341b3da26067126c49cb0114ad4af 100644 (file)
@@ -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', () => {
index 5f34d96af4d64d602196e97b49a17c068f705f17..74c0c30d449678c69fcfa01180350abefe5edfc2 100644 (file)
@@ -51,8 +51,19 @@ export class HostService {
     return this.http.get<string[]>(`${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) {
index 3e0d1d29900a9e2bc3b517522fe6a181a412ff82..294d43f775b21c1b184294323901bf0e309b13ea 100644 (file)
@@ -1,5 +1,9 @@
 <cd-modal (hide)="cancel()">
-  <ng-container class="modal-title">{{ titleText }}</ng-container>
+  <ng-container class="modal-title">
+    <span class="text-warning"
+          *ngIf="warning">
+      <i class="fa fa-exclamation-triangle fa-1x"></i>
+    </span>{{ titleText }}</ng-container>
   <ng-container class="modal-content">
     <form name="confirmationForm"
           #formDir="ngForm"
@@ -15,7 +19,8 @@
         <cd-form-button-panel (submitActionEvent)="onSubmit(confirmationForm.value)"
                               (backActionEvent)="boundCancel()"
                               [form]="confirmationForm"
-                              [submitText]="buttonText"></cd-form-button-panel>
+                              [submitText]="buttonText"
+                              [showSubmit]="showSubmit"></cd-form-button-panel>
       </div>
     </form>
   </ng-container>
index bdd233cefc1ce905ed234e1827966939166ef8d0..fe56249816a01fb794725badaf8612f026fb3876 100644 (file)
@@ -19,9 +19,11 @@ export class ConfirmationModalComponent implements OnInit, OnDestroy {
   description?: TemplateRef<any>;
 
   // Optional
+  warning = false;
   bodyData?: object;
   onCancel?: Function;
   bodyContext?: object;
+  showSubmit = true;
 
   // Component only
   boundCancel = this.cancel.bind(this);
index 7747f146836f6265168fa069f8985188bf455e77..05d6b5c53a9ada733670502178c1c865389cd0fb 100644 (file)
@@ -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`;
index f8096a4657a8f40531e8135d1b002f801e14fd84..b60c9b1ddb42df8f3fa97b9bbf2e63f93dc47d67 100644 (file)
@@ -13,7 +13,6 @@
   </ng-container>
   <div class="btn-group"
        ngbDropdown
-       placement="bottom-right"
        role="group"
        *ngIf="dropDownActions.length > 1"
        aria-label="Button group with nested dropdown">
index 9a07d4dec94c91e0239c0231f5c68b4006dcd3e4..2478ecd128987a3109d223502a5d7761c1379721 100644 (file)
@@ -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
index 50a70df8633d1fdef5117d31046c7f52b0c8074a..8c740f4e01756fd1b02c887ffb85d3c902086880 100644 (file)
@@ -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',
index 3b583399001c7b8243f0fe03e2502ae4f18d5393..98facbc7402d1804360ada5a6556e1c686b3f7f5 100644 (file)
@@ -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':
index 3d25091e29960cbbb848906523d81dee3d7c1c60..024ffe43d781209f4aede92e7e8488c9219de52f 100644 (file)
@@ -55,6 +55,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
@@ -189,6 +197,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'
index bd0deb5f8219189b8e89b0830728f2879e6998ae..6093ba8f4314b09eb79c62fb1d0461ff61d48c3e 100644 (file)
@@ -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)