From: Pedro Gonzalez Gomez Date: Wed, 1 Jul 2026 09:46:51 +0000 (+0200) Subject: mgr/dashboard: cephfs mirror add remove path action X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=91a3d49198245f2b4d6bbe37f65ea74085f187af;p=ceph.git mgr/dashboard: cephfs mirror add remove path action Signed-off-by: Pedro Gonzalez Gomez --- diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index afc146bd587..2906496c0e2 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -1481,6 +1481,24 @@ class CephFSMirror(RESTController): ) return json.loads(out) if out else {} + @EndpointDoc("Remove a directory path from snapshot mirroring", + parameters={ + 'fs_name': (str, 'File system name'), + 'path': (str, 'Directory path to remove from mirroring'), + }) + @Endpoint('DELETE', path='/directory', query_params=['fs_name', 'path']) + @DeletePermission + def remove_directory(self, fs_name: str, path: str): + error_code, out, err = mgr.remote( + 'mirroring', 'snapshot_mirror_remove_dir', fs_name, path) + if error_code != 0: + raise DashboardException( + msg=f'Failed to remove mirroring path {path}: {err}', + code=error_code, + component='cephfs.mirror' + ) + return json.loads(out) if out else {} + @EndpointDoc("List snapshot mirrored directories", parameters={ 'fs_name': (str, 'File system name'), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html index 65a529a2c4e..c0e4760fbb5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html @@ -8,8 +8,20 @@ [autoReload]="5000" [toolHeader]="true" [searchableObjects]="true" + [showInlineActions]="false" + selectionType="single" + (updateSelection)="updateSelection($event)" (fetchData)="loadMirrorPaths()"> + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts index 70c6ea373e4..dcd21dbceda 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts @@ -5,10 +5,16 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { of, throwError } from 'rxjs'; -import { MirrorStatusResponse } from '~/app/shared/models/cephfs.model'; import { CephfsService } from '~/app/shared/api/cephfs.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'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { MirrorStatusResponse } from '~/app/shared/models/cephfs.model'; import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service'; import { FormatterService } from '~/app/shared/services/formatter.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths.component'; import { HttpClientModule } from '@angular/common/http'; import { RouterTestingModule } from '@angular/router/testing'; @@ -113,7 +119,8 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => { beforeEach(async () => { const cephfsServiceMock = { - getMirrorStatus: jest.fn() + getMirrorStatus: jest.fn(), + removeMirrorDirectory: jest.fn().mockReturnValue(of({})) }; const snapshotScheduleServiceMock = { @@ -165,6 +172,22 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => { paramMap: of(convertToParamMap({ fsName: 'test-fs' })) } } + }, + { + provide: AuthStorageService, + useValue: { + getPermissions: () => ({ + cephfsMirror: { read: true, create: true, update: true, delete: true } + }) + } + }, + { + provide: ModalCdsService, + useValue: { show: jest.fn() } + }, + { + provide: TaskWrapperService, + useValue: { wrapTaskAroundCall: jest.fn((args) => args.call) } } ], schemas: [NO_ERRORS_SCHEMA], @@ -504,9 +527,9 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => { describe('onSelectionChange', () => { it('should update selection', () => { - const mockSelection = { selected: [{ path: '/test' }] } as any; + const mockSelection = new CdTableSelection([{ path: '/test' }]); - component.selection = mockSelection; + component.updateSelection(mockSelection); expect(component.selection).toBe(mockSelection); }); @@ -1021,4 +1044,22 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => { })); }); }); + describe('removePathModal', () => { + it('should open high-impact deletion modal for selected path', () => { + const modalService = TestBed.inject(ModalCdsService); + component.selection = new CdTableSelection([{ path: '/path1' }]); + component.fsName = 'test-fs'; + + component.removePathModal(); + + expect(modalService.show).toHaveBeenCalledWith( + DeleteConfirmationModalComponent, + expect.objectContaining({ + impact: DeletionImpact.high, + itemNames: ['/path1'], + actionDescription: 'remove' + }) + ); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts index 873af47ad86..63cbe6aab0c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts @@ -4,18 +4,27 @@ import { OnDestroy, TemplateRef, ViewChild, - ViewEncapsulation + ViewEncapsulation, + inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { CephfsService } from '~/app/shared/api/cephfs.service'; import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.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'; +import { Icons, ICON_TYPE } from '~/app/shared/enum/icons.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { ICON_TYPE } from '~/app/shared/enum/icons.enum'; +import { FinishedTask } from '~/app/shared/models/finished-task'; import { FormatterService } from '~/app/shared/services/formatter.service'; import { MirrorDirStatus, MirrorStatusResponse } from '~/app/shared/models/cephfs.model'; import { MirrorPathSchedule } from '~/app/shared/models/snapshot-schedule'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; interface MirrorPath { path: string; @@ -58,9 +67,18 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy @ViewChild('currentSyncSnapshotTpl', { static: true }) currentSyncSnapshotTpl!: TemplateRef; + private cephfsService = inject(CephfsService); + private route = inject(ActivatedRoute); + private formatterService = inject(FormatterService); + private authStorageService = inject(AuthStorageService); + private cdsModalService = inject(ModalCdsService); + private taskWrapper = inject(TaskWrapperService); + columns: CdTableColumn[] = []; mirrorPaths: MirrorPath[] = []; selection = new CdTableSelection(); + tableActions: CdTableAction[] = []; + permission = this.authStorageService.getPermissions().cephfsMirror; selectedPath: MirrorPath | null = null; sidePanelOpen = false; fsName: string = ''; @@ -71,15 +89,9 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy private subscriptions = new Subscription(); private mirrorPathsSubscription?: Subscription; - constructor( - private cephfsService: CephfsService, - private snapshotScheduleService: CephfsSnapshotScheduleService, - private route: ActivatedRoute, - private formatterService: FormatterService - ) {} - ngOnInit(): void { this.initializeColumns(); + this.initializeTableActions(); this.fetchFsName(); } @@ -124,6 +136,48 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy ]; } + initializeTableActions(): void { + this.tableActions = [ + { + name: $localize`Remove path`, + permission: 'delete', + icon: Icons.destroy, + click: () => this.removePathModal(), + disable: (selection: CdTableSelection) => !selection.hasSingleSelection + } + ]; + } + + updateSelection(selection: CdTableSelection): void { + this.selection = selection; + } + + removePathModal(): void { + const path = this.selection.first().path; + this.cdsModalService.show(DeleteConfirmationModalComponent, { + impact: DeletionImpact.high, + itemDescription: $localize`mirror path`, + itemNames: [path], + actionDescription: 'remove', + submitActionObservable: () => + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('cephfs/mirroring/path/remove', { + fsName: this.fsName, + path + }), + call: this.cephfsService.removeMirrorDirectory(this.fsName, path).pipe( + tap(() => { + if (this.selectedPath?.path === path) { + this.closeSidePanel(); + } + this.loadMirrorPaths(); + }) + ) + }) + }); + } + private fetchFsName(): void { this.subscriptions.add( this.route.parent?.paramMap.subscribe((paramMap) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts index 9038d279e71..eb7b4270d85 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts @@ -20,7 +20,7 @@ describe('CephfsMirroringListComponent', () => { }; const authStorageServiceMock = { - getPermissions: jest.fn().mockReturnValue({ cephfs: {} as Permission }) + getPermissions: jest.fn().mockReturnValue({ cephfsMirror: {} as Permission }) }; beforeEach(async () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts index ba93cdbcb80..f595b15b1d1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts @@ -33,7 +33,7 @@ export class CephfsMirroringListComponent implements OnInit, OnDestroy { tableActions: CdTableAction[]; isSetupModalOpen = false; selection = new CdTableSelection(); - permission = this.authStorageService.getPermissions().cephfs; + permission = this.authStorageService.getPermissions().cephfsMirror; isPrepareModalOpen = false; jumpInTiles: MirroringJumpInTile[] = []; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts index 103d5b5712e..ac58c15b8cd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -135,4 +135,17 @@ describe('CephfsService', () => { const req = httpTesting.expectOne('api/cephfs/mirror/directory/testfs'); expect(req.request.method).toBe('GET'); }); + + it('should remove mirror directory using query parameters', () => { + const path = '/volumes/Group1/A1/64446b51-d39b-436b-991f-0f8e713067ff'; + service.removeMirrorDirectory('testfs', path).subscribe(); + const req = httpTesting.expectOne( + (request) => + request.url === 'api/cephfs/mirror/directory' && + request.params.get('fs_name') === 'testfs' && + request.params.get('path') === path + ); + expect(req.request.method).toBe('DELETE'); + expect(req.request.body).toBeNull(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index 08d16198f87..73d6c572a27 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -184,4 +184,10 @@ export class CephfsService { listMirrorDirectories(@cdEncodeNot fsName: string): Observable { return this.http.get(`${this.baseURL}/mirror/directory/${fsName}`); } + + removeMirrorDirectory(@cdEncodeNot fsName: string, @cdEncodeNot path: string): Observable { + return this.http.delete(`${this.baseURL}/mirror/directory`, { + params: { fs_name: fsName, path } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts index 838385d840a..3fbfbe86ba8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts @@ -23,6 +23,7 @@ export class Permissions { rbdMirroring: Permission; rgw: Permission; cephfs: Permission; + cephfsMirror: Permission; manager: Permission; log: Permission; user: Permission; @@ -43,6 +44,7 @@ export class Permissions { this.rbdMirroring = new Permission(serverPermissions['rbd-mirroring']); this.rgw = new Permission(serverPermissions['rgw']); this.cephfs = new Permission(serverPermissions['cephfs']); + this.cephfsMirror = new Permission(serverPermissions['cephfs-mirror']); this.manager = new Permission(serverPermissions['manager']); this.log = new Permission(serverPermissions['log']); this.user = new Permission(serverPermissions['user']); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index bc78bf2b5ff..5027ee031b1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -330,6 +330,10 @@ export class TaskMessageService { ), (metadata) => $localize`filesystem mirroring for '${metadata.fsName}'` ), + 'cephfs/mirroring/path/remove': this.newTaskMessage( + this.commonOperations.remove, + (metadata) => $localize`mirror path '${metadata.path}' from '${metadata.fsName}'` + ), // RGW operations 'rgw/bucket/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => { return $localize`${metadata.bucket_names[0]}`; diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py index 392b3ab7999..59a72a0433d 100644 --- a/src/pybind/mgr/dashboard/services/access_control.py +++ b/src/pybind/mgr/dashboard/services/access_control.py @@ -272,8 +272,9 @@ POOL_MGR_ROLE = Role( # CephFS manager role provides all permissions for CephFS related scopes CEPHFS_MGR_ROLE = Role( - 'cephfs-manager', 'allows full permissions for the cephfs scope', { + 'cephfs-manager', 'allows full permissions for the cephfs and cephfs-mirror scopes', { Scope.CEPHFS: [_P.READ, _P.CREATE, _P.UPDATE, _P.DELETE], + Scope.CEPHFS_MIRROR: [_P.READ, _P.CREATE, _P.UPDATE, _P.DELETE], Scope.GRAFANA: [_P.READ], Scope.PROMETHEUS: [_P.READ] }) diff --git a/src/pybind/mgr/dashboard/tests/test_cephfs.py b/src/pybind/mgr/dashboard/tests/test_cephfs.py index ec243edb494..27a2a2b9c41 100644 --- a/src/pybind/mgr/dashboard/tests/test_cephfs.py +++ b/src/pybind/mgr/dashboard/tests/test_cephfs.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +import urllib.parse from collections import defaultdict try: @@ -278,6 +279,37 @@ class CephFSMirrorTest(ControllerTestCase): self.assertIn(error_message, response.get('detail', '')) mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_add_dir', fs_name, path) + def test_remove_directory_success(self): + fs_name = 'test_fs' + path = '/volumes/g1/sv1' + expected_result = {} + mock_output = json.dumps(expected_result) + mgr.remote = Mock(return_value=(0, mock_output, '')) + + self._delete( + f'/api/cephfs/mirror/directory?fs_name={fs_name}&path={urllib.parse.quote(path)}' + ) + self.assertStatus(200) + self.assertJsonBody(expected_result) + mgr.remote.assert_called_once_with( + 'mirroring', 'snapshot_mirror_remove_dir', fs_name, path) + + def test_remove_directory_error(self): + fs_name = 'test_fs' + path = '/volumes/g1/sv1' + error_message = 'directory not tracked' + mgr.remote = Mock(return_value=(1, '', error_message)) + + self._delete( + f'/api/cephfs/mirror/directory?fs_name={fs_name}&path={urllib.parse.quote(path)}' + ) + self.assertStatus(400) + response = self.json_body() + self.assertIn('Failed to remove mirroring path', response.get('detail', '')) + self.assertIn(error_message, response.get('detail', '')) + mgr.remote.assert_called_once_with( + 'mirroring', 'snapshot_mirror_remove_dir', fs_name, path) + def test_list_directories_success(self): fs_name = 'test_fs' expected_dirs = ['/volumes/g1/sv1', '/volumes/g2/sv2']