]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Host Maintenance Feature 39226/head
authorNizamudeen A <nia@redhat.com>
Tue, 2 Feb 2021 12:26:13 +0000 (17:56 +0530)
committerNizamudeen A <nia@redhat.com>
Mon, 8 Mar 2021 16:06:15 +0000 (21:36 +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>
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 ae7b901f44e133088e28d6afabe78f25c5905147..0785f5107274fe1140c5a54fcf21d85106a2560f 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 86b8a6fd3b8bea1718b48fb79afc7142e30c25a7..640d39d1fdd532fdb82e3ec8170dce95e57d053f 100644 (file)
@@ -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<any>;
+  @ViewChild('maintenanceConfirmTpl', { static: true })
+  maintenanceConfirmTpl: TemplateRef<any>;
 
   permissions: Permissions;
   columns: Array<CdTableColumn> = [];
@@ -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;
       }
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 8672a3271cf2326dfff1e2df5ae037f5563e65f2..5ecd9d5df86a742929cab8c98bf0723fdb5cef9c 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 5463c4e7a757b44c8ae25e0732e70440270450e0..1b472932c933cfac7721cff9f7091bcf7bb9d01e 100644 (file)
@@ -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'
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)