]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: perform daemon actions on cluster->services 44609/head
authorPere Diaz Bou <pdiazbou@redhat.com>
Mon, 17 Jan 2022 09:07:15 +0000 (10:07 +0100)
committerPere Diaz Bou <pdiazbou@redhat.com>
Fri, 28 Jan 2022 10:44:35 +0000 (11:44 +0100)
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Fixes: https://tracker.ceph.com/issues/50322
18 files changed:
src/pybind/mgr/dashboard/controllers/daemon.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/services.po.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/09-services.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts [new file with mode: 0644]
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/datatable/table-actions/table-actions.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/orchestrator.py
src/pybind/mgr/dashboard/tests/__init__.py
src/pybind/mgr/dashboard/tests/test_daemon.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tests/test_host.py

diff --git a/src/pybind/mgr/dashboard/controllers/daemon.py b/src/pybind/mgr/dashboard/controllers/daemon.py
new file mode 100644 (file)
index 0000000..eeea5a3
--- /dev/null
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+from typing import Optional
+
+from ..exceptions import DashboardException
+from ..security import Scope
+from ..services.exception import handle_orchestrator_error
+from ..services.orchestrator import OrchClient, OrchFeature
+from . import APIDoc, APIRouter, RESTController
+from ._version import APIVersion
+from .orchestrator import raise_if_no_orchestrator
+
+
+@APIRouter('/daemon', Scope.HOSTS)
+@APIDoc("Perform actions on daemons", "Daemon")
+class Daemon(RESTController):
+    @raise_if_no_orchestrator([OrchFeature.DAEMON_ACTION])
+    @handle_orchestrator_error('daemon')
+    @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
+    def set(self, daemon_name: str, action: str = '',
+            container_image: Optional[str] = None):
+
+        if action not in ['start', 'stop', 'restart', 'redeploy']:
+            raise DashboardException(
+                code='invalid_daemon_action',
+                msg=f'Daemon action "{action}" is either not valid or not supported.')
+        # non 'None' container_images change need a redeploy
+        if container_image == '' and action != 'redeploy':
+            container_image = None
+
+        orch = OrchClient.instance()
+        res = orch.daemons.action(action=action, daemon_name=daemon_name, image=container_image)
+        return res
index 07bd3b58b8b7ef01116effcc4992fe9311117641..42734a81bf6960efbf418ec7fd0a4c5926e5e12b 100644 (file)
@@ -85,14 +85,14 @@ export class ServicesPageHelper extends PageHelper {
     });
   }
 
-  checkServiceStatus(daemon: string) {
+  checkServiceStatus(daemon: string, expectedStatus = 'running') {
     cy.get('cd-service-daemon-list').within(() => {
       this.getTableCell(this.serviceDetailColumnIndex.daemonType, daemon)
         .parent()
         .find(`datatable-body-cell:nth-child(${this.serviceDetailColumnIndex.status}) .badge`)
         .should(($ele) => {
           const status = $ele.toArray().map((v) => v.innerText);
-          expect(status).to.include('running');
+          expect(status).to.include(expectedStatus);
         });
     });
   }
@@ -133,4 +133,11 @@ export class ServicesPageHelper extends PageHelper {
     cy.get('cd-modal').should('not.exist');
     this.checkExist(serviceName, false);
   }
+
+  daemonAction(daemon: string, action: string) {
+    cy.get('cd-service-daemon-list').within(() => {
+      this.getTableRow(daemon).click();
+      this.clickActionButton(action);
+    });
+  }
 }
index 9b49c75aca6a35498b641b7a67aff758d29b279a..eb4033c9a570e4f303813025081f9486035a78f2 100644 (file)
@@ -12,7 +12,7 @@ describe('Services page', () => {
     services.checkExist('rgw.foo', true);
   });
 
-  it('should create and delete an mds service', () => {
+  it('should create an mds service', () => {
     services.navigateTo('create');
     services.addService('mds', false);
     services.checkExist('mds.test', true);
@@ -21,7 +21,44 @@ describe('Services page', () => {
     cy.get('cd-service-details').within(() => {
       services.checkServiceStatus('mds');
     });
+  });
+
+  it('should stop a daemon', () => {
+    services.clickServiceTab('mds.test', 'Details');
+    services.checkServiceStatus('mds');
+
+    services.daemonAction('mds', 'stop');
+    services.checkServiceStatus('mds', 'stopped');
+  });
+
+  it('should restart a daemon', () => {
+    services.checkExist('mds.test', true);
+    services.clickServiceTab('mds.test', 'Details');
+    services.daemonAction('mds', 'restart');
+    services.checkServiceStatus('mds', 'running');
+  });
+
+  it('should redeploy a daemon', () => {
+    services.checkExist('mds.test', true);
+    services.clickServiceTab('mds.test', 'Details');
+
+    services.daemonAction('mds', 'stop');
+    services.checkServiceStatus('mds', 'stopped');
+    services.daemonAction('mds', 'redeploy');
+    services.checkServiceStatus('mds', 'running');
+  });
+
+  it('should start a daemon', () => {
+    services.checkExist('mds.test', true);
+    services.clickServiceTab('mds.test', 'Details');
+
+    services.daemonAction('mds', 'stop');
+    services.checkServiceStatus('mds', 'stopped');
+    services.daemonAction('mds', 'start');
+    services.checkServiceStatus('mds', 'running');
+  });
 
+  it('should delete an mds service', () => {
     services.deleteService('mds.test');
   });
 });
index 3aeb04eade7afde501fd649179cd1b4f0f8313b5..5b631453f8b3d78debf9d2f149192b9ff6c0e3ba 100644 (file)
           <cd-table *ngIf="hasOrchestrator"
                     #daemonsTable
                     [data]="daemons"
+                    selectionType="single"
                     [columns]="columns"
                     columnMode="flex"
-                    (fetchData)="getDaemons($event)">
+                    identifier="daemon_id"
+                    (fetchData)="getDaemons($event)"
+                    (updateSelection)="updateSelection($event)">
+            <cd-table-actions id="service-daemon-list-actions"
+                              class="table-actions"
+                              [selection]="selection"
+                              [permission]="permissions.hosts"
+                              [tableActions]="tableActions">
+            </cd-table-actions>
           </cd-table>
         </ng-template>
       </li>
index e9b49ce80ea02d8498483d51af1aa689ae55a398..7e2148c1a261023c584f6eef52e522be3f3979a7 100644 (file)
@@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 
 import _ from 'lodash';
 import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
 import { of } from 'rxjs';
 
 import { CephModule } from '~/app/ceph/ceph.module';
@@ -26,10 +27,16 @@ describe('ServiceDaemonListComponent', () => {
       container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
       daemon_id: '3',
       daemon_type: 'osd',
+      daemon_name: 'osd.3',
       version: '15.1.0-1174-g16a11f7',
       status: 1,
       status_desc: 'running',
-      last_refresh: '2020-02-25T04:33:26.465699'
+      last_refresh: '2020-02-25T04:33:26.465699',
+      events: [
+        { created: '2020-02-24T04:33:26.465699' },
+        { created: '2020-02-25T04:33:26.465699' },
+        { created: '2020-02-26T04:33:26.465699' }
+      ]
     },
     {
       hostname: 'osd0',
@@ -38,10 +45,12 @@ describe('ServiceDaemonListComponent', () => {
       container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
       daemon_id: '4',
       daemon_type: 'osd',
+      daemon_name: 'osd.4',
       version: '15.1.0-1174-g16a11f7',
       status: 1,
       status_desc: 'running',
-      last_refresh: '2020-02-25T04:33:26.465822'
+      last_refresh: '2020-02-25T04:33:26.465822',
+      events: []
     },
     {
       hostname: 'osd0',
@@ -50,10 +59,12 @@ describe('ServiceDaemonListComponent', () => {
       container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
       daemon_id: '5',
       daemon_type: 'osd',
+      daemon_name: 'osd.5',
       version: '15.1.0-1174-g16a11f7',
       status: 1,
       status_desc: 'running',
-      last_refresh: '2020-02-25T04:33:26.465886'
+      last_refresh: '2020-02-25T04:33:26.465886',
+      events: []
     },
     {
       hostname: 'mon0',
@@ -61,11 +72,13 @@ describe('ServiceDaemonListComponent', () => {
       container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
       container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
       daemon_id: 'a',
+      daemon_name: 'mon.a',
       daemon_type: 'mon',
       version: '15.1.0-1174-g16a11f7',
       status: 1,
       status_desc: 'running',
-      last_refresh: '2020-02-25T04:33:26.465886'
+      last_refresh: '2020-02-25T04:33:26.465886',
+      events: []
     }
   ];
 
@@ -105,7 +118,14 @@ describe('ServiceDaemonListComponent', () => {
   };
 
   configureTestBed({
-    imports: [HttpClientTestingModule, CephModule, CoreModule, NgxPipeFunctionModule, SharedModule]
+    imports: [
+      HttpClientTestingModule,
+      CephModule,
+      CoreModule,
+      NgxPipeFunctionModule,
+      SharedModule,
+      ToastrModule.forRoot()
+    ]
   });
 
   beforeEach(() => {
@@ -147,4 +167,79 @@ describe('ServiceDaemonListComponent', () => {
   it('should not display doc panel if orchestrator is available', () => {
     expect(component.showDocPanel).toBeFalsy();
   });
+
+  it('should call daemon action', () => {
+    const daemon = daemons[0];
+    component.selection.selected = [daemon];
+    component['daemonService'].action = jest.fn(() => of());
+    for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+      component.daemonAction(action);
+      expect(component['daemonService'].action).toHaveBeenCalledWith(daemon.daemon_name, action);
+    }
+  });
+
+  it('should disable daemon actions', () => {
+    const daemon = {
+      daemon_type: 'osd',
+      status_desc: 'running'
+    };
+
+    const states = {
+      start: true,
+      stop: false,
+      restart: false,
+      redeploy: false
+    };
+    const expectBool = (toExpect: boolean, arg: boolean) => {
+      if (toExpect === true) {
+        expect(arg).toBeTruthy();
+      } else {
+        expect(arg).toBeFalsy();
+      }
+    };
+
+    component.selection.selected = [daemon];
+    for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+      expectBool(states[action], component.actionDisabled(action));
+    }
+
+    daemon.status_desc = 'stopped';
+    states.start = false;
+    states.stop = true;
+    component.selection.selected = [daemon];
+    for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+      expectBool(states[action], component.actionDisabled(action));
+    }
+  });
+
+  it('should disable daemon actions in mgr and mon daemon', () => {
+    const daemon = {
+      daemon_type: 'mgr',
+      status_desc: 'running'
+    };
+    for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+      expect(component.actionDisabled(action)).toBeTruthy();
+    }
+    daemon.daemon_type = 'mon';
+    for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+      expect(component.actionDisabled(action)).toBeTruthy();
+    }
+  });
+
+  it('should disable daemon actions if no selection', () => {
+    component.selection.selected = [];
+    for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+      expect(component.actionDisabled(action)).toBeTruthy();
+    }
+  });
+
+  it('should sort daemons events', () => {
+    component.sortDaemonEvents();
+    const daemon = daemons[0];
+    for (let i = 1; i < daemon.events.length; i++) {
+      const t1 = new Date(daemon.events[i - 1].created).getTime();
+      const t2 = new Date(daemon.events[i].created).getTime();
+      expect(t1 >= t2).toBeTruthy();
+    }
+  });
 });
index 1a17fdb61ccddd2d07187f528a5cc7f952029239..adb2c1871dfa1c2e7d6ad0e0b485f9c99d702f9d 100644 (file)
@@ -13,18 +13,27 @@ import {
 
 import _ from 'lodash';
 import { Observable, Subscription } from 'rxjs';
+import { take } from 'rxjs/operators';
 
 import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { DaemonService } from '~/app/shared/api/daemon.service';
 import { HostService } from '~/app/shared/api/host.service';
 import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+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';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { Daemon } from '~/app/shared/models/daemon.interface';
+import { Permissions } from '~/app/shared/models/permissions';
 import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { RelativeDatePipe } from '~/app/shared/pipes/relative-date.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
 
 @Component({
   selector: 'cd-service-daemon-list',
@@ -56,6 +65,9 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI
   services: Array<CephServiceSpec> = [];
   columns: CdTableColumn[] = [];
   serviceColumns: CdTableColumn[] = [];
+  tableActions: CdTableAction[];
+  selection = new CdTableSelection();
+  permissions: Permissions;
 
   hasOrchestrator = false;
   showDocPanel = false;
@@ -68,10 +80,45 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI
     private hostService: HostService,
     private cephServiceService: CephServiceService,
     private orchService: OrchestratorService,
-    private relativeDatePipe: RelativeDatePipe
+    private relativeDatePipe: RelativeDatePipe,
+    public actionLabels: ActionLabelsI18n,
+    private authStorageService: AuthStorageService,
+    private daemonService: DaemonService,
+    private notificationService: NotificationService
   ) {}
 
   ngOnInit() {
+    this.permissions = this.authStorageService.getPermissions();
+    this.tableActions = [
+      {
+        permission: 'update',
+        icon: Icons.start,
+        click: () => this.daemonAction('start'),
+        name: this.actionLabels.START,
+        disable: () => this.actionDisabled('start')
+      },
+      {
+        permission: 'update',
+        icon: Icons.stop,
+        click: () => this.daemonAction('stop'),
+        name: this.actionLabels.STOP,
+        disable: () => this.actionDisabled('stop')
+      },
+      {
+        permission: 'update',
+        icon: Icons.restart,
+        click: () => this.daemonAction('restart'),
+        name: this.actionLabels.RESTART,
+        disable: () => this.actionDisabled('restart')
+      },
+      {
+        permission: 'update',
+        icon: Icons.deploy,
+        click: () => this.daemonAction('redeploy'),
+        name: this.actionLabels.REDEPLOY,
+        disable: () => this.actionDisabled('redeploy')
+      }
+    ];
     this.columns = [
       {
         name: $localize`Hostname`,
@@ -219,6 +266,7 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI
     observable.subscribe(
       (daemons: Daemon[]) => {
         this.daemons = daemons;
+        this.sortDaemonEvents();
       },
       () => {
         this.daemons = [];
@@ -227,6 +275,13 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI
     );
   }
 
+  sortDaemonEvents() {
+    this.daemons.forEach((daemon: any) => {
+      daemon.events?.sort((event1: any, event2: any) => {
+        return new Date(event2.created).getTime() - new Date(event1.created).getTime();
+      });
+    });
+  }
   getServices(context: CdTableFetchDataContext) {
     this.serviceSub = this.cephServiceService.list(this.serviceName).subscribe(
       (services: CephServiceSpec[]) => {
@@ -242,4 +297,53 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI
   trackByFn(_index: any, item: any) {
     return item.created;
   }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  daemonAction(actionType: string) {
+    this.daemonService
+      .action(this.selection.first()?.daemon_name, actionType)
+      .pipe(take(1))
+      .subscribe({
+        next: (resp) => {
+          this.notificationService.show(
+            NotificationType.success,
+            `Daemon ${actionType} scheduled`,
+            resp.body.toString()
+          );
+        },
+        error: (resp) => {
+          this.notificationService.show(
+            NotificationType.error,
+            'Daemon action failed',
+            resp.body.toString()
+          );
+        }
+      });
+  }
+
+  actionDisabled(actionType: string) {
+    if (this.selection?.hasSelection) {
+      const daemon = this.selection.selected[0];
+      if (daemon.daemon_type === 'mon' || daemon.daemon_type === 'mgr') {
+        return true; // don't allow actions on mon and mgr, dashboard requires them.
+      }
+      switch (actionType) {
+        case 'start':
+          if (daemon.status_desc === 'running') {
+            return true;
+          }
+          break;
+        case 'stop':
+          if (daemon.status_desc === 'stopped') {
+            return true;
+          }
+          break;
+      }
+      return false;
+    }
+    return true; // if no selection then disable everything
+  }
 }
index 6be3b2689526f06e6c54352e27775dfa8f4d2d3f..109ef039fbab03ddc4a72783598f815f95eaffd7 100644 (file)
@@ -4,6 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing';
 
 import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
 import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
 
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { SummaryService } from '~/app/shared/services/summary.service';
@@ -22,7 +23,8 @@ describe('ServiceDetailsComponent', () => {
       RouterTestingModule,
       SharedModule,
       NgbNavModule,
-      NgxPipeFunctionModule
+      NgxPipeFunctionModule,
+      ToastrModule.forRoot()
     ],
     declarations: [ServiceDetailsComponent, ServiceDaemonListComponent],
     providers: [{ provide: SummaryService, useValue: { subscribeOnce: jest.fn() } }]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts
new file mode 100644 (file)
index 0000000..787e5db
--- /dev/null
@@ -0,0 +1,39 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DaemonService } from './daemon.service';
+
+describe('DaemonService', () => {
+  let service: DaemonService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    providers: [DaemonService],
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    service = TestBed.inject(DaemonService);
+    httpTesting = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should call action', () => {
+    const put_data: any = {
+      action: 'start',
+      container_image: null
+    };
+    service.action('osd.1', 'start').subscribe();
+    const req = httpTesting.expectOne('api/daemon/osd.1');
+    expect(req.request.method).toBe('PUT');
+    expect(req.request.body).toEqual(put_data);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts
new file mode 100644 (file)
index 0000000..a66ed7e
--- /dev/null
@@ -0,0 +1,28 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+  providedIn: 'root'
+})
+export class DaemonService {
+  private url = 'api/daemon';
+
+  constructor(private http: HttpClient) {}
+
+  action(daemonName: string, actionType: string) {
+    return this.http.put(
+      `${this.url}/${daemonName}`,
+      {
+        action: actionType,
+        container_image: null
+      },
+      {
+        headers: { Accept: 'application/vnd.ceph.api.v0.1+json' },
+        observe: 'response'
+      }
+    );
+  }
+}
index 5cb2f4e309bc323d15eb998066369c08882a6d56..cb67cada578b505117d9829b422a5622f0a8c60b 100644 (file)
@@ -35,7 +35,10 @@ export enum URLVerbs {
 
   /* Prometheus wording */
   RECREATE = 'recreate',
-  EXPIRE = 'expire'
+  EXPIRE = 'expire',
+
+  /* Daemons */
+  RESTART = 'Restart'
 }
 
 export enum ActionLabels {
@@ -68,7 +71,13 @@ export enum ActionLabels {
 
   /* Prometheus wording */
   RECREATE = 'Recreate',
-  EXPIRE = 'Expire'
+  EXPIRE = 'Expire',
+
+  /* Daemons */
+  START = 'Start',
+  STOP = 'Stop',
+  REDEPLOY = 'Redeploy',
+  RESTART = 'Restart'
 }
 
 @Injectable({
@@ -120,6 +129,10 @@ export class ActionLabelsI18n {
   EXIT_MAINTENANCE: string;
   START_DRAIN: string;
   STOP_DRAIN: string;
+  START: string;
+  STOP: string;
+  REDEPLOY: string;
+  RESTART: string;
 
   constructor() {
     /* Create a new item */
@@ -179,6 +192,11 @@ export class ActionLabelsI18n {
     /* Prometheus wording */
     this.RECREATE = $localize`Recreate`;
     this.EXPIRE = $localize`Expire`;
+
+    this.START = $localize`Start`;
+    this.STOP = $localize`Stop`;
+    this.REDEPLOY = $localize`Redeploy`;
+    this.RESTART = $localize`Restart`;
   }
 }
 
@@ -219,6 +237,11 @@ export class SucceededActionLabelsI18n {
   CHANGE: string;
   RECREATED: string;
   EXPIRED: string;
+  MOVE: string;
+  START: string;
+  STOP: string;
+  REDEPLOY: string;
+  RESTART: string;
 
   constructor() {
     /* Create a new item */
@@ -264,5 +287,10 @@ export class SucceededActionLabelsI18n {
     /* Prometheus wording */
     this.RECREATED = $localize`Recreated`;
     this.EXPIRED = $localize`Expired`;
+
+    this.START = $localize`Start`;
+    this.STOP = $localize`Stop`;
+    this.REDEPLOY = $localize`Redeploy`;
+    this.RESTART = $localize`Restart`;
   }
 }
index 9896d56206d256e1cc0f864da3a148644abedcb8..9720c14e9166bf99711c73a6ca90b64e9fde5eef 100644 (file)
@@ -33,7 +33,7 @@
                 [routerLink]="useRouterLink(action)"
                 [preserveFragment]="action.preserveFragment ? '' : null"
                 [disabled]="disableSelectionAction(action)">
-          <i [ngClass]="[action.icon]"></i>
+          <i [ngClass]="[action.icon, 'action-icon']"></i>
           <span>{{ action.name }}</span>
         </button>
       </ng-container>
index 11419cee61b43856dc4d7bd5f10c2ed6e4bf1a4e..f996de72794e9d4cfdee4ad819e16eb4cc8762c8 100644 (file)
@@ -2,3 +2,7 @@ button.disabled {
   cursor: default !important;
   pointer-events: auto;
 }
+
+.action-icon {
+  padding-right: 1.5rem;
+}
index 2478ecd128987a3109d223502a5d7761c1379721..6b65f04e8cb2f47af1489ba22dc7d5177fa4d966 100644 (file)
@@ -68,6 +68,8 @@ export enum Icons {
   wrench = 'fa fa-wrench', // Configuration Error
   enter = 'fa fa-sign-in', // Enter
   exit = 'fa fa-sign-out', // Exit
+  restart = 'fa fa-history', // Restart
+  deploy = 'fa fa-cube', // Deploy, Redeploy
 
   /* Icons for special effect */
   large = 'fa fa-lg', // icon becomes 33% larger
index c05831eb77fd7868c8df507615defe8b214edef4..00329b78033840b2a81e542d5c5b654caa0e4604 100644 (file)
@@ -2521,6 +2521,49 @@ paths:
       - jwt: []
       tags:
       - CrushRule
+  /api/daemon/{daemon_name}:
+    put:
+      parameters:
+      - in: path
+        name: daemon_name
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                action:
+                  default: ''
+                  type: string
+                container_image:
+                  type: string
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v0.1+json:
+              type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/vnd.ceph.api.v0.1+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - Daemon
   /api/erasure_code_profile:
     get:
       parameters: []
@@ -10457,6 +10500,8 @@ tags:
   name: ClusterConfiguration
 - description: Crush Rule Management API
   name: CrushRule
+- description: Perform actions on daemons
+  name: Daemon
 - description: Erasure Code Profile Management API
   name: ErasureCodeProfile
 - description: Manage Features API
index 2124a961f3617ebab1c61099b88b96009ad9884b..4cc0a3998563a14c4dcf2a23b3c4aa6ec706b4f0 100644 (file)
@@ -151,6 +151,12 @@ class OsdManager(ResourceManager):
         return self.api.remove_osds_status()
 
 
+class DaemonManager(ResourceManager):
+    @wait_api_result
+    def action(self, daemon_name='', action='', image=None):
+        return self.api.daemon_action(daemon_name=daemon_name, action=action, image=image)
+
+
 class OrchClient(object):
 
     _instance = None
@@ -169,6 +175,7 @@ class OrchClient(object):
         self.inventory = InventoryManager(self.api)
         self.services = ServiceManager(self.api)
         self.osds = OsdManager(self.api)
+        self.daemons = DaemonManager(self.api)
 
     def available(self, features: Optional[List[str]] = None) -> bool:
         available = self.status()['available']
@@ -218,3 +225,5 @@ class OrchFeature(object):
 
     DEVICE_LIST = 'get_inventory'
     DEVICE_BLINK_LIGHT = 'blink_device_light'
+
+    DAEMON_ACTION = 'daemon_action'
index 6ae01cca172767115e49da05f74ecf9260d7dd5a..2859e89a2599815cdf0a888711a0bc5714a5cee9 100644 (file)
@@ -1,17 +1,20 @@
 # -*- coding: utf-8 -*-
 # pylint: disable=too-many-arguments
 
+import contextlib
 import json
 import logging
 import threading
 import time
-from typing import Any, Dict
+from typing import Any, Dict, List, Optional
+from unittest import mock
 from unittest.mock import Mock
 
 import cherrypy
 from cherrypy._cptools import HandlerWrapperTool
 from cherrypy.test import helper
 from mgr_module import HandleCommandResult
+from orchestrator import HostSpec, InventoryHost
 from pyfakefs import fake_filesystem
 
 from .. import mgr
@@ -337,3 +340,28 @@ class Waiter(threading.Thread):
                     running = False
                     self.res_task = task
                     self.ev.set()
+
+
+@contextlib.contextmanager
+def patch_orch(available: bool, missing_features: Optional[List[str]] = None,
+               hosts: Optional[List[HostSpec]] = None,
+               inventory: Optional[List[dict]] = None):
+    with mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') as instance:
+        fake_client = mock.Mock()
+        fake_client.available.return_value = available
+        fake_client.get_missing_features.return_value = missing_features
+
+        if hosts is not None:
+            fake_client.hosts.list.return_value = hosts
+
+        if inventory is not None:
+            def _list_inventory(hosts=None, refresh=False):  # pylint: disable=unused-argument
+                inv_hosts = []
+                for inv_host in inventory:
+                    if hosts is None or inv_host['name'] in hosts:
+                        inv_hosts.append(InventoryHost.from_json(inv_host))
+                return inv_hosts
+            fake_client.inventory.list.side_effect = _list_inventory
+
+        instance.return_value = fake_client
+        yield fake_client
diff --git a/src/pybind/mgr/dashboard/tests/test_daemon.py b/src/pybind/mgr/dashboard/tests/test_daemon.py
new file mode 100644 (file)
index 0000000..2008c86
--- /dev/null
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+
+from ..controllers._version import APIVersion
+from ..controllers.daemon import Daemon
+from ..tests import ControllerTestCase, patch_orch
+
+
+class DaemonTest(ControllerTestCase):
+
+    URL_DAEMON = '/api/daemon'
+
+    @classmethod
+    def setup_server(cls):
+        cls.setup_controllers([Daemon])
+
+    def test_daemon_action(self):
+        msg = "Scheduled to stop crash.b78cd1164a1b on host 'hostname'"
+
+        with patch_orch(True) as fake_client:
+            fake_client.daemons.action.return_value = msg
+            payload = {
+                'action': 'restart',
+                'container_image': None
+            }
+            self._put(f'{self.URL_DAEMON}/crash.b78cd1164a1b', payload, version=APIVersion(0, 1))
+            self.assertJsonBody(msg)
+            self.assertStatus(200)
+
+    def test_daemon_invalid_action(self):
+        payload = {
+            'action': 'invalid',
+            'container_image': None
+        }
+        with patch_orch(True):
+            self._put(f'{self.URL_DAEMON}/crash.b78cd1164a1b', payload, version=APIVersion(0, 1))
+            self.assertJsonBody({
+                'detail': 'Daemon action "invalid" is either not valid or not supported.',
+                'code': 'invalid_daemon_action',
+                'component': None
+            })
+            self.assertStatus(400)
index b21dc0fffc930c2ec5fa22c235e7520ba2aa5c3d..0f55ce52247c117fea36972700a8daace03f60be 100644 (file)
@@ -1,42 +1,15 @@
-import contextlib
 import unittest
-from typing import List, Optional
 from unittest import mock
 
-from orchestrator import HostSpec, InventoryHost
+from orchestrator import HostSpec
 
 from .. import mgr
 from ..controllers._version import APIVersion
 from ..controllers.host import Host, HostUi, get_device_osd_map, get_hosts, get_inventories
-from ..tests import ControllerTestCase
+from ..tests import ControllerTestCase, patch_orch
 from ..tools import NotificationQueue, TaskManager
 
 
-@contextlib.contextmanager
-def patch_orch(available: bool, missing_features: Optional[List[str]] = None,
-               hosts: Optional[List[HostSpec]] = None,
-               inventory: Optional[List[dict]] = None):
-    with mock.patch('dashboard.controllers.orchestrator.OrchClient.instance') as instance:
-        fake_client = mock.Mock()
-        fake_client.available.return_value = available
-        fake_client.get_missing_features.return_value = missing_features
-
-        if hosts is not None:
-            fake_client.hosts.list.return_value = hosts
-
-        if inventory is not None:
-            def _list_inventory(hosts=None, refresh=False):  # pylint: disable=unused-argument
-                inv_hosts = []
-                for inv_host in inventory:
-                    if hosts is None or inv_host['name'] in hosts:
-                        inv_hosts.append(InventoryHost.from_json(inv_host))
-                return inv_hosts
-            fake_client.inventory.list.side_effect = _list_inventory
-
-        instance.return_value = fake_client
-        yield fake_client
-
-
 class HostControllerTest(ControllerTestCase):
     URL_HOST = '/api/host'