)
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'
)
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}',
});
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();
})
export class CephfsMirroringListComponent implements OnInit {
columns: CdTableColumn[];
+ isSetupModalOpen = false;
selection = new CdTableSelection();
private subject$ = new Subject<void>();
{ name: $localize`Last sync`, prop: 'last_sync', flexGrow: 2 },
{ name: $localize`Replicated paths`, prop: 'directory_count', flexGrow: 2 }
];
+ this.loadDaemonStatus();
}
loadDaemonStatus() {
this.loadDaemonStatus();
}
+ openSetupMirroring() {
+ this.isSetupModalOpen = true;
+ }
+
+ closeSetupModal() {
+ this.isSetupModalOpen = false;
+ this.loadDaemonStatus();
+ }
+
private buildRows(daemons: Daemon[]): MirroringRow[] {
const rows: MirroringRow[] = [];
if (!daemons?.length) {
--- /dev/null
+<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>
--- /dev/null
+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 });
+ });
+ });
+});
--- /dev/null
+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;
+ }
+}
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'
+ });
+ });
});
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 = {
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 });
+ });
+ });
});
});
};
}
+ /**
+ * 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.
*
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]}`;
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'