From 4e6c345c391eeb0b438ecf96655fc80a95ae26e9 Mon Sep 17 00:00:00 2001 From: Dnyaneshwari Talwekar Date: Mon, 15 Jun 2026 18:08:15 +0530 Subject: [PATCH] mgr/dashboard: Cephfs Mirroring Import Token Fixes: https://tracker.ceph.com/issues/77069 Signed-off-by: Dnyaneshwari Talwekar --- .../mgr/dashboard/controllers/cephfs.py | 15 +- .../cephfs-mirroring-list.component.spec.ts | 8 + .../cephfs-mirroring-list.component.ts | 11 + .../cephfs-setup-mirroring.component.html | 93 +++++++ .../cephfs-setup-mirroring.component.scss | 0 .../cephfs-setup-mirroring.component.spec.ts | 248 ++++++++++++++++++ .../cephfs-setup-mirroring.component.ts | 120 +++++++++ .../src/app/shared/api/cephfs.service.spec.ts | 10 + .../src/app/shared/enum/icons.enum.ts | 4 +- .../app/shared/forms/cd-validators.spec.ts | 28 ++ .../src/app/shared/forms/cd-validators.ts | 23 ++ .../shared/services/task-message.service.ts | 9 + src/pybind/mgr/dashboard/tests/test_cephfs.py | 25 ++ 13 files changed, 584 insertions(+), 10 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 61930c676bd..f8cfcd0e1d3 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -1322,19 +1322,18 @@ class CephFSMirror(RESTController): ) return json.loads(out) - @EndpointDoc("Enable snapshot mirroring for a filesystem", + @EndpointDoc("Enable mirroring for a filesystem", parameters={ 'fs_name': (str, 'File system name'), }, - responses={200: {}}) - @Endpoint('POST') + responses={201: {}}) + @RESTController.Collection('POST', path='/enable', status=201) @CreatePermission def enable(self, fs_name: str): - error_code, out, err = mgr.remote( - 'mirroring', 'snapshot_mirror_enable', fs_name) + error_code, out, err = mgr.remote('mirroring', 'snapshot_mirror_enable', fs_name) if error_code != 0: raise DashboardException( - msg=f'Failed to enable mirroring for filesystem: {err}', + msg=f'Failed to enable Cephfs mirroring: {err}', code=error_code, component='cephfs.mirror' ) @@ -1368,8 +1367,10 @@ class CephFSMirror(RESTController): responses={200: {}}) @CreatePermission def create(self, fs_name: str, token: str): + import urllib.parse + decoded_token = urllib.parse.unquote(token) error_code, out, err = mgr.remote( - 'mirroring', 'snapshot_mirror_peer_bootstrap_import', fs_name, token) + 'mirroring', 'snapshot_mirror_peer_bootstrap_import', fs_name, decoded_token) if error_code != 0: raise DashboardException( msg=f'Failed to import the token to create bootstrap peer: {err}', 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 f520578e235..626fb56ddfa 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 @@ -26,12 +26,20 @@ describe('CephfsMirroringListComponent', () => { }); it('should initialize columns correctly on ngOnInit', () => { + cephfsServiceMock.listDaemonStatus.mockReturnValue(of([])); component.ngOnInit(); expect(component.columns.length).toBe(6); expect(component.columns[0].prop).toBe('local_fs_name'); }); + it('should load daemon status on ngOnInit', () => { + cephfsServiceMock.listDaemonStatus.mockReturnValue(of([])); + component.daemonStatus$.subscribe(); + component.ngOnInit(); + expect(cephfsServiceMock.listDaemonStatus).toHaveBeenCalledTimes(1); + }); + it('should fetch daemon status when loadDaemonStatus() is called', () => { cephfsServiceMock.listDaemonStatus.mockReturnValue(of([])); component.daemonStatus$.subscribe(); 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 c986f9a34f8..4ef96dc6c26 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 @@ -17,6 +17,7 @@ import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/ceph }) export class CephfsMirroringListComponent implements OnInit { columns: CdTableColumn[]; + isSetupModalOpen = false; selection = new CdTableSelection(); private subject$ = new Subject(); @@ -49,6 +50,7 @@ export class CephfsMirroringListComponent implements OnInit { { name: $localize`Last sync`, prop: 'last_sync', flexGrow: 2 }, { name: $localize`Replicated paths`, prop: 'directory_count', flexGrow: 2 } ]; + this.loadDaemonStatus(); } loadDaemonStatus() { @@ -68,6 +70,15 @@ export class CephfsMirroringListComponent implements OnInit { this.loadDaemonStatus(); } + openSetupMirroring() { + this.isSetupModalOpen = true; + } + + closeSetupModal() { + this.isSetupModalOpen = false; + this.loadDaemonStatus(); + } + private buildRows(daemons: Daemon[]): MirroringRow[] { const rows: MirroringRow[] = []; if (!daemons?.length) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.html new file mode 100644 index 00000000000..f8723748273 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.html @@ -0,0 +1,93 @@ + + Setup filesystem mirroring + + +
+
Create a mirror relationship to replicate selected filesystem paths to another cluster.
+ +
+ +
+
Secure token required
+
To connect with the destination cluster, you'll need a secure token from the destination + cluster to establish the mirror relationship. Ask your administrator to generate a + bootstrap token from the destination cluster.
+
+
+
+ +
+
+ + + + + + @if (setupForm.showError('filesystem', formDir, 'required')) { + This field is required. + } + + + Select the filesystem whose data will be replicated to the destination cluster. Mirroring will be enabled on the selected filesystem. + +
+ +
+ Secure token + + + + Paste the token generated from the destination cluster that will receive the replicated data. + + + @if (setupForm.showError('token', formDir, 'required')) { + This field is required. + } + @if (setupForm.showError('token', formDir, 'invalidBase64Json')) { + Invalid token. Please enter a valid base64 bootstrap token. + } + +
+
+
+ + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.spec.ts new file mode 100644 index 00000000000..e362fe1b8e3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.spec.ts @@ -0,0 +1,248 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { of, throwError } from 'rxjs'; + +import { CephfsSetupMirroringComponent } from './cephfs-setup-mirroring.component'; +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { CephServiceService } from '~/app/shared/api/ceph-service.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; + +describe('CephfsSetupMirroringComponent', () => { + let component: CephfsSetupMirroringComponent; + let fixture: ComponentFixture; + + const cephfsServiceMock = { + list: jest.fn().mockReturnValue(of([])), + listDaemonStatus: jest.fn().mockReturnValue(of([])), + enableMirror: jest.fn().mockReturnValue(of(null)), + createBootstrapPeer: jest.fn().mockReturnValue(of(null)) + }; + + const cephServiceServiceMock = { + create: jest.fn().mockReturnValue(of(null)) + }; + + const taskWrapperMock = { + wrapTaskAroundCall: jest.fn().mockImplementation(({ call }) => call) + }; + + beforeEach(async () => { + jest.clearAllMocks(); + cephfsServiceMock.list.mockReturnValue(of([])); + cephfsServiceMock.listDaemonStatus.mockReturnValue(of([])); + cephfsServiceMock.enableMirror.mockReturnValue(of(null)); + cephfsServiceMock.createBootstrapPeer.mockReturnValue(of(null)); + + await TestBed.configureTestingModule({ + declarations: [CephfsSetupMirroringComponent], + imports: [ReactiveFormsModule], + providers: [ + CdFormBuilder, + { provide: CephfsService, useValue: cephfsServiceMock }, + { provide: CephServiceService, useValue: cephServiceServiceMock }, + { provide: TaskWrapperService, useValue: taskWrapperMock } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .overrideComponent(CephfsSetupMirroringComponent, { + set: { template: '' } + }) + .compileComponents(); + + fixture = TestBed.createComponent(CephfsSetupMirroringComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should fetch filesystems and populate the list', () => { + cephfsServiceMock.list.mockReturnValue( + of([ + { id: 1, mdsmap: { fs_name: 'myfs' } }, + { id: 2, mdsmap: { fs_name: 'backup' } } + ]) + ); + fixture.detectChanges(); + + expect(cephfsServiceMock.list).toHaveBeenCalled(); + expect(cephfsServiceMock.listDaemonStatus).toHaveBeenCalled(); + expect(component.filesystems).toEqual([ + { id: 1, name: 'myfs' }, + { id: 2, name: 'backup' } + ]); + }); + + it('should filter out filesystems that already have mirror peers', () => { + cephfsServiceMock.list.mockReturnValue( + of([ + { id: 1, mdsmap: { fs_name: 'myfs' } }, + { id: 2, mdsmap: { fs_name: 'mirrored-fs' }, mirror_info: { peers: { uuid: {} } } }, + { id: 3, mdsmap: { fs_name: 'backup' } } + ]) + ); + cephfsServiceMock.listDaemonStatus.mockReturnValue( + of([ + { + daemon_id: 1, + filesystems: [ + { + filesystem_id: 4, + name: 'daemon-mirrored-fs', + directory_count: 0, + peers: [{ uuid: 'peer-1', remote: {}, stats: {} }], + id: '' + } + ] + } + ]) + ); + fixture.detectChanges(); + + expect(component.filesystems).toEqual([ + { id: 1, name: 'myfs' }, + { id: 3, name: 'backup' } + ]); + }); + + it('should fall back to fs- when mdsmap.fs_name is missing', () => { + cephfsServiceMock.list.mockReturnValue(of([{ id: 5, mdsmap: {} }])); + fixture.detectChanges(); + + expect(component.filesystems).toEqual([{ id: 5, name: 'fs-5' }]); + }); + }); + + describe('form validation', () => { + beforeEach(() => fixture.detectChanges()); + + it('should be invalid when empty', () => { + expect(component.setupForm.invalid).toBe(true); + }); + + it('should be valid when filesystem and token are filled', () => { + component.setupForm.setValue({ filesystem: 'myfs', token: 'eyJrZXkiOiJ2YWx1ZSJ9' }); + expect(component.setupForm.valid).toBe(true); + }); + + it('should be invalid when filesystem is missing', () => { + component.setupForm.setValue({ filesystem: '', token: 'eyJrZXkiOiJ2YWx1ZSJ9' }); + expect(component.setupForm.invalid).toBe(true); + }); + + it('should be invalid when token is missing', () => { + component.setupForm.setValue({ filesystem: 'myfs', token: '' }); + expect(component.setupForm.invalid).toBe(true); + }); + + it('should be invalid when token is not valid base64 JSON', () => { + component.setupForm.setValue({ filesystem: 'myfs', token: 'not-a-valid-token' }); + expect(component.setupForm.controls['token'].hasError('invalidBase64Json')).toBe(true); + expect(component.setupForm.invalid).toBe(true); + }); + + it('should accept a valid bootstrap token with surrounding whitespace', () => { + const token = btoa(JSON.stringify({ fsid: 'abc', key: 'value' })); + component.setupForm.setValue({ filesystem: 'myfs', token: ` ${token} ` }); + expect(component.setupForm.valid).toBe(true); + }); + }); + + describe('onSetupMirroring', () => { + beforeEach(() => fixture.detectChanges()); + + it('should not submit when form is invalid', () => { + component.onSetupMirroring(); + expect(taskWrapperMock.wrapTaskAroundCall).not.toHaveBeenCalled(); + }); + + it('should expose localized submit button text', () => { + expect(component.submitText).toBe('Setup mirroring'); + + component.isSubmitting = true; + expect(component.submitText).toBe('Setting up...'); + }); + + it('should deploy service, enable mirror, create peer, and emit on success', () => { + const emitSpy = jest.spyOn(component.mirroringSetup, 'emit'); + component.setupForm.setValue({ filesystem: 'myfs', token: ' eyJrZXkiOiJ2YWx1ZSJ9 ' }); + + component.onSetupMirroring(); + + expect(cephServiceServiceMock.create).toHaveBeenCalledWith({ service_type: 'cephfs-mirror' }); + expect(cephfsServiceMock.enableMirror).toHaveBeenCalledWith('myfs'); + expect(cephfsServiceMock.createBootstrapPeer).toHaveBeenCalledWith( + 'myfs', + 'eyJrZXkiOiJ2YWx1ZSJ9' + ); + expect(taskWrapperMock.wrapTaskAroundCall).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ name: 'cephfs/mirroring/setup' }) + }) + ); + expect(component.isSubmitting).toBe(false); + expect(emitSpy).toHaveBeenCalledWith({ filesystem: 'myfs' }); + }); + + it('should strip all whitespace from the token before creating the peer', () => { + component.setupForm.setValue({ + filesystem: 'myfs', + token: ' eyJr\nZXkiOiJ2YWx1ZSJ9 ' + }); + + component.onSetupMirroring(); + + expect(cephfsServiceMock.createBootstrapPeer).toHaveBeenCalledWith( + 'myfs', + 'eyJrZXkiOiJ2YWx1ZSJ9' + ); + }); + + it('should reset isSubmitting on error without emitting', () => { + const emitSpy = jest.spyOn(component.mirroringSetup, 'emit'); + const setErrorsSpy = jest.spyOn(component.setupForm, 'setErrors'); + taskWrapperMock.wrapTaskAroundCall.mockReturnValue(throwError(() => new Error('fail'))); + component.setupForm.setValue({ filesystem: 'myfs', token: 'eyJrZXkiOiJ2YWx1ZSJ9' }); + + component.onSetupMirroring(); + + expect(component.isSubmitting).toBe(false); + expect(setErrorsSpy).toHaveBeenCalledWith({ cdSubmitButton: true }); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should set isSubmitting to true before API call completes', () => { + component.setupForm.setValue({ filesystem: 'myfs', token: 'eyJrZXkiOiJ2YWx1ZSJ9' }); + + const submittingDuringCall: boolean[] = []; + taskWrapperMock.wrapTaskAroundCall.mockImplementation(({ call }) => { + submittingDuringCall.push(component.isSubmitting); + return call; + }); + + component.onSetupMirroring(); + expect(submittingDuringCall[0]).toBe(true); + }); + }); + + describe('closeModal', () => { + beforeEach(() => fixture.detectChanges()); + + it('should emit close and reset form', () => { + const closeSpy = jest.spyOn(component.close, 'emit'); + component.setupForm.setValue({ filesystem: 'myfs', token: 'eyJrZXkiOiJ2YWx1ZSJ9' }); + component.isSubmitting = true; + + component.closeModal(); + + expect(closeSpy).toHaveBeenCalled(); + expect(component.isSubmitting).toBe(false); + expect(component.setupForm.value).toEqual({ filesystem: null, token: null }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.ts new file mode 100644 index 00000000000..3260228aa83 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.ts @@ -0,0 +1,120 @@ +import { Component, OnInit, Output, EventEmitter, inject } from '@angular/core'; +import { Validators } from '@angular/forms'; +import { concat, forkJoin, of } from 'rxjs'; +import { catchError, finalize, last } from 'rxjs/operators'; + +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { CephServiceService } from '~/app/shared/api/ceph-service.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { CephfsDetail, CephfsMirroringSetupEvent, Daemon } from '~/app/shared/models/cephfs.model'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; + +@Component({ + selector: 'cd-cephfs-setup-mirroring', + templateUrl: './cephfs-setup-mirroring.component.html', + styleUrls: ['./cephfs-setup-mirroring.component.scss'], + standalone: false +}) +export class CephfsSetupMirroringComponent extends CdForm implements OnInit { + @Output() mirroringSetup = new EventEmitter(); + + setupForm: CdFormGroup; + filesystems: { id: number; name: string }[] = []; + isSubmitting = false; + + private cephfsService = inject(CephfsService); + private cephServiceService = inject(CephServiceService); + private taskWrapper = inject(TaskWrapperService); + private fb = inject(CdFormBuilder); + + constructor() { + super(); + this.setupForm = this.fb.group({ + filesystem: ['', Validators.required], + token: ['', [Validators.required, CdValidators.base64Json()]] + }); + } + + ngOnInit(): void { + forkJoin({ + filesystems: this.cephfsService.list(), + daemons: this.cephfsService.listDaemonStatus().pipe(catchError(() => of([] as Daemon[]))) + }).subscribe(({ filesystems, daemons }) => { + const mirroredNames = this.getMirroredFilesystem(daemons, filesystems as CephfsDetail[]); + this.filesystems = (filesystems as CephfsDetail[]) + .map((fs) => ({ + id: fs.id, + name: fs.mdsmap?.fs_name || `fs-${fs.id}` + })) + .filter((fs) => !mirroredNames.has(fs.name)); + }); + } + + get submitText(): string { + return this.isSubmitting ? $localize`Setting up...` : $localize`Setup mirroring`; + } + + onSetupMirroring(): void { + if (this.setupForm.invalid) return; + + this.isSubmitting = true; + const { filesystem, token } = this.setupForm.value; + + const apiActionsObs = concat( + this.cephServiceService.create({ + service_type: 'cephfs-mirror' + }), + this.cephfsService.enableMirror(filesystem), + this.cephfsService.createBootstrapPeer(filesystem, token.replace(/\s/g, '')) + ).pipe(last()); + + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('cephfs/mirroring/setup', { + fsName: filesystem + }), + call: apiActionsObs + }) + .pipe(finalize(() => (this.isSubmitting = false))) + .subscribe({ + complete: () => { + this.mirroringSetup.emit({ filesystem }); + }, + error: () => { + this.setupForm.setErrors({ cdSubmitButton: true }); + } + }); + } + + closeModal(): void { + this.isSubmitting = false; + this.setupForm.reset(); + super.closeModal(); + } + + private getMirroredFilesystem(daemons: Daemon[], filesystems: CephfsDetail[]): Set { + const names = new Set(); + + for (const daemon of daemons || []) { + for (const fs of daemon.filesystems || []) { + if (fs.peers?.length) { + names.add(fs.name); + } + } + } + + for (const fs of filesystems || []) { + const fsName = fs.mdsmap?.fs_name || `fs-${fs.id}`; + const peers = fs.mirror_info?.peers ?? fs.cephfs?.mirror_info?.peers; + if (peers && Object.keys(peers).length > 0) { + names.add(fsName); + } + } + + return names; + } +} 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 90fa98845b4..a288fcaf26e 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 @@ -111,4 +111,14 @@ describe('CephfsService', () => { const req = httpTesting.expectOne(`api/cephfs/remove/${volName}`); expect(req.request.method).toBe('DELETE'); }); + + it('should create bootstrap peer without encoding body fields', () => { + service.createBootstrapPeer('my fs', 'token/with/special=chars').subscribe(); + const req = httpTesting.expectOne('api/cephfs/mirror'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + fs_name: 'my fs', + token: 'token/with/special=chars' + }); + }); }); 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 e95cd5fc44e..5a304fc43ac 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 @@ -176,9 +176,7 @@ export const ICON_TYPE = { rightArrow: 'caret--right', locked: 'locked', cloudMonitoring: 'cloud--monitoring', - trash: 'trash-can', - replicate: 'replicate', - share: 'share' + trash: 'trash-can' } as const; export const EMPTY_STATE_IMAGE = { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts index 0b065f464dd..7c7dc3c70a6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts @@ -949,5 +949,33 @@ describe('CdValidators', () => { expect(CdValidators.url(control)).toBeNull(); }); }); + + describe('base64Json', () => { + it('should return null for an empty value', () => { + const control = new UntypedFormControl(''); + expect(CdValidators.base64Json()(control)).toBeNull(); + }); + + it('should return null for valid base64-encoded JSON', () => { + const control = new UntypedFormControl(btoa(JSON.stringify({ key: 'value' }))); + expect(CdValidators.base64Json()(control)).toBeNull(); + }); + + it('should ignore surrounding whitespace', () => { + const token = btoa(JSON.stringify({ key: 'value' })); + const control = new UntypedFormControl(` ${token} `); + expect(CdValidators.base64Json()(control)).toBeNull(); + }); + + it('should return invalidBase64Json for invalid base64', () => { + const control = new UntypedFormControl('not-a-valid-token'); + expect(CdValidators.base64Json()(control)).toEqual({ invalidBase64Json: true }); + }); + + it('should return invalidBase64Json when decoded value is not JSON', () => { + const control = new UntypedFormControl(btoa('not-json')); + expect(CdValidators.base64Json()(control)).toEqual({ invalidBase64Json: true }); + }); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index 997564a983c..56538e78f44 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -292,6 +292,29 @@ export class CdValidators { }; } + /** + * Validator that checks whether the control value is a Base64 encoded JSON string. + * Whitespace characters are ignored. Skips validation when value is empty. + * @returns {ValidatorFn} Returns error map with `invalidBase64Json` if validation fails. + */ + static base64Json(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = (control.value || '').replace(/\s/g, ''); + if (isEmptyInputValue(value)) { + return null; + } + if (!/^[A-Za-z0-9+/]+=*$/.test(value)) { + return { invalidBase64Json: true }; + } + try { + JSON.parse(atob(value)); + return null; + } catch { + return { invalidBase64Json: true }; + } + }; + } + /** * Validate form control if condition is true with validators. * 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 d15f973a756..bc78bf2b5ff 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 @@ -321,6 +321,15 @@ export class TaskMessageService { this.rbd_mirroring.pool_peer, () => ({}) ), + // CephFS mirroring tasks + 'cephfs/mirroring/setup': this.newTaskMessage( + new TaskMessageOperation( + $localize`Setting up`, + $localize`set up`, + $localize`Successfully set up` + ), + (metadata) => $localize`filesystem mirroring for '${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/tests/test_cephfs.py b/src/pybind/mgr/dashboard/tests/test_cephfs.py index a4252f05d32..66ad55ce380 100644 --- a/src/pybind/mgr/dashboard/tests/test_cephfs.py +++ b/src/pybind/mgr/dashboard/tests/test_cephfs.py @@ -118,6 +118,31 @@ class CephFSMirrorTest(ControllerTestCase): mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_peer_bootstrap_create', fs_name, client_name, site_name) + def test_enable_success(self): + fs_name = 'test_fs' + mgr.remote = Mock(return_value=(0, '{}', '')) + + self._post('/api/cephfs/mirror/enable', { + 'fs_name': fs_name + }) + self.assertStatus(201) + self.assertJsonBody({}) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_enable', fs_name) + + def test_enable_error(self): + fs_name = 'test_fs' + error_message = 'Failed to enable mirroring' + mgr.remote = Mock(return_value=(1, '', error_message)) + + self._post('/api/cephfs/mirror/enable', { + 'fs_name': fs_name + }) + self.assertStatus(400) + response = self.json_body() + self.assertIn('Failed to enable Cephfs mirroring', response.get('detail', '')) + self.assertIn(error_message, response.get('detail', '')) + mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_enable', fs_name) + def test_create_success(self): fs_name = 'test_fs' token = 'bootstrap-token-12345' -- 2.47.3