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
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(
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])
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';
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) {
};
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));
}
});
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();
}
});
-import { HttpParams } from '@angular/common/http';
+import { HttpParams, HttpResponse } from '@angular/common/http';
import {
AfterViewInit,
ChangeDetectorRef,
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';
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',
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;
private authStorageService: AuthStorageService,
private daemonService: DaemonService,
private notificationService: NotificationService,
+ private modalService: ModalCdsService,
private cdRef: ChangeDetectorRef
) {}
{
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 = [
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;
}
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[]> {
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',
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':
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):
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 = {