From: Dnyaneshwari Talwekar Date: Thu, 4 Jun 2026 07:01:48 +0000 (+0530) Subject: Cephfs Mirroring - Generate Token X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F68975%2Fhead;p=ceph.git Cephfs Mirroring - Generate Token Fixes: https://tracker.ceph.com/issues/76781 Signed-off-by: Dnyaneshwari Talwekar --- diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 4c150bdc4b7..61930c676bd 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -1322,6 +1322,24 @@ class CephFSMirror(RESTController): ) 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'), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.html new file mode 100644 index 00000000000..62ea8a59685 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.html @@ -0,0 +1,53 @@ + + Ceph filesystem mirroring + + +
+
Secure token successfully generated
+ + +
+
Secure token generated
+
Bootstrap token generated successfully. Share this token with the source cluster administrator.
+
+
+ +
+
+ +
+ +
+ Copy or download token to finish setup on the source cluster +
+
+ + +
+ + + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.spec.ts new file mode 100644 index 00000000000..d7e945710e2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.spec.ts @@ -0,0 +1,91 @@ +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; + + 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(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.ts new file mode 100644 index 00000000000..d03b4116b4d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.ts @@ -0,0 +1,51 @@ +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(); + + 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(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.html new file mode 100644 index 00000000000..e70ffc8f28e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.html @@ -0,0 +1,141 @@ +@if (open && !generatedToken) { + + Ceph filesystem mirroring + + +
+
Prepare cluster to receive data
+ +
Generate a bootstrap token to use on the source cluster and establish a mirroring relationship, + enabling this cluster to receive replicated data.
+ +
+ +
+
How to use this secure token
+
Once you've generated the token, send it to the source cluster's administrator to finish + the setup.
+
+
+
+ +
+
+
+ + + + + + This field is required. + +
+ +
+
+
+ + + +
+ + ​ + + +
+ + Ceph authx user. Enter a new username or select an existing one. + + + @if (tokenForm.controls['username'].hasError('required')) { + This field is required. + } + @if (tokenForm.controls['username'].hasError('forbiddenClientPrefix')) { + Do not include 'client.' prefix. Enter only the entity suffix. + } + @if (tokenForm.controls['username'].hasError('invalidChars')) { + Only letters, numbers, underscores, and hyphens are allowed. + } + +
+ +
+ Sitename + + + + A unique name for this storage site. Used by the primary cluster to identify this replication target. + + + This field is required. + +
+
+
+
+ + + +
+} + +@if (open && generatedToken) { + + +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.scss new file mode 100644 index 00000000000..e626432c394 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.scss @@ -0,0 +1,7 @@ +.fit-content { + width: fit-content; +} + +.cds-input-group { + width: 100%; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.spec.ts new file mode 100644 index 00000000000..27a5ac95459 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.spec.ts @@ -0,0 +1,199 @@ +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; + + 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- 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); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.ts new file mode 100644 index 00000000000..b8a2fc1adeb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.ts @@ -0,0 +1,186 @@ +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(); + @Output() cancelled = new EventEmitter(); + + filesystems: { id: number; name: string }[] = []; + filteredUsers: string[] = []; + isGenerating = false; + generatedToken = ''; + usernameFocus$ = new Subject(); + + private allClientUsers: CephAuthUser[] = []; + private destroy$ = new Subject(); + + 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 = (text$: Observable) => + 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)); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html deleted file mode 100644 index 6dd4aafb144..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html +++ /dev/null @@ -1,193 +0,0 @@ -
-
-
Create or select entity
-

- Choose an existing CephFS mirroring entity or create a new one to be used for mirroring. -

-
- -
- - Create new entity - Use existing entity - -
- - @if (isCreatingNewEntity) { - @if (showCreateRequirementsWarning) { - -
-
Mirroring entity requirements
-
- This entity will be used by the mirroring service to manage snapshots and replication. - It will require the proper permissions to replicate data. -
-
-
- } - - @if (showCreateCapabilitiesInfo) { - -
-
Capabilities added automatically.
-
- If you create a new entity, the following capabilities will be assigned automatically: -
-
    - @for (cap of capabilities; track cap.name) { -
  • - {{ cap.name }}: {{ cap.permission }} -
  • - } -
-
-
- } - -
-
- - Ceph entity -
- - -
-
- - - @if (entityForm.showError('user_entity', formDir, 'required')) { - This field is required. - } - @if (entityForm.showError('user_entity', formDir, 'forbiddenClientPrefix')) { - Do not include 'client.' prefix. Enter only the entity suffix. - } - -
-
- - -
-
- } - - @if (!isCreatingNewEntity && showSelectRequirementsWarning) { - -
-
Mirroring entity requirements
-
- 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. -
-
-
- @if (showSelectEntityInfo) { - -
-
Entity selection note
-
- Only entities with valid rwps capabilities can be used for mirroring. -
-
-
- } - } - - @let entities = (entities$ | async); - @if (!isCreatingNewEntity && entities) { - - - } -
-
- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss deleted file mode 100644 index f359d817d0e..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss +++ /dev/null @@ -1,17 +0,0 @@ -@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; -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts deleted file mode 100644 index 893cfcf0d65..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -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; - - 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(); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts deleted file mode 100644 index 8bba56da5d4..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts +++ /dev/null @@ -1,265 +0,0 @@ -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(undefined); - entities$: Observable; - 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(); - 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), __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; - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html index c0c55839e30..7c75ab25f90 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html @@ -29,7 +29,7 @@
- +

Mirrored filesystems + (fetchData)="loadDaemonStatus()"> + +@if (isPrepareModalOpen) { + + +} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts index d7fa1dbc148..f520578e235 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts @@ -1,4 +1,3 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; @@ -19,8 +18,7 @@ describe('CephfsMirroringListComponent', () => { await TestBed.configureTestingModule({ declarations: [CephfsMirroringListComponent], - providers: [{ provide: CephfsService, useValue: cephfsServiceMock }], - schemas: [NO_ERRORS_SCHEMA] + providers: [{ provide: CephfsService, useValue: cephfsServiceMock }] }).compileComponents(); fixture = TestBed.createComponent(CephfsMirroringListComponent); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts index 02f1cbe55ba..c986f9a34f8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts @@ -1,9 +1,8 @@ -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'; @@ -17,8 +16,6 @@ import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/ceph encapsulation: ViewEncapsulation.None }) export class CephfsMirroringListComponent implements OnInit { - @ViewChild('table', { static: true }) table: TableComponent; - columns: CdTableColumn[]; selection = new CdTableSelection(); @@ -31,6 +28,8 @@ export class CephfsMirroringListComponent implements OnInit { map((daemons) => this.buildRows(daemons)) ); + isPrepareModalOpen = false; + constructor(private cephfsService: CephfsService) {} ngOnInit() { @@ -56,8 +55,17 @@ export class CephfsMirroringListComponent implements OnInit { 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[] { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index f5b0fc1590d..5248f2ce4ba 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -37,6 +37,8 @@ import { CephfsMirroringFsTabsComponent } from './cephfs-mirroring-fs-tabs/cephf 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, @@ -136,7 +138,9 @@ import ShareIcon24 from '@carbon/icons/es/share/24'; CephfsMirroringFsTabsComponent, CephfsMirroringFsOverviewComponent, CephfsMirroringFsMirrorPathsComponent, - CephfsMirroringFsSchedulesComponent + CephfsMirroringFsSchedulesComponent, + CephfsGenerateTokenComponent, + CephfsDownloadTokenComponent ], providers: [provideCharts(withDefaultRegisterables())] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index 003c48a8259..ba995dc16c3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -272,14 +272,12 @@ i18n-title *ngIf="permissions.cephfs.read && enabledFeature.cephfs" class="tc_submenuitem tc_submenuitem_file_cephfs">File systems - { return this.http.get(`${this.baseURL}/mirror/daemon-status`); } + + enableMirror(fsName: string): Observable { + return this.http.post(`${this.baseURL}/mirror/enable`, { + fs_name: fsName + }); + } + + createBootstrapToken(fsName: string, clientName: string, siteName: string): Observable { + return this.http.post(`${this.baseURL}/mirror/token`, { + fs_name: fsName, + client_name: clientName, + site_name: siteName + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts index 4f3d4450ce5..1939ea4e590 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts @@ -137,3 +137,12 @@ export type MirroringEntityRow = { 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_-]/; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index de890d8d5ca..d15f973a756 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -569,6 +569,9 @@ export class TaskMessageService { 'ceph-user/create': this.newTaskMessage( this.commonOperations.create, (metadata: { userEntity: string }) => this.cephUser(metadata) + ), + 'mirroring/token/create': this.newTaskMessage(this.commonOperations.create, () => + this.bootstrap() ) }; @@ -752,4 +755,8 @@ export class TaskMessageService { cephUser(metadata: { userEntity: string }) { return $localize`Ceph user '${metadata.userEntity}'`; } + + bootstrap() { + return $localize`bootstrap token`; + } } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 28d785dfaeb..1dc905d1e9e 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -3324,6 +3324,53 @@ paths: 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: []