From 239f884c31976f3e716d6e33224a0efb6220288e Mon Sep 17 00:00:00 2001 From: Pere Diaz Bou Date: Mon, 17 Jan 2022 10:07:15 +0100 Subject: [PATCH] mgr/dashboard: perform daemon actions on cluster->services Signed-off-by: Pere Diaz Bou Fixes: https://tracker.ceph.com/issues/50322 --- .../mgr/dashboard/controllers/daemon.py | 33 ++++++ .../integration/cluster/services.po.ts | 11 +- .../workflow/09-services.e2e-spec.ts | 39 ++++++- .../service-daemon-list.component.html | 11 +- .../service-daemon-list.component.spec.ts | 105 ++++++++++++++++- .../service-daemon-list.component.ts | 106 +++++++++++++++++- .../service-details.component.spec.ts | 4 +- .../src/app/shared/api/daemon.service.spec.ts | 39 +++++++ .../src/app/shared/api/daemon.service.ts | 28 +++++ .../src/app/shared/constants/app.constants.ts | 32 +++++- .../table-actions.component.html | 2 +- .../table-actions.component.scss | 4 + .../src/app/shared/enum/icons.enum.ts | 2 + src/pybind/mgr/dashboard/openapi.yaml | 45 ++++++++ .../mgr/dashboard/services/orchestrator.py | 9 ++ src/pybind/mgr/dashboard/tests/__init__.py | 30 ++++- src/pybind/mgr/dashboard/tests/test_daemon.py | 41 +++++++ src/pybind/mgr/dashboard/tests/test_host.py | 31 +---- 18 files changed, 528 insertions(+), 44 deletions(-) create mode 100644 src/pybind/mgr/dashboard/controllers/daemon.py create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts create mode 100644 src/pybind/mgr/dashboard/tests/test_daemon.py diff --git a/src/pybind/mgr/dashboard/controllers/daemon.py b/src/pybind/mgr/dashboard/controllers/daemon.py new file mode 100644 index 00000000000..eeea5a32625 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/daemon.py @@ -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 diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/services.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/services.po.ts index 07bd3b58b8b..42734a81bf6 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/services.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/services.po.ts @@ -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); + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/09-services.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/09-services.e2e-spec.ts index 9b49c75aca6..eb4033c9a57 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/09-services.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/09-services.e2e-spec.ts @@ -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'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html index 3aeb04eade7..5b631453f8b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html @@ -23,9 +23,18 @@ + identifier="daemon_id" + (fetchData)="getDaemons($event)" + (updateSelection)="updateSelection($event)"> + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts index e9b49ce80ea..7e2148c1a26 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts @@ -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(); + } + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts index 1a17fdb61cc..adb2c1871df 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts @@ -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 = []; 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 + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts index 6be3b268952..109ef039fba 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts @@ -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 index 00000000000..787e5db7c93 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts @@ -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 index 00000000000..a66ed7edb19 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts @@ -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' + } + ); + } +} 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 5cb2f4e309b..cb67cada578 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 @@ -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`; } } 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 9896d56206d..9720c14e916 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 @@ -33,7 +33,7 @@ [routerLink]="useRouterLink(action)" [preserveFragment]="action.preserveFragment ? '' : null" [disabled]="disableSelectionAction(action)"> - + {{ action.name }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss index 11419cee61b..f996de72794 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss @@ -2,3 +2,7 @@ button.disabled { cursor: default !important; pointer-events: auto; } + +.action-icon { + padding-right: 1.5rem; +} 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 2478ecd1289..6b65f04e8cb 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 @@ -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 diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index c05831eb77f..00329b78033 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -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 diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index 2124a961f36..4cc0a399856 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -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' diff --git a/src/pybind/mgr/dashboard/tests/__init__.py b/src/pybind/mgr/dashboard/tests/__init__.py index 6ae01cca172..2859e89a259 100644 --- a/src/pybind/mgr/dashboard/tests/__init__.py +++ b/src/pybind/mgr/dashboard/tests/__init__.py @@ -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 index 00000000000..2008c8630f5 --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_daemon.py @@ -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) diff --git a/src/pybind/mgr/dashboard/tests/test_host.py b/src/pybind/mgr/dashboard/tests/test_host.py index b21dc0fffc9..0f55ce52247 100644 --- a/src/pybind/mgr/dashboard/tests/test_host.py +++ b/src/pybind/mgr/dashboard/tests/test_host.py @@ -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' -- 2.39.5