)
return json.loads(out)
+ @EndpointDoc("Enable snapshot mirroring for a filesystem",
+ parameters={
+ 'fs_name': (str, 'File system name'),
+ },
+ responses={200: {}})
+ @Endpoint('POST')
+ @CreatePermission
+ def enable(self, fs_name: str):
+ 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}',
+ code=error_code,
+ component='cephfs.mirror'
+ )
+ return json.loads(out) if out else {}
+
@EndpointDoc("Create bootstrap token",
parameters={
'fs_name': (str, 'File system name'),
--- /dev/null
+<cds-modal size="md"
+ [open]="open"
+ (overlaySelected)="onClose()">
+ <cds-modal-header (closeSelect)="onClose()"
+ i18n>Ceph filesystem mirroring
+ </cds-modal-header>
+
+ <section cdsModalContent>
+ <div class="cds--type-heading-03 cds-mb-5"
+ i18n>Secure token successfully generated</div>
+
+ <cd-alert-panel type="success">
+ <div [cdsStack]="'vertical'"
+ [gap]="2">
+ <div class="cds--type-heading-compact-01"
+ i18n>Secure token generated</div>
+ <div class="cds--type-body-compact-01"
+ i18n>Bootstrap token generated successfully. Share this token with the source cluster administrator.</div>
+ </div>
+ </cd-alert-panel>
+
+ <div cdsRow class="cds-mt-5 cds-mb-5">
+ <div cdsCol [columnNumbers]="{lg: 16, md: 8, sm: 4}">
+ <label class="cds--label"
+ i18n>Secure token</label>
+ <div class="cds--text-area__wrapper">
+ <textarea class="cds--text-area"
+ id="secureToken"
+ rows="6"
+ cols="80"
+ readonly>{{ token }}</textarea>
+ </div>
+ <span class="cds--form__helper-text cds-mt-2"
+ i18n>Copy or download token to finish setup on the source cluster</span>
+ </div>
+ </div>
+
+ <button cdsButton="tertiary"
+ size="sm"
+ (click)="copyToken()"
+ i18n>
+ Copy token to share
+ <cd-icon type="copy"
+ class="cds-ml-2"></cd-icon>
+ </button>
+ </section>
+
+ <cds-modal-footer>
+ <button cdsButton="primary"
+ (click)="downloadToken()"
+ i18n>Download token</button>
+ </cds-modal-footer>
+</cds-modal>
--- /dev/null
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TextToDownloadService } from '~/app/shared/services/text-to-download.service';
+import { CephfsDownloadTokenComponent } from './cephfs-download-token.component';
+
+describe('CephfsDownloadTokenComponent', () => {
+ let component: CephfsDownloadTokenComponent;
+ let fixture: ComponentFixture<CephfsDownloadTokenComponent>;
+
+ const notificationServiceMock = {
+ show: jest.fn()
+ };
+
+ const textToDownloadServiceMock = {
+ download: jest.fn()
+ };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ await TestBed.configureTestingModule({
+ declarations: [CephfsDownloadTokenComponent],
+ providers: [
+ { provide: NotificationService, useValue: notificationServiceMock },
+ { provide: TextToDownloadService, useValue: textToDownloadServiceMock }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CephfsDownloadTokenComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('downloadToken', () => {
+ it('should download token file using siteName', () => {
+ component.token = 'my-token';
+ component.siteName = 'site-a';
+ component.filesystemName = 'fs1';
+ component.downloadToken();
+
+ expect(textToDownloadServiceMock.download).toHaveBeenCalledWith(
+ 'my-token',
+ 'cephfs-bootstrap-token-site-a.txt'
+ );
+ });
+
+ it('should fall back to filesystemName when siteName is empty', () => {
+ component.token = 'my-token';
+ component.siteName = '';
+ component.filesystemName = 'myfs';
+ component.downloadToken();
+
+ expect(textToDownloadServiceMock.download).toHaveBeenCalledWith(
+ 'my-token',
+ 'cephfs-bootstrap-token-myfs.txt'
+ );
+ });
+
+ it('should sanitize invalid filename characters', () => {
+ component.token = 'my-token';
+ component.siteName = 'Foo/Bar';
+ component.downloadToken();
+
+ expect(textToDownloadServiceMock.download).toHaveBeenCalledWith(
+ 'my-token',
+ 'cephfs-bootstrap-token-Foo_Bar.txt'
+ );
+ });
+
+ it('should not download when token is empty', () => {
+ component.token = '';
+ component.downloadToken();
+ expect(textToDownloadServiceMock.download).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('onClose', () => {
+ it('should emit closed event', () => {
+ const closedSpy = jest.spyOn(component.closed, 'emit');
+ component.onClose();
+ expect(closedSpy).toHaveBeenCalled();
+ });
+ });
+});
--- /dev/null
+import { Component, Input, Output, EventEmitter, inject } from '@angular/core';
+
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TextToDownloadService } from '~/app/shared/services/text-to-download.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+
+@Component({
+ selector: 'cd-cephfs-download-token',
+ templateUrl: './cephfs-download-token.component.html',
+ styleUrls: ['./cephfs-download-token.component.scss'],
+ standalone: false
+})
+export class CephfsDownloadTokenComponent {
+ @Input() open = false;
+ @Input() token = '';
+ @Input() siteName = '';
+ @Input() filesystemName = '';
+ @Output() closed = new EventEmitter<void>();
+
+ private notificationService = inject(NotificationService);
+ private textToDownloadService = inject(TextToDownloadService);
+
+ copyToken(): void {
+ const el = document.getElementById('secureToken') as HTMLTextAreaElement;
+ const text = el?.value || el?.textContent || '';
+ navigator.clipboard.writeText(text).then(
+ () =>
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Success`,
+ $localize`Copied text to the clipboard successfully.`
+ ),
+ () =>
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Error`,
+ $localize`Failed to copy text to the clipboard.`
+ )
+ );
+ }
+
+ downloadToken(): void {
+ if (!this.token) return;
+ const label = (this.siteName || this.filesystemName || 'token').replace(/[^a-zA-Z0-9_-]/g, '_');
+ this.textToDownloadService.download(this.token, `cephfs-bootstrap-token-${label}.txt`);
+ }
+
+ onClose(): void {
+ this.closed.emit();
+ }
+}
--- /dev/null
+@if (open && !generatedToken) {
+ <cds-modal size="md"
+ [open]="true"
+ (overlaySelected)="onCancel()">
+ <cds-modal-header (closeSelect)="onCancel()"
+ i18n>Ceph filesystem mirroring
+ </cds-modal-header>
+
+ <section cdsModalContent>
+ <div class="cds--type-heading-03 cds-mb-5"
+ i18n>Prepare cluster to receive data</div>
+
+ <div class="cds--type-body-compact-01 cds-mb-5"
+ i18n>Generate a bootstrap token to use on the source cluster and establish a mirroring relationship,
+ enabling this cluster to receive replicated data.</div>
+
+ <div class="cds-mb-5">
+ <cd-alert-panel type="info">
+ <div [cdsStack]="'vertical'"
+ [gap]="2">
+ <div class="cds--type-heading-compact-01"
+ i18n>How to use this secure token</div>
+ <div class="cds--type-body-compact-01"
+ i18n>Once you've generated the token, send it to the source cluster's administrator to finish
+ the setup.</div>
+ </div>
+ </cd-alert-panel>
+ </div>
+
+ <div class="cds-mb-5">
+ <form [formGroup]="tokenForm">
+ <div class="form-item cds-mb-5">
+ <cds-select formControlName="filesystem"
+ label="Destination filesystem"
+ i18n-label
+ helperText="Filesystem you want to receive data to from the source cluster. Mirroring will be enabled on this filesystem."
+ i18n-helperText
+ id="filesystem"
+ [invalid]="tokenForm.controls['filesystem'].invalid && tokenForm.controls['filesystem'].touched"
+ [invalidText]="fsError">
+ <option value="" i18n>Select a destination filesystem</option>
+ <option *ngFor="let fs of filesystems"
+ [value]="fs.name">{{ fs.name }}</option>
+ </cds-select>
+ <ng-template #fsError>
+ <span i18n>This field is required.</span>
+ </ng-template>
+ </div>
+
+ <div class="form-item cds-mb-5">
+ <div class="cds-input-group">
+ <div class="fit-content">
+ <cds-text-label cdRequiredField="Username"
+ i18n>
+ <input cdsText
+ value="client."
+ readonly
+ name="client_prefix" />
+ </cds-text-label>
+ </div>
+ <cds-text-label labelInputID="username"
+ [helperText]="usernameHelper"
+ [invalid]="tokenForm.controls['username'].invalid && tokenForm.controls['username'].touched"
+ [invalidText]="usernameError">
+ ​
+ <input cdsText
+ type="text"
+ id="username"
+ name="username"
+ formControlName="username"
+ placeholder="Enter or select an existing username"
+ i18n-placeholder
+ [invalid]="tokenForm.controls['username'].invalid && tokenForm.controls['username'].touched"
+ [ngbTypeahead]="searchUsername"
+ (focus)="usernameFocus$.next(tokenForm.controls['username'].value || '')"
+ #usernameTypeahead="ngbTypeahead">
+ </cds-text-label>
+ </div>
+ <ng-template #usernameHelper>
+ <span i18n>Ceph authx user. Enter a new username or select an existing one.</span>
+ </ng-template>
+ <ng-template #usernameError>
+ @if (tokenForm.controls['username'].hasError('required')) {
+ <span i18n>This field is required.</span>
+ }
+ @if (tokenForm.controls['username'].hasError('forbiddenClientPrefix')) {
+ <span i18n>Do not include 'client.' prefix. Enter only the entity suffix.</span>
+ }
+ @if (tokenForm.controls['username'].hasError('invalidChars')) {
+ <span i18n>Only letters, numbers, underscores, and hyphens are allowed.</span>
+ }
+ </ng-template>
+ </div>
+
+ <div class="form-item cds-mb-5">
+ <cds-text-label for="sitename"
+ cdRequiredField="Sitename"
+ [helperText]="sitenameHelper"
+ [invalid]="tokenForm.controls['sitename'].invalid && tokenForm.controls['sitename'].touched"
+ [invalidText]="sitenameError"
+ i18n>Sitename
+ <input cdsText
+ type="text"
+ id="sitename"
+ name="sitename"
+ formControlName="sitename"
+ placeholder="Enter a sitename"
+ i18n-placeholder
+ [invalid]="tokenForm.controls['sitename'].invalid && tokenForm.controls['sitename'].touched">
+ </cds-text-label>
+ <ng-template #sitenameHelper>
+ <span i18n>A unique name for this storage site. Used by the primary cluster to identify this replication target.</span>
+ </ng-template>
+ <ng-template #sitenameError>
+ <span i18n>This field is required.</span>
+ </ng-template>
+ </div>
+ </form>
+ </div>
+ </section>
+
+ <cd-form-button-panel
+ (submitActionEvent)="onGenerateToken()"
+ (backActionEvent)="onCancel()"
+ [form]="tokenForm"
+ [disabled]="tokenForm.invalid || isGenerating"
+ [submitText]="submitText"
+ [modalForm]="true">
+ </cd-form-button-panel>
+ </cds-modal>
+}
+
+@if (open && generatedToken) {
+ <cd-cephfs-download-token
+ [open]="true"
+ [token]="generatedToken"
+ [siteName]="tokenForm.value.sitename"
+ [filesystemName]="tokenForm.value.filesystem"
+ (closed)="onCancel()">
+ </cd-cephfs-download-token>
+}
--- /dev/null
+.fit-content {
+ width: fit-content;
+}
+
+.cds-input-group {
+ width: 100%;
+}
--- /dev/null
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { of, throwError } from 'rxjs';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ClusterService } from '~/app/shared/api/cluster.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephfsGenerateTokenComponent } from './cephfs-generate-token.component';
+
+describe('CephfsGenerateTokenComponent', () => {
+ let component: CephfsGenerateTokenComponent;
+ let fixture: ComponentFixture<CephfsGenerateTokenComponent>;
+
+ const cephfsServiceMock = {
+ list: jest.fn().mockReturnValue(of([])),
+ enableMirror: jest.fn().mockReturnValue(of(null)),
+ createBootstrapToken: jest.fn().mockReturnValue(of({ token: 'test-token' }))
+ };
+
+ const clusterServiceMock = {
+ listUser: jest.fn().mockReturnValue(of([]))
+ };
+
+ const taskWrapperMock = {
+ wrapTaskAroundCall: jest.fn().mockImplementation(({ call }) => call)
+ };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ await TestBed.configureTestingModule({
+ declarations: [CephfsGenerateTokenComponent],
+ imports: [ReactiveFormsModule],
+ providers: [
+ { provide: CephfsService, useValue: cephfsServiceMock },
+ { provide: ClusterService, useValue: clusterServiceMock },
+ { provide: TaskWrapperService, useValue: taskWrapperMock }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CephfsGenerateTokenComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create the component', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize the token form with required validators', () => {
+ expect(component.tokenForm).toBeTruthy();
+ expect(component.tokenForm.controls['filesystem']).toBeTruthy();
+ expect(component.tokenForm.controls['username']).toBeTruthy();
+ expect(component.tokenForm.controls['sitename']).toBeTruthy();
+
+ expect(component.tokenForm.controls['filesystem'].hasError('required')).toBe(true);
+ expect(component.tokenForm.controls['username'].hasError('required')).toBe(true);
+ expect(component.tokenForm.controls['sitename'].hasError('required')).toBe(true);
+ });
+
+ it('should load filesystems on init', fakeAsync(() => {
+ cephfsServiceMock.list.mockReturnValue(
+ of([
+ { id: 1, mdsmap: { fs_name: 'myfs' } },
+ { id: 2, mdsmap: { fs_name: 'otherfs' } }
+ ])
+ );
+
+ component.ngOnInit();
+ tick();
+
+ expect(component.filesystems).toEqual([
+ { id: 1, name: 'myfs' },
+ { id: 2, name: 'otherfs' }
+ ]);
+ }));
+
+ it('should fallback to fs-<id> when mdsmap has no fs_name', fakeAsync(() => {
+ cephfsServiceMock.list.mockReturnValue(of([{ id: 5, mdsmap: {} }]));
+
+ component.ngOnInit();
+ tick();
+
+ expect(component.filesystems[0].name).toBe('fs-5');
+ }));
+
+ it('should not generate token when form is invalid', () => {
+ component.onGenerateToken();
+
+ expect(component.isGenerating).toBe(false);
+ expect(cephfsServiceMock.enableMirror).not.toHaveBeenCalled();
+ });
+
+ it('should enable mirroring and create bootstrap token', fakeAsync(() => {
+ jest.spyOn(component.tokenGenerated, 'emit');
+ component.tokenForm.patchValue({
+ filesystem: 'myfs',
+ username: 'mirror-peer',
+ sitename: 'site-a'
+ });
+
+ component.onGenerateToken();
+ tick();
+
+ expect(cephfsServiceMock.enableMirror).toHaveBeenCalledWith('myfs');
+ expect(cephfsServiceMock.createBootstrapToken).toHaveBeenCalledWith(
+ 'myfs',
+ 'client.mirror-peer',
+ 'site-a'
+ );
+ expect(component.generatedToken).toBe('test-token');
+ expect(component.isGenerating).toBe(false);
+ expect(component.tokenGenerated.emit).toHaveBeenCalledWith('test-token');
+ }));
+
+ it('should abort when enableMirror fails', fakeAsync(() => {
+ cephfsServiceMock.enableMirror.mockReturnValue(throwError(() => new Error('enable failed')));
+ jest.spyOn(component.tokenGenerated, 'emit');
+ component.tokenForm.patchValue({
+ filesystem: 'myfs',
+ username: 'mirror-peer',
+ sitename: 'site-a'
+ });
+
+ component.onGenerateToken();
+ tick();
+
+ expect(cephfsServiceMock.createBootstrapToken).not.toHaveBeenCalled();
+ expect(component.generatedToken).toBe('');
+ expect(component.isGenerating).toBe(false);
+ expect(component.tokenGenerated.emit).not.toHaveBeenCalled();
+ }));
+
+ it('should not emit token when bootstrap response has no token', fakeAsync(() => {
+ cephfsServiceMock.createBootstrapToken.mockReturnValue(of({}));
+ jest.spyOn(component.tokenGenerated, 'emit');
+ component.tokenForm.patchValue({
+ filesystem: 'myfs',
+ username: 'mirror-peer',
+ sitename: 'site-a'
+ });
+
+ component.onGenerateToken();
+ tick();
+
+ expect(component.generatedToken).toBe('');
+ expect(component.isGenerating).toBe(false);
+ expect(component.tokenGenerated.emit).not.toHaveBeenCalled();
+ }));
+
+ it('should filter users by MDS capabilities when a filesystem is selected', fakeAsync(() => {
+ clusterServiceMock.listUser.mockReturnValue(
+ of([
+ { entity: 'client.admin', caps: { mds: 'allow *' } },
+ { entity: 'client.mirror-myfs', caps: { mds: 'allow r fsname=myfs' } },
+ { entity: 'client.mirror-other', caps: { mds: 'allow r fsname=otherfs' } }
+ ])
+ );
+
+ component.ngOnInit();
+ tick();
+
+ component.tokenForm.controls['filesystem'].setValue('myfs');
+ tick();
+
+ expect(component.filteredUsers).toEqual(['mirror-myfs']);
+ }));
+
+ it('should show no users when no filesystem is selected', fakeAsync(() => {
+ clusterServiceMock.listUser.mockReturnValue(
+ of([
+ { entity: 'client.user1', caps: { mds: 'allow r fsname=fs1' } },
+ { entity: 'client.user2', caps: { mds: 'allow r fsname=fs2' } }
+ ])
+ );
+
+ component.ngOnInit();
+ tick();
+
+ component.tokenForm.controls['filesystem'].setValue('');
+ tick();
+
+ expect(component.filteredUsers).toEqual([]);
+ }));
+
+ it('should emit cancelled and reset state on cancel', () => {
+ jest.spyOn(component.cancelled, 'emit');
+ component.generatedToken = 'some-token';
+ component.isGenerating = true;
+ component.tokenForm.patchValue({ filesystem: 'fs1', username: 'user1' });
+
+ component.onCancel();
+
+ expect(component.cancelled.emit).toHaveBeenCalled();
+ expect(component.generatedToken).toBe('');
+ expect(component.isGenerating).toBe(false);
+ });
+});
--- /dev/null
+import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, inject } from '@angular/core';
+import {
+ AbstractControl,
+ FormBuilder,
+ ValidationErrors,
+ ValidatorFn,
+ Validators
+} from '@angular/forms';
+import { merge, Observable, OperatorFunction, Subject } from 'rxjs';
+import { debounceTime, finalize, map, switchMap, takeUntil, tap } from 'rxjs/operators';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ClusterService } from '~/app/shared/api/cluster.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import {
+ BootstrapTokenResponse,
+ CephfsDetail,
+ CLIENT_PREFIX,
+ MAX_TYPEAHEAD_SUGGESTIONS,
+ VALID_USERNAME_PATTERN
+} from '~/app/shared/models/cephfs.model';
+import { CephAuthUser } from '~/app/shared/models/cluster.model';
+
+@Component({
+ selector: 'cd-cephfs-generate-token',
+ templateUrl: './cephfs-generate-token.component.html',
+ standalone: false,
+ styleUrls: ['./cephfs-generate-token.component.scss']
+})
+export class CephfsGenerateTokenComponent implements OnInit, OnDestroy {
+ @Input() open = false;
+ @Output() tokenGenerated = new EventEmitter<string>();
+ @Output() cancelled = new EventEmitter<void>();
+
+ filesystems: { id: number; name: string }[] = [];
+ filteredUsers: string[] = [];
+ isGenerating = false;
+ generatedToken = '';
+ usernameFocus$ = new Subject<string>();
+
+ private allClientUsers: CephAuthUser[] = [];
+ private destroy$ = new Subject<void>();
+
+ private cephfsService = inject(CephfsService);
+ private clusterService = inject(ClusterService);
+ private taskWrapper = inject(TaskWrapperService);
+ private fb = inject(FormBuilder);
+
+ private noClientPrefix: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
+ const value = (control.value ?? '').toString().trim();
+ if (!value) return null;
+ return value.startsWith(CLIENT_PREFIX) ? { forbiddenClientPrefix: true } : null;
+ };
+
+ private validUsername = (control: AbstractControl): ValidationErrors | null => {
+ const value = (control.value ?? '').toString();
+ if (!value) return null;
+ if (VALID_USERNAME_PATTERN.test(value)) return { invalidChars: true };
+ return null;
+ };
+
+ tokenForm = this.fb.group({
+ filesystem: ['', Validators.required],
+ username: ['', [Validators.required, this.noClientPrefix, this.validUsername]],
+ sitename: ['', Validators.required]
+ });
+
+ ngOnInit(): void {
+ this.loadFilesystems();
+ this.loadExistingUsers();
+ this.tokenForm.controls['filesystem'].valueChanges
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ this.updateFilteredUsers();
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ searchUsername: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) =>
+ merge(text$, this.usernameFocus$).pipe(
+ debounceTime(200),
+ map((term) =>
+ this.filteredUsers
+ .filter((u) => !term || u.toLowerCase().includes(term.toLowerCase()))
+ .slice(0, MAX_TYPEAHEAD_SUGGESTIONS)
+ )
+ );
+
+ get submitText(): string {
+ return this.isGenerating ? $localize`Generating...` : $localize`Generate token`;
+ }
+
+ onGenerateToken(): void {
+ if (this.tokenForm.invalid) return;
+
+ this.isGenerating = true;
+ const { filesystem, username, sitename } = this.tokenForm.value;
+ const fullEntity = `${CLIENT_PREFIX}${username}`;
+
+ const apiActions = this.cephfsService.enableMirror(filesystem).pipe(
+ switchMap(() =>
+ this.cephfsService.createBootstrapToken(filesystem, fullEntity, sitename).pipe(
+ tap((res: BootstrapTokenResponse) => {
+ const token = res?.token || res?.data || '';
+ if (!token) {
+ throw new Error('Bootstrap token missing from API response');
+ }
+ this.generatedToken = token;
+ })
+ )
+ )
+ );
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('mirroring/token/create', {
+ fsName: filesystem,
+ clientName: fullEntity,
+ siteName: sitename
+ }),
+ call: apiActions
+ })
+ .pipe(finalize(() => (this.isGenerating = false)))
+ .subscribe({
+ complete: () => {
+ if (this.generatedToken) {
+ this.tokenGenerated.emit(this.generatedToken);
+ }
+ },
+ error: () => {
+ this.generatedToken = '';
+ }
+ });
+ }
+
+ onCancel(): void {
+ this.cancelled.emit();
+ this.generatedToken = '';
+ this.isGenerating = false;
+ this.tokenForm.reset();
+ }
+
+ private loadFilesystems(): void {
+ this.cephfsService.list().subscribe((data: CephfsDetail[]) => {
+ this.filesystems = data.map((fs) => ({
+ id: fs.id,
+ name: fs.mdsmap?.fs_name || `fs-${fs.id}`
+ }));
+ });
+ }
+
+ private loadExistingUsers(): void {
+ this.clusterService.listUser().subscribe({
+ next: (users: CephAuthUser[]) => {
+ this.allClientUsers = (users || []).filter((u) => {
+ const entity = String(u.entity || u['user_entity'] || '');
+ return entity.startsWith(CLIENT_PREFIX);
+ });
+ this.updateFilteredUsers();
+ },
+ error: () => {
+ this.allClientUsers = [];
+ this.filteredUsers = [];
+ }
+ });
+ }
+
+ private updateFilteredUsers(): void {
+ const selectedFs = this.tokenForm.controls['filesystem'].value;
+ if (!selectedFs) {
+ this.filteredUsers = [];
+ return;
+ }
+ this.filteredUsers = this.allClientUsers
+ .filter((u) => {
+ const mdsCaps = u.caps?.mds || '';
+ return mdsCaps.includes(`fsname=${selectedFs}`);
+ })
+ .map((u) => String(u.entity || u['user_entity'] || '').slice(CLIENT_PREFIX.length));
+ }
+}
+++ /dev/null
-<div class="scroll-overflow">
- <div
- [cdsStack]="'vertical'"
- gap="3">
- <div
- class="cds--type-heading-03"
- i18n>Create or select entity</div>
- <p
- class="cds--type-body-01"
- i18n>
- Choose an existing CephFS mirroring entity or create a new one to be used for mirroring.
- </p>
- </div>
- <cds-tile>
- <div>
- <cds-radio-group [(ngModel)]="isCreatingNewEntity">
- <cds-radio
- [value]="true"
- (click)="onCreateEntitySelected()"
- i18n> Create new entity </cds-radio>
- <cds-radio
- [value]="false"
- (click)="onExistingEntitySelected()"
- i18n> Use existing entity </cds-radio>
- </cds-radio-group>
- </div>
-
- @if (isCreatingNewEntity) {
- @if (showCreateRequirementsWarning) {
- <cd-alert-panel
- type="warning"
- class="cds-ml-3 cds-mr-3"
- dismissible="true"
- (dismissed)="onDismissCreateRequirementsWarning()">
- <div
- [cdsStack]="'vertical'"
- gap="2">
- <div
- class="cds--type-heading-compact-01"
- i18n>Mirroring entity requirements</div>
- <div
- class="cds--type-body-compact-01"
- i18n>
- This entity will be used by the mirroring service to manage snapshots and replication.
- It will require the proper permissions to replicate data.
- </div>
- </div>
- </cd-alert-panel>
- }
-
- @if (showCreateCapabilitiesInfo) {
- <cd-alert-panel
- type="info"
- dismissible="true"
- (dismissed)="onDismissCreateCapabilitiesInfo()">
- <div
- [cdsStack]="'vertical'"
- gap="1">
- <div
- class="cds--type-heading-compact-01"
- i18n>Capabilities added automatically.</div>
- <div
- class="cds--type-body-compact-01 requirements-list"
- i18n>
- If you create a new entity, the following capabilities will be assigned automatically:
- </div>
- <ul class="list-disc">
- @for (cap of capabilities; track cap.name) {
- <li>
- {{ cap.name }}: {{ cap.permission }}
- </li>
- }
- </ul>
- </div>
- </cd-alert-panel>
- }
-
- <form
- name="entityForm"
- #formDir="ngForm"
- [formGroup]="entityForm"
- novalidate>
- <div
- [cdsStack]="'vertical'"
- gap="5"
- class="form-item form-item-append cds-mt-5">
- <cds-text-label
- labelInputID="user_entity"
- i18n
- cdRequiredField="Ceph entity"
- [helperText]="userEntityHelperText"
- [invalid]="!entityForm.controls['user_entity'].valid && (entityForm.controls['user_entity'].dirty)"
- [invalidText]="userEntityError"
- i18n-invalidText>
- Ceph entity
- <div
- [cdsStack]="'horizontal'"
- gap="0">
- <input
- cdsText
- value="client."
- readonly
- name="client_prefix" />
- <input
- cdsText
- name="user_entity"
- formControlName="user_entity"
- [invalid]="!entityForm.controls['user_entity'].valid && (entityForm.controls['user_entity'].dirty)" />
- </div>
- </cds-text-label>
-
- <ng-template #userEntityError>
- @if (entityForm.showError('user_entity', formDir, 'required')) {
- <span
- class="invalid-feedback"
- i18n>This field is required.</span>
- }
- @if (entityForm.showError('user_entity', formDir, 'forbiddenClientPrefix')) {
- <span
- class="invalid-feedback"
- i18n>Do not include 'client.' prefix. Enter only the entity suffix.</span>
- }
- </ng-template>
- </div>
- <div>
- <cd-form-button-panel
- (submitActionEvent)="submitAction()"
- [form]="entityForm"
- submitText="Create entity"
- i18n-submitText
- [showCancel]="false">
- </cd-form-button-panel>
- </div>
- </form>
- }
-
- @if (!isCreatingNewEntity && showSelectRequirementsWarning) {
- <cd-alert-panel
- type="warning"
- class="cds-ml-3 cds-mr-3"
- dismissible="true"
- (dismissed)="onDismissSelectRequirementsWarning()">
- <div
- [cdsStack]="'vertical'"
- gap="2">
- <div
- class="cds--type-heading-compact-01"
- i18n>Mirroring entity requirements</div>
- <div
- class="cds--type-body-compact-01"
- i18n>
- This selected entity will be used by the mirroring service to manage snapshots and
- replication. It must have rwps capabilities on MDS, MON, and OSD.
- </div>
- </div>
- </cd-alert-panel>
- @if (showSelectEntityInfo) {
- <cd-alert-panel
- type="info"
- class="cds-mb-3"
- dismissible="true"
- (dismissed)="onDismissSelectEntityInfo()">
- <div
- [cdsStack]="'vertical'"
- gap="2">
- <div
- class="cds--type-heading-01"
- i18n>Entity selection note</div>
- <div
- class="cds--type-body-compact-01"
- i18n>
- Only entities with valid rwps capabilities can be used for mirroring.
- </div>
- </div>
- </cd-alert-panel>
- }
- }
-
- @let entities = (entities$ | async);
- @if (!isCreatingNewEntity && entities) {
- <cd-table
- #table
- [data]="entities"
- [columns]="columns"
- columnMode="flex"
- selectionType="singleRadio"
- (updateSelection)="updateSelection($event)"
- (fetchData)="loadEntities($event)">
- </cd-table>
- }
- </cds-tile>
-</div>
-
+++ /dev/null
-@use '@carbon/layout';
-
-.scroll-overflow {
- max-height: 70vh;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.requirements-list {
- list-style-type: disc;
- padding-left: var(--cds-spacing-05);
- margin-left: layout.$spacing-02;
-}
-
-.list-disc {
- list-style-type: disc;
-}
+++ /dev/null
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
-import { of } from 'rxjs';
-
-import { CephfsMirroringEntityComponent } from './cephfs-mirroring-entity.component';
-import { CephfsService } from '~/app/shared/api/cephfs.service';
-import { ClusterService } from '~/app/shared/api/cluster.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
-import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-
-describe('CephfsMirroringEntityComponent', () => {
- let component: CephfsMirroringEntityComponent;
- let fixture: ComponentFixture<CephfsMirroringEntityComponent>;
-
- let clusterServiceMock: any;
- let cephfsServiceMock: any;
- let taskWrapperServiceMock: any;
-
- beforeEach(async () => {
- clusterServiceMock = {
- listUser: jest.fn(),
- createUser: jest.fn()
- };
-
- cephfsServiceMock = {
- setAuth: jest.fn()
- };
-
- taskWrapperServiceMock = {
- wrapTaskAroundCall: jest.fn()
- };
-
- await TestBed.configureTestingModule({
- declarations: [CephfsMirroringEntityComponent],
- imports: [ReactiveFormsModule],
- providers: [
- CdFormBuilder,
- { provide: ClusterService, useValue: clusterServiceMock },
- { provide: CephfsService, useValue: cephfsServiceMock },
- { provide: TaskWrapperService, useValue: taskWrapperServiceMock }
- ]
- })
- .overrideComponent(CephfsMirroringEntityComponent, {
- set: { template: '' }
- })
- .compileComponents();
-
- fixture = TestBed.createComponent(CephfsMirroringEntityComponent);
- component = fixture.componentInstance;
-
- component.selectedFilesystem = { name: 'myfs' } as any;
-
- clusterServiceMock.listUser.mockReturnValue(of([]));
-
- fixture.detectChanges();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it('should create component', () => {
- expect(component).toBeTruthy();
- });
-
- it('should initialize form with required + noClientPrefix validator', () => {
- const control = component.entityForm.get('user_entity');
-
- control?.setValue('');
- expect(control?.valid).toBeFalsy();
-
- control?.setValue('client.test');
- expect(control?.errors?.['forbiddenClientPrefix']).toBeTruthy();
-
- control?.setValue('validuser');
- expect(control?.valid).toBeTruthy();
- });
-
- it('should filter entities based on fsname', fakeAsync(() => {
- clusterServiceMock.listUser.mockReturnValue(
- of([
- {
- entity: 'client.valid',
- caps: { mds: 'allow rw fsname=myfs' }
- },
- {
- entity: 'client.invalid',
- caps: { mds: 'allow rw fsname=otherfs' }
- },
- {
- entity: 'osd.something',
- caps: { mds: 'allow rw fsname=myfs' }
- }
- ])
- );
-
- component.loadEntities();
- tick();
-
- component.entities$.subscribe((rows) => {
- expect(rows.length).toBe(1);
- expect(rows[0].entity).toBe('client.valid');
- expect(rows[0].mdsCaps).toContain('fsname=myfs');
- });
- }));
-
- it('should not submit if form invalid', () => {
- component.entityForm.get('user_entity')?.setValue('');
-
- component.submitAction();
-
- expect(clusterServiceMock.createUser).not.toHaveBeenCalled();
- expect(component.isSubmitting).toBeFalsy();
- });
-
- it('should create entity and set auth successfully', fakeAsync(() => {
- component.entityForm.get('user_entity')?.setValue('newuser');
-
- clusterServiceMock.createUser.mockReturnValue(of({}));
- taskWrapperServiceMock.wrapTaskAroundCall.mockReturnValue(of({}));
- cephfsServiceMock.setAuth.mockReturnValue(of({}));
-
- const emitSpy = jest.spyOn(component.entitySelected, 'emit');
-
- component.submitAction();
- tick();
-
- expect(clusterServiceMock.createUser).toHaveBeenCalledWith(
- expect.objectContaining({
- user_entity: 'client.newuser'
- })
- );
-
- expect(cephfsServiceMock.setAuth).toHaveBeenCalledWith('myfs', 'newuser', ['/', 'rwps'], false);
-
- expect(emitSpy).toHaveBeenCalledWith('client.newuser');
- expect(component.isSubmitting).toBeFalsy();
- expect(component.isCreatingNewEntity).toBeFalsy();
- }));
-
- it('should emit selected entity on updateSelection', () => {
- const selection = new CdTableSelection();
- selection.selected = [{ entity: 'client.test' }];
-
- const emitSpy = jest.spyOn(component.entitySelected, 'emit');
-
- component.updateSelection(selection);
-
- expect(emitSpy).toHaveBeenCalledWith('client.test');
- });
-
- it('should emit null when selection empty', () => {
- const selection = new CdTableSelection();
- selection.selected = [];
-
- const emitSpy = jest.spyOn(component.entitySelected, 'emit');
-
- component.updateSelection(selection);
-
- expect(emitSpy).toHaveBeenCalledWith(null);
- });
-
- it('should toggle create/existing states correctly', () => {
- component.onExistingEntitySelected();
- expect(component.isCreatingNewEntity).toBeFalsy();
-
- component.onCreateEntitySelected();
- expect(component.isCreatingNewEntity).toBeTruthy();
- });
-
- it('should dismiss warnings correctly', () => {
- component.onDismissCreateRequirementsWarning();
- expect(component.showCreateRequirementsWarning).toBeFalsy();
-
- component.onDismissCreateCapabilitiesInfo();
- expect(component.showCreateCapabilitiesInfo).toBeFalsy();
-
- component.onDismissSelectRequirementsWarning();
- expect(component.showSelectRequirementsWarning).toBeFalsy();
-
- component.onDismissSelectEntityInfo();
- expect(component.showSelectEntityInfo).toBeFalsy();
- });
-});
+++ /dev/null
-import {
- Component,
- OnInit,
- OnChanges,
- SimpleChanges,
- Output,
- EventEmitter,
- Input,
- inject,
- ViewChild
-} from '@angular/core';
-import { BehaviorSubject, Observable, of } from 'rxjs';
-import { catchError, map, switchMap, defaultIfEmpty } from 'rxjs/operators';
-
-import { CdTableColumn } from '~/app/shared/models/cd-table-column';
-import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-import { TableComponent } from '~/app/shared/datatable/table/table.component';
-import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
-import { CephfsService } from '~/app/shared/api/cephfs.service';
-import { ClusterService } from '~/app/shared/api/cluster.service';
-
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
-import { Validators, AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
-import { CdForm } from '~/app/shared/forms/cd-form';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { FinishedTask } from '~/app/shared/models/finished-task';
-import { FilesystemRow, MirroringEntityRow } from '~/app/shared/models/cephfs.model';
-import { CephAuthUser } from '~/app/shared/models/cluster.model';
-
-@Component({
- selector: 'cd-cephfs-mirroring-entity',
- templateUrl: './cephfs-mirroring-entity.component.html',
- styleUrls: ['./cephfs-mirroring-entity.component.scss'],
- standalone: false
-})
-export class CephfsMirroringEntityComponent extends CdForm implements OnInit, OnChanges {
- @ViewChild('table') table: TableComponent;
- columns: CdTableColumn[];
- selection = new CdTableSelection();
-
- subject$ = new BehaviorSubject<void>(undefined);
- entities$: Observable<MirroringEntityRow[]>;
- context: CdTableFetchDataContext;
- capabilities = [
- { name: $localize`MDS`, permission: 'rwps' },
- { name: $localize`MON`, permission: 'rwps' },
- { name: $localize`OSD`, permission: 'rwps' }
- ];
-
- isCreatingNewEntity = true;
- showCreateRequirementsWarning = true;
- showCreateCapabilitiesInfo = true;
- showSelectRequirementsWarning = true;
- showSelectEntityInfo = true;
-
- entityForm: CdFormGroup;
-
- readonly userEntityHelperText = $localize`Ceph Authentication entity used by mirroring.`;
-
- @Input() selectedFilesystem: FilesystemRow | null = null;
- @Output() entitySelected = new EventEmitter<string | null>();
- isSubmitting: boolean = false;
-
- private cephfsService = inject(CephfsService);
- private clusterService = inject(ClusterService);
- private taskWrapperService = inject(TaskWrapperService);
- private formBuilder = inject(CdFormBuilder);
-
- ngOnChanges(changes: SimpleChanges): void {
- if (changes['selectedFilesystem'] && !changes['selectedFilesystem'].firstChange) {
- this.resetSelection();
- }
- }
-
- ngOnInit(): void {
- const noClientPrefix: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
- const value = (control.value ?? '').toString().trim();
- if (!value) return null;
- return value.startsWith('client.') ? { forbiddenClientPrefix: true } : null;
- };
-
- this.entityForm = this.formBuilder.group({
- user_entity: ['', [Validators.required, noClientPrefix]]
- });
-
- this.columns = [
- {
- name: $localize`Entity ID`,
- prop: 'entity',
- flexGrow: 2
- },
- {
- name: $localize`MDS capabilities`,
- prop: 'mdsCaps',
- flexGrow: 1.5
- },
- {
- name: $localize`MON capabilities`,
- prop: 'monCaps',
- flexGrow: 1.5
- },
- {
- name: $localize`OSD capabilities`,
- prop: 'osdCaps',
- flexGrow: 1.5
- }
- ];
-
- this.entities$ = this.subject$.pipe(
- switchMap(() =>
- this.clusterService.listUser().pipe(
- switchMap((users) => {
- const typedUsers = (users as CephAuthUser[]) || [];
- const filteredEntities = typedUsers.filter((entity) => {
- if (entity.entity?.startsWith('client.')) {
- const caps = entity.caps || {};
- const mdsCaps = caps.mds || '-';
-
- const fsName = this.selectedFilesystem?.name || '';
- const isValid = mdsCaps.includes(`fsname=${fsName}`);
-
- return isValid;
- }
- return false;
- });
-
- const rows: MirroringEntityRow[] = filteredEntities.map((entity) => {
- const caps = entity.caps || {};
- const mdsCaps = caps.mds || '-';
- const monCaps = caps.mon || '-';
- const osdCaps = caps.osd || '-';
-
- return {
- entity: entity.entity,
- mdsCaps,
- monCaps,
- osdCaps
- };
- });
-
- return of(rows);
- }),
- catchError(() => {
- this.context?.error();
- return of([]);
- })
- )
- )
- );
-
- this.loadEntities();
- }
-
- submitAction(): void {
- if (!this.entityForm.valid) {
- this.entityForm.markAllAsTouched();
- return;
- }
-
- const clientEntity = (this.entityForm.get('user_entity')?.value || '').toString().trim();
- const fullEntity = `client.${clientEntity}`;
- const fsName = this.selectedFilesystem?.name;
-
- const payload = {
- user_entity: fullEntity,
- capabilities: [
- { entity: 'mds', cap: 'allow *' },
- { entity: 'mgr', cap: 'allow *' },
- { entity: 'mon', cap: 'allow *' },
- { entity: 'osd', cap: 'allow *' }
- ]
- };
-
- this.isSubmitting = true;
-
- this.taskWrapperService
- .wrapTaskAroundCall({
- task: new FinishedTask(`ceph-user/create`, {
- userEntity: fullEntity,
- fsName: fsName
- }),
- call: this.clusterService.createUser(payload).pipe(
- map((res) => {
- return { ...(res as Record<string, unknown>), __taskCompleted: true };
- })
- )
- })
- .pipe(
- defaultIfEmpty(null),
- switchMap(() => {
- if (fsName) {
- return this.cephfsService.setAuth(fsName, clientEntity, ['/', 'rwps'], false);
- }
- return of(null);
- })
- )
- .subscribe({
- complete: () => {
- this.isSubmitting = false;
- this.entityForm.reset();
- this.handleEntityCreated(fullEntity);
- }
- });
- }
-
- private handleEntityCreated(entityId: string) {
- this.loadEntities(this.context);
- this.entitySelected.emit(entityId);
- this.isCreatingNewEntity = false;
- }
-
- loadEntities(context?: CdTableFetchDataContext) {
- this.context = context;
- this.subject$.next();
- }
-
- updateSelection(selection: CdTableSelection) {
- this.selection = selection;
- const selectedRow = selection?.first();
- if (!selectedRow) {
- this.selection.selected = [];
- this.entitySelected.emit(null);
- return;
- }
- this.entitySelected.emit(selectedRow.entity);
- }
-
- resetSelection() {
- if (this.table) {
- this.table.model.selectAll(false);
- }
- this.selection = new CdTableSelection();
- this.entitySelected.emit(null);
- }
-
- onCreateEntitySelected() {
- this.isCreatingNewEntity = true;
- this.showCreateRequirementsWarning = true;
- this.showCreateCapabilitiesInfo = true;
- }
-
- onExistingEntitySelected() {
- this.isCreatingNewEntity = false;
- this.showSelectRequirementsWarning = true;
- this.showSelectEntityInfo = true;
- this.resetSelection();
- }
-
- onDismissCreateRequirementsWarning() {
- this.showCreateRequirementsWarning = false;
- }
-
- onDismissCreateCapabilitiesInfo() {
- this.showCreateCapabilitiesInfo = false;
- }
-
- onDismissSelectRequirementsWarning() {
- this.showSelectRequirementsWarning = false;
- }
-
- onDismissSelectEntityInfo() {
- this.showSelectEntityInfo = false;
- }
-}
</div>
<div cdsCol
[columnNumbers]="{lg: 4, md: 4, sm: 4}">
- <cds-clickable-tile>
+ <cds-clickable-tile (click)="openPrepareToReceive(); $event.preventDefault()">
<div cdsStack="vertical"
gap="5">
<p class="cds--type-heading-compact-01"
<h3 class="cds--type-heading-03 cds-mb-3"
i18n>Mirrored filesystems</h3>
<cd-table
- #table
[data]="daemonStatus$ | async"
[columns]="columns"
columnMode="flex"
selectionType="single"
- (fetchData)="loadDaemonStatus()"
- (updateSelection)="updateSelection($event)">
+ (fetchData)="loadDaemonStatus()">
</cd-table>
</section>
+<!-- Prepare to receive modal -->
+@if (isPrepareModalOpen) {
+ <cd-cephfs-generate-token
+ [open]="true"
+ (tokenGenerated)="onTokenGenerated($event)"
+ (cancelled)="closePrepareModal()">
+ </cd-cephfs-generate-token>
+}
+
-import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
await TestBed.configureTestingModule({
declarations: [CephfsMirroringListComponent],
- providers: [{ provide: CephfsService, useValue: cephfsServiceMock }],
- schemas: [NO_ERRORS_SCHEMA]
+ providers: [{ provide: CephfsService, useValue: cephfsServiceMock }]
}).compileComponents();
fixture = TestBed.createComponent(CephfsMirroringListComponent);
-import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
+import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { Subject, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { CephfsService } from '~/app/shared/api/cephfs.service';
-import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
encapsulation: ViewEncapsulation.None
})
export class CephfsMirroringListComponent implements OnInit {
- @ViewChild('table', { static: true }) table: TableComponent;
-
columns: CdTableColumn[];
selection = new CdTableSelection();
map((daemons) => this.buildRows(daemons))
);
+ isPrepareModalOpen = false;
+
constructor(private cephfsService: CephfsService) {}
ngOnInit() {
this.subject$.next();
}
- updateSelection(selection: CdTableSelection) {
- this.selection = selection;
+ openPrepareToReceive() {
+ this.isPrepareModalOpen = true;
+ }
+
+ closePrepareModal() {
+ this.isPrepareModalOpen = false;
+ this.loadDaemonStatus();
+ }
+
+ onTokenGenerated(_response: any) {
+ this.loadDaemonStatus();
}
private buildRows(daemons: Daemon[]): MirroringRow[] {
import { CephfsMirroringFsOverviewComponent } from './cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component';
import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component';
import { CephfsMirroringFsSchedulesComponent } from './cephfs-mirroring-fs-schedules/cephfs-mirroring-fs-schedules.component';
+import { CephfsGenerateTokenComponent } from './cephfs-generate-token/cephfs-generate-token.component';
+import { CephfsDownloadTokenComponent } from './cephfs-download-token/cephfs-download-token.component';
import {
ButtonModule,
CheckboxModule,
CephfsMirroringFsTabsComponent,
CephfsMirroringFsOverviewComponent,
CephfsMirroringFsMirrorPathsComponent,
- CephfsMirroringFsSchedulesComponent
+ CephfsMirroringFsSchedulesComponent,
+ CephfsGenerateTokenComponent,
+ CephfsDownloadTokenComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
i18n-title
*ngIf="permissions.cephfs.read && enabledFeature.cephfs"
class="tc_submenuitem tc_submenuitem_file_cephfs"><span i18n>File systems</span></cds-sidenav-item>
- <!-- Removed for umbrella release, will be re-enabled in next release
<cds-sidenav-item route="/cephfs/mirroring"
[useRouter]="true"
title="Mirroring"
i18n-title
*ngIf="permissions.cephfs.read && enabledFeature.cephfs"
class="tc_submenuitem tc_submenuitem_file_cephfs"><span i18n>Mirroring</span></cds-sidenav-item>
- -->
<cds-sidenav-item route="/cephfs/nfs"
[useRouter]="true"
title="NFS"
listDaemonStatus(): Observable<Daemon[]> {
return this.http.get<Daemon[]>(`${this.baseURL}/mirror/daemon-status`);
}
+
+ enableMirror(fsName: string): Observable<any> {
+ return this.http.post(`${this.baseURL}/mirror/enable`, {
+ fs_name: fsName
+ });
+ }
+
+ createBootstrapToken(fsName: string, clientName: string, siteName: string): Observable<any> {
+ return this.http.post(`${this.baseURL}/mirror/token`, {
+ fs_name: fsName,
+ client_name: clientName,
+ site_name: siteName
+ });
+ }
}
monCaps: string;
osdCaps: string;
};
+
+export interface BootstrapTokenResponse {
+ token?: string;
+ data?: string;
+}
+
+export const CLIENT_PREFIX = 'client.';
+export const MAX_TYPEAHEAD_SUGGESTIONS = 10;
+export const VALID_USERNAME_PATTERN = /[^a-zA-Z0-9_-]/;
'ceph-user/create': this.newTaskMessage(
this.commonOperations.create,
(metadata: { userEntity: string }) => this.cephUser(metadata)
+ ),
+ 'mirroring/token/create': this.newTaskMessage(this.commonOperations.create, () =>
+ this.bootstrap()
)
};
cephUser(metadata: { userEntity: string }) {
return $localize`Ceph user '${metadata.userEntity}'`;
}
+
+ bootstrap() {
+ return $localize`bootstrap token`;
+ }
}
summary: Get mirror daemon and peers information
tags:
- CephfsMirror
+ /api/cephfs/mirror/enable:
+ post:
+ parameters: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ fs_name:
+ description: File system name
+ type: string
+ required:
+ - fs_name
+ type: object
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ type: object
+ application/vnd.ceph.api.v1.0+json:
+ schema:
+ type: object
+ description: Resource created.
+ '202':
+ content:
+ application/json:
+ schema:
+ type: object
+ application/vnd.ceph.api.v1.0+json:
+ schema:
+ type: object
+ description: Operation is still executing. Please check the task queue.
+ '400':
+ description: Operation exception. Please check the response body for details.
+ '401':
+ description: Unauthenticated access. Please login first.
+ '403':
+ description: Unauthorized access. Please check your permissions.
+ '500':
+ description: Unexpected error. Please check the response body for the stack
+ trace.
+ security:
+ - jwt: []
+ summary: Enable snapshot mirroring for a filesystem
+ tags:
+ - CephfsMirror
/api/cephfs/mirror/token:
post:
parameters: []