From: Abhishek Desai Date: Tue, 21 Apr 2026 06:29:18 +0000 (+0530) Subject: mgr/dashboard : Fix RGW restart/stop issue X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=04db68a4ed4ddc31ff9ab509e8fa50d1589f7948;p=ceph.git mgr/dashboard : Fix RGW restart/stop issue fixes : https://tracker.ceph.com/issues/76158 Signed-off-by: Abhishek Desai --- diff --git a/src/pybind/mgr/dashboard/controllers/daemon.py b/src/pybind/mgr/dashboard/controllers/daemon.py index d5c288131b92..9dc6521a0af4 100644 --- a/src/pybind/mgr/dashboard/controllers/daemon.py +++ b/src/pybind/mgr/dashboard/controllers/daemon.py @@ -6,7 +6,7 @@ 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 . import APIDoc, APIRouter, EndpointDoc, RESTController from ._version import APIVersion from .orchestrator import raise_if_no_orchestrator @@ -16,9 +16,21 @@ from .orchestrator import raise_if_no_orchestrator class Daemon(RESTController): @raise_if_no_orchestrator([OrchFeature.DAEMON_ACTION]) @handle_orchestrator_error('daemon') + @EndpointDoc( + '', + parameters={ + 'force': ( + bool, + 'When true, force stops/restarts (bypasses ok-to-stop warnings; e.g. RGW, ' + 'NFS, SMB, NVMe-oF, monitoring daemons).', + True, + False, + ), + }, + ) @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL) def set(self, daemon_name: str, action: str = '', - container_image: Optional[str] = None): + container_image: Optional[str] = None, force: bool = False): if action not in ['start', 'stop', 'restart', 'redeploy']: raise DashboardException( @@ -29,7 +41,8 @@ class Daemon(RESTController): container_image = None orch = OrchClient.instance() - res = orch.daemons.action(action=action, daemon_name=daemon_name, image=container_image) + res = orch.daemons.action(action=action, daemon_name=daemon_name, image=container_image, + force=force) return res @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST]) 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 2564a8430475..3b7f0cb1dcba 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 @@ -5,15 +5,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import _ from 'lodash'; import { PipesModule } from '~/app/shared/pipes/pipes.module'; import { ToastrModule } from 'ngx-toastr'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { CephModule } from '~/app/ceph/ceph.module'; import { CoreModule } from '~/app/core/core.module'; 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 { PaginateObservable } from '~/app/shared/api/paginate.model'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; import { SharedModule } from '~/app/shared/shared.module'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { DaemonAction } from '~/app/shared/models/service.interface'; +import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; import { configureTestBed } from '~/testing/unit-test-helper'; import { ServiceDaemonListComponent } from './service-daemon-list.component'; @@ -190,24 +195,117 @@ describe('ServiceDaemonListComponent', () => { 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']) { + const daemonService = TestBed.inject(DaemonService); + const modalService = TestBed.inject(ModalCdsService); + const showSpy = spyOn(modalService, 'show'); + const actionSpy = spyOn(daemonService, 'action').and.returnValue(of({ body: 'ok' } as any)); + + component.daemonAction(DaemonAction.START); + expect(showSpy).not.toHaveBeenCalled(); + expect(actionSpy).toHaveBeenCalledWith(daemon.daemon_name, DaemonAction.START, undefined); + + actionSpy.calls.reset(); + component.daemonAction(DaemonAction.REDEPLOY); + expect(showSpy).not.toHaveBeenCalled(); + expect(actionSpy).toHaveBeenCalledWith(daemon.daemon_name, DaemonAction.REDEPLOY, undefined); + + for (const action of [DaemonAction.STOP, DaemonAction.RESTART] as const) { + actionSpy.calls.reset(); + showSpy.calls.reset(); + actionSpy.and.returnValue(of({ body: 'scheduled' } as any)); component.daemonAction(action); - expect(component['daemonService'].action).toHaveBeenCalledWith(daemon.daemon_name, action); + expect(showSpy).not.toHaveBeenCalled(); + expect(actionSpy).toHaveBeenCalledWith(daemon.daemon_name, action, undefined); + } + }); + + it('should open delete-confirmation modal for restart when daemon type needs orchestrator force', () => { + const modalService = TestBed.inject(ModalCdsService); + const showSpy = spyOn(modalService, 'show'); + const daemonService = TestBed.inject(DaemonService); + spyOn(daemonService, 'action').and.returnValue(of({ body: 'scheduled' } as any)); + const rgw = { + hostname: 'h1', + daemon_id: 'x', + daemon_type: 'rgw', + daemon_name: 'rgw.foo.host', + status_desc: 'running' + }; + component.selection.selected = [rgw]; + component.daemonAction(DaemonAction.RESTART); + expect(showSpy).toHaveBeenCalledWith( + DeleteConfirmationModalComponent, + jasmine.objectContaining({ + impact: DeletionImpact.medium, + itemNames: ['rgw.foo.host'], + actionDescription: DaemonAction.RESTART + }) + ); + const modalConfig = showSpy.calls.mostRecent().args[1] as { + infoMessage?: string; + submitActionObservable: () => Observable; + }; + expect(modalConfig.infoMessage).toContain('rgw'); + expect(modalConfig.infoMessage).toContain('orchestrator force option'); + + expect(daemonService.action).not.toHaveBeenCalled(); + modalConfig.submitActionObservable().subscribe(); + expect(daemonService.action).toHaveBeenCalledWith('rgw.foo.host', DaemonAction.RESTART, true); + }); + + it('should include daemon_type in modal infoMessage for each orchestrator-force type', () => { + const modalService = TestBed.inject(ModalCdsService); + const showSpy = spyOn(modalService, 'show'); + const daemonService = TestBed.inject(DaemonService); + spyOn(daemonService, 'action').and.returnValue(of({ body: 'ok' } as any)); + + for (const daemonType of ['nfs', 'grafana', 'alertmanager'] as const) { + showSpy.calls.reset(); + component.selection.selected = [ + { + hostname: 'h1', + daemon_id: 'id', + daemon_type: daemonType, + daemon_name: `${daemonType}.host`, + status_desc: 'running' + } + ]; + component.daemonAction(DaemonAction.STOP); + const cfg = showSpy.calls.mostRecent().args[1] as { infoMessage?: string }; + expect(cfg.infoMessage).toContain(daemonType); + expect(cfg.infoMessage).toContain('orchestrator force option'); } }); + it('should not show force modal for stop/restart on osd and other types outside the force list', () => { + const modalService = TestBed.inject(ModalCdsService); + const showSpy = spyOn(modalService, 'show'); + const daemonService = TestBed.inject(DaemonService); + spyOn(daemonService, 'action').and.returnValue(of({ body: 'ok' } as any)); + const crash = { + hostname: 'h1', + daemon_id: 'uuid', + daemon_type: 'crash', + daemon_name: 'crash.h1', + status_desc: 'running' + }; + component.selection.selected = [crash]; + component.daemonAction(DaemonAction.STOP); + expect(showSpy).not.toHaveBeenCalled(); + expect(daemonService.action).toHaveBeenCalledWith('crash.h1', DaemonAction.STOP, undefined); + }); + it('should disable daemon actions', () => { const daemon = { daemon_type: 'osd', status_desc: 'running' }; - const states = { - start: true, - stop: false, - restart: false, - redeploy: false + const states: Record = { + [DaemonAction.START]: true, + [DaemonAction.STOP]: false, + [DaemonAction.RESTART]: false, + [DaemonAction.REDEPLOY]: false }; const expectBool = (toExpect: boolean, arg: boolean) => { if (toExpect === true) { @@ -218,15 +316,15 @@ describe('ServiceDaemonListComponent', () => { }; component.selection.selected = [daemon]; - for (const action of ['start', 'stop', 'restart', 'redeploy']) { + for (const action of Object.values(DaemonAction)) { expectBool(states[action], component.actionDisabled(action)); } daemon.status_desc = 'stopped'; - states.start = false; - states.stop = true; + states[DaemonAction.START] = false; + states[DaemonAction.STOP] = true; component.selection.selected = [daemon]; - for (const action of ['start', 'stop', 'restart', 'redeploy']) { + for (const action of Object.values(DaemonAction)) { expectBool(states[action], component.actionDisabled(action)); } }); @@ -236,18 +334,18 @@ describe('ServiceDaemonListComponent', () => { daemon_type: 'mgr', status_desc: 'running' }; - for (const action of ['start', 'stop', 'restart', 'redeploy']) { + for (const action of Object.values(DaemonAction)) { expect(component.actionDisabled(action)).toBeTruthy(); } daemon.daemon_type = 'mon'; - for (const action of ['start', 'stop', 'restart', 'redeploy']) { + for (const action of Object.values(DaemonAction)) { expect(component.actionDisabled(action)).toBeTruthy(); } }); it('should disable daemon actions if no selection', () => { component.selection.selected = []; - for (const action of ['start', 'stop', 'restart', 'redeploy']) { + for (const action of Object.values(DaemonAction)) { expect(component.actionDisabled(action)).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 d63d4b804a51..0a91fe50df2c 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 @@ -1,4 +1,4 @@ -import { HttpParams } from '@angular/common/http'; +import { HttpParams, HttpResponse } from '@angular/common/http'; import { AfterViewInit, ChangeDetectorRef, @@ -17,7 +17,7 @@ import { import _ from 'lodash'; import { Observable, Subscription } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { take, tap } from 'rxjs/operators'; import { CephServiceService } from '~/app/shared/api/ceph-service.service'; import { DaemonService } from '~/app/shared/api/daemon.service'; @@ -33,11 +33,14 @@ import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data 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 { CephServiceSpec, DaemonAction } from '~/app/shared/models/service.interface'; import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; 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'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; @Component({ selector: 'cd-service-daemon-list', @@ -95,6 +98,21 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI hasOrchestrator = false; showDocPanel = false; + private static readonly DAEMON_ACTIONS_NEED_ORCHESTRATOR_FORCE = new Set([ + DaemonAction.STOP, + DaemonAction.RESTART + ]); + + private static readonly DAEMON_TYPES_NEED_ORCHESTRATOR_FORCE = new Set([ + 'alertmanager', + 'grafana', + 'nfs', + 'nvmeof', + 'prometheus', + 'rgw', + 'smb' + ]); + private daemonsTable: TableComponent; private daemonsTableTplsSub: Subscription; private serviceSub: Subscription; @@ -109,6 +127,7 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI private authStorageService: AuthStorageService, private daemonService: DaemonService, private notificationService: NotificationService, + private modalService: ModalCdsService, private cdRef: ChangeDetectorRef ) {} @@ -118,30 +137,30 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI { permission: 'update', icon: Icons.start, - click: () => this.daemonAction('start'), + click: () => this.daemonAction(DaemonAction.START), name: this.actionLabels.START, - disable: () => this.actionDisabled('start') + disable: () => this.actionDisabled(DaemonAction.START) }, { permission: 'update', icon: Icons.stop, - click: () => this.daemonAction('stop'), + click: () => this.daemonAction(DaemonAction.STOP), name: this.actionLabels.STOP, - disable: () => this.actionDisabled('stop') + disable: () => this.actionDisabled(DaemonAction.STOP) }, { permission: 'update', icon: Icons.restart, - click: () => this.daemonAction('restart'), + click: () => this.daemonAction(DaemonAction.RESTART), name: this.actionLabels.RESTART, - disable: () => this.actionDisabled('restart') + disable: () => this.actionDisabled(DaemonAction.RESTART) }, { permission: 'update', icon: Icons.deploy, - click: () => this.daemonAction('redeploy'), + click: () => this.daemonAction(DaemonAction.REDEPLOY), name: this.actionLabels.REDEPLOY, - disable: () => this.actionDisabled('redeploy') + disable: () => this.actionDisabled(DaemonAction.REDEPLOY) } ]; this.columns = [ @@ -306,41 +325,77 @@ export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewI this.selection = selection; } - daemonAction(actionType: string) { - this.daemonService - .action(this.selection.first()?.daemon_name, actionType) - .pipe(take(1)) - .subscribe({ - next: (resp) => { + daemonAction(actionType: DaemonAction) { + const daemon = this.selection.first(); + if (!daemon?.daemon_name) { + return; + } + if ( + ServiceDaemonListComponent.DAEMON_ACTIONS_NEED_ORCHESTRATOR_FORCE.has(actionType) && + ServiceDaemonListComponent.DAEMON_TYPES_NEED_ORCHESTRATOR_FORCE.has(daemon.daemon_type) + ) { + this.modalService.show(DeleteConfirmationModalComponent, { + impact: DeletionImpact.medium, + itemDescription: 'daemon', + itemNames: [daemon.daemon_name], + actionDescription: actionType, + infoMessage: $localize`Stopping or restarting this ${daemon.daemon_type}:daemonType: daemon can disrupt clients or services that depend on it. The orchestrator may require acknowledging risk, confirm only if you accept it. This action uses the orchestrator force option.`, + submitActionObservable: () => + this.executeDaemonActionObservable(daemon.daemon_name, actionType, true) + }); + return; + } + this.executeDaemonAction(daemon.daemon_name, actionType); + } + + /** + * Observable used by DeleteConfirmationModal (submitActionObservable) so the modal closes on + * complete; sync submitAction does not call hideModal(). + */ + private executeDaemonActionObservable( + daemonName: string, + actionType: DaemonAction, + force?: boolean + ): Observable> { + return this.daemonService.action(daemonName, actionType, force).pipe( + take(1), + tap({ + next: (resp: HttpResponse) => { this.notificationService.show( NotificationType.success, `Daemon ${actionType} scheduled`, - resp.body.toString() + resp.body?.toString() ?? '' ); }, - error: (resp) => { + error: (resp: unknown) => { + const err = resp as { body?: { toString?: () => string }; message?: string }; this.notificationService.show( NotificationType.error, 'Daemon action failed', - resp.body.toString() + err?.body?.toString?.() ?? err?.message ?? '' ); } - }); + }) + ); + } + + private executeDaemonAction(daemonName: string, actionType: DaemonAction, force?: boolean) { + this.executeDaemonActionObservable(daemonName, actionType, force).subscribe(); } - actionDisabled(actionType: string) { + actionDisabled(actionType: DaemonAction) { 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': + case DaemonAction.START: if (daemon.status_desc === 'running') { return true; } break; - case 'stop': + case DaemonAction.STOP: if (daemon.status_desc === 'stopped') { return true; } 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 index 0912e693139f..c874fb464bab 100644 --- 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 @@ -14,18 +14,18 @@ export class DaemonService { 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' - } - ); + action(daemonName: string, actionType: string, force?: boolean) { + const body: Record = { + action: actionType, + container_image: null + }; + if (force !== undefined) { + body['force'] = force; + } + return this.http.put(`${this.url}/${daemonName}`, body, { + headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }, + observe: 'response' + }); } list(daemonTypes: string[]): Observable { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts index 583c7b7edcf5..5dd8eea65b2a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts @@ -16,6 +16,13 @@ export enum CephCertificateStatus { invalid = 'invalid' } +export enum DaemonAction { + START = 'start', + STOP = 'stop', + RESTART = 'restart', + REDEPLOY = 'redeploy' +} + export const CERTIFICATE_STATUS_ICON_MAP: Record = { valid: 'success', expiring: 'warning', diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 4a43209cec27..f74e7c8c2d35 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -6775,6 +6775,11 @@ paths: type: string container_image: type: string + force: + default: false + description: When true, force stops/restarts (bypasses ok-to-stop + warnings; e.g. RGW, NFS, SMB, NVMe-oF, monitoring daemons). + type: boolean type: object responses: '200': diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index df059d42cb03..e2495a44444b 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -164,8 +164,9 @@ class OsdManager(ResourceManager): 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) + def action(self, daemon_name='', action='', image=None, force=False): + return self.api.daemon_action(daemon_name=daemon_name, action=action, image=image, + force=force) class UpgradeManager(ResourceManager): diff --git a/src/pybind/mgr/dashboard/tests/test_daemon.py b/src/pybind/mgr/dashboard/tests/test_daemon.py index 4ba23866d076..942e90228195 100644 --- a/src/pybind/mgr/dashboard/tests/test_daemon.py +++ b/src/pybind/mgr/dashboard/tests/test_daemon.py @@ -25,6 +25,24 @@ class DaemonTest(ControllerTestCase): self._put(f'{self.URL_DAEMON}/crash.b78cd1164a1b', payload, version=APIVersion(0, 1)) self.assertJsonBody(msg) self.assertStatus(200) + fake_client.daemons.action.assert_called_with( + action='restart', daemon_name='crash.b78cd1164a1b', image=None, force=False) + + def test_daemon_action_force(self): + msg = "Scheduled to stop rgw.foo.host on host 'hostname'" + + with patch_orch(True) as fake_client: + fake_client.daemons.action.return_value = msg + payload = { + 'action': 'stop', + 'container_image': None, + 'force': True + } + self._put(f'{self.URL_DAEMON}/rgw.foo.host', payload, version=APIVersion(0, 1)) + self.assertJsonBody(msg) + self.assertStatus(200) + fake_client.daemons.action.assert_called_with( + action='stop', daemon_name='rgw.foo.host', image=None, force=True) def test_daemon_invalid_action(self): payload = {