]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cephfs Mirroring Import Token import-token
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Mon, 15 Jun 2026 12:38:15 +0000 (18:08 +0530)
committerDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Fri, 26 Jun 2026 05:37:25 +0000 (11:07 +0530)
Fixes: https://tracker.ceph.com/issues/77069
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
13 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-setup-mirroring/cephfs-setup-mirroring.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/tests/test_cephfs.py

index 61930c676bd9097bc06c8371a382dd2604b60a1b..f8cfcd0e1d3610b9433255c3012464ef6c93a43f 100644 (file)
@@ -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}',
index f520578e235fe279a93c18a30ed4894da57dd80a..626fb56ddfa366638aef6ba9f6e91c066b541ad9 100644 (file)
@@ -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();
index c986f9a34f86e7f95d1fd4f4e4b629bcb40378d0..4ef96dc6c26135a7d4347f4b54617e1a78109093 100644 (file)
@@ -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<void>();
@@ -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 (file)
index 0000000..f872374
--- /dev/null
@@ -0,0 +1,93 @@
+<cds-modal size="md"
+           [open]="open"
+           (overlaySelected)="closeModal()">
+  <cds-modal-header (closeSelect)="closeModal()"
+                    i18n>Setup filesystem mirroring
+  </cds-modal-header>
+
+  <section cdsModalContent>
+    <div class="cds--type-body-compact-01 cds-mb-5"
+         i18n>Create a mirror relationship to replicate selected filesystem paths to another cluster.</div>
+
+    <div class="cds-mb-5">
+      <cd-alert-panel type="info">
+        <div [cdsStack]="'vertical'"
+             [gap]="2">
+          <div class="cds--type-heading-compact-01"
+               i18n>Secure token required</div>
+          <div class="cds--type-body-compact-01"
+               i18n>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.</div>
+        </div>
+      </cd-alert-panel>
+    </div>
+
+    <form [formGroup]="setupForm"
+          #formDir="ngForm"
+          novalidate>
+      <div class="form-item cds-mb-5">
+        <cds-select formControlName="filesystem"
+                    cdValidate
+                    #filesystemRef="cdValidate"
+                    label="Filesystem"
+                    id="filesystem"
+                    [invalid]="filesystemRef.isInvalid"
+                    [invalidText]="fsError"
+                    [helperText]="fsHelper"
+                    i18n>
+          <option value="">Select a filesystem</option>
+          <option *ngFor="let fs of filesystems"
+                  [value]="fs.name">{{ fs.name }}</option>
+        </cds-select>
+        <ng-template #fsError>
+          @if (setupForm.showError('filesystem', formDir, 'required')) {
+          <span i18n>This field is required.</span>
+          }
+        </ng-template>
+        <ng-template #fsHelper>
+          <span i18n>Select the filesystem whose data will be replicated to the destination cluster. Mirroring will be enabled on the selected filesystem.</span>
+        </ng-template>
+      </div>
+
+      <div class="form-item cds-mb-5">
+        <cds-textarea-label for="secureToken"
+                            [helperText]="tokenHelper"
+                            [invalid]="tokenRef.isInvalid"
+                            [invalidText]="tokenError"
+                            i18n>Secure token
+          <textarea cdsTextArea
+                    cdValidate
+                    #tokenRef="cdValidate"
+                    id="secureToken"
+                    formControlName="token"
+                    rows="5"
+                    cols="80"
+                    placeholder="Paste the token generated on the destination cluster."
+                    i18n-placeholder
+                    [invalid]="tokenRef.isInvalid"></textarea>
+        </cds-textarea-label>
+        <ng-template #tokenHelper>
+          <span i18n>Paste the token generated from the destination cluster that will receive the replicated data.</span>
+        </ng-template>
+        <ng-template #tokenError>
+          @if (setupForm.showError('token', formDir, 'required')) {
+          <span i18n>This field is required.</span>
+          }
+          @if (setupForm.showError('token', formDir, 'invalidBase64Json')) {
+          <span i18n>Invalid token. Please enter a valid base64 bootstrap token.</span>
+          }
+        </ng-template>
+      </div>
+    </form>
+  </section>
+
+  <cd-form-button-panel
+      (submitActionEvent)="onSetupMirroring()"
+      (backActionEvent)="closeModal()"
+      [form]="setupForm"
+      [disabled]="isSubmitting"
+      [submitText]="submitText"
+      [modalForm]="true">
+  </cd-form-button-panel>
+</cds-modal>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..e362fe1
--- /dev/null
@@ -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<CephfsSetupMirroringComponent>;
+
+  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-<id> 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 (file)
index 0000000..3260228
--- /dev/null
@@ -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<CephfsMirroringSetupEvent>();
+
+  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<string> {
+    const names = new Set<string>();
+
+    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;
+  }
+}
index 90fa98845b439649e507da5e23b30ec21934c7d0..a288fcaf26e00b8a7be20282555f9f668138a530 100644 (file)
@@ -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'
+    });
+  });
 });
index e95cd5fc44e095adb219b54045e9b52f339e3772..5a304fc43ac1e4380df0049ed6a9ae14d21fd33a 100644 (file)
@@ -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 = {
index 0b065f464dddf5d5f18b31e0c06107b72ad5ee14..7c7dc3c70a6ae78f2f19157880638607a257228f 100644 (file)
@@ -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 });
+      });
+    });
   });
 });
index 997564a983c3c15f2cd923ae20e4c00265e213ad..56538e78f442e9c94aef23916cb5490cfd7e3a8d 100644 (file)
@@ -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.
    *
index d15f973a756aeac39f7447bc1f4cb87dd8e6876f..bc78bf2b5ffdf46683342fed8842139b4f95f436 100644 (file)
@@ -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]}`;
index a4252f05d32089cdcc99cea4064d7767e5b808ac..66ad55ce3802df9384da6238f35e83209d84857b 100644 (file)
@@ -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'