]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard : Fix RGW restart/stop issue 68493/head
authorAbhishek Desai <abhishek.desai1@ibm.com>
Tue, 21 Apr 2026 06:29:18 +0000 (11:59 +0530)
committerAbhishek Desai <abhishek.desai1@ibm.com>
Wed, 22 Apr 2026 04:23:36 +0000 (09:53 +0530)
fixes : https://tracker.ceph.com/issues/76158
Signed-off-by: Abhishek Desai <abhishek.desai1@ibm.com>
src/pybind/mgr/dashboard/controllers/daemon.py
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/shared/api/daemon.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/orchestrator.py
src/pybind/mgr/dashboard/tests/test_daemon.py

index d5c288131b92e5263f92f49607341ee5d4684ec7..9dc6521a0af4d24708fd41f589c2cb20429f5dcd 100644 (file)
@@ -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])
index 2564a843047591f53ed44cd38d3c5f81bb2b5e17..3b7f0cb1dcbae3f2900b54df30a1a7a81d899052 100644 (file)
@@ -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<unknown>;
+    };
+    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, boolean> = {
+      [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();
     }
   });
index d63d4b804a519758e43185c3b7c1e9030cb4c99a..0a91fe50df2c73cd4789a366401d1836003116da 100644 (file)
@@ -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>([
+    DaemonAction.STOP,
+    DaemonAction.RESTART
+  ]);
+
+  private static readonly DAEMON_TYPES_NEED_ORCHESTRATOR_FORCE = new Set<string>([
+    '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<HttpResponse<object>> {
+    return this.daemonService.action(daemonName, actionType, force).pipe(
+      take(1),
+      tap({
+        next: (resp: HttpResponse<object>) => {
           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;
           }
index 0912e693139f5d8687cb30a8485d55e98d2f7113..c874fb464babbae4e3580eb1409491043fbf76e9 100644 (file)
@@ -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<string, string | boolean | null> = {
+      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<Daemon[]> {
index 583c7b7edcf5baffac37ee6ee80b0163d0242e4a..5dd8eea65b2abeb9683eea64174b0c123c592508 100644 (file)
@@ -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<string, string> = {
   valid: 'success',
   expiring: 'warning',
index 4a43209cec27279c06079cba14bdb838ed9fc2b3..f74e7c8c2d352f009d0d9dabc6ade7d1310136f1 100644 (file)
@@ -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':
index df059d42cb0332bee1eb05e01972a25692121014..e2495a44444bee45e0f6595a937cbd40207baf63 100644 (file)
@@ -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):
index 4ba23866d076a2fc961ea9a481e678f3d4854444..942e902281951f1f5aa450bb3804b84a9e892dec 100644 (file)
@@ -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 = {