]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
Cephfs Mirroring - Generate Token 68975/head
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Thu, 4 Jun 2026 07:01:48 +0000 (12:31 +0530)
committerDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Thu, 25 Jun 2026 11:15:39 +0000 (16:45 +0530)
Fixes: https://tracker.ceph.com/issues/76781
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
22 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-download-token/cephfs-download-token.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-generate-token/cephfs-generate-token.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index 4c150bdc4b75349c04accef81588a3c532c76d71..61930c676bd9097bc06c8371a382dd2604b60a1b 100644 (file)
@@ -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 (file)
index 0000000..62ea8a5
--- /dev/null
@@ -0,0 +1,53 @@
+<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>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..d7e9457
--- /dev/null
@@ -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<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();
+    });
+  });
+});
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 (file)
index 0000000..d03b411
--- /dev/null
@@ -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<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();
+  }
+}
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 (file)
index 0000000..e70ffc8
--- /dev/null
@@ -0,0 +1,141 @@
+@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">
+                &#8203;
+                <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>
+}
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 (file)
index 0000000..e626432
--- /dev/null
@@ -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 (file)
index 0000000..27a5ac9
--- /dev/null
@@ -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<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);
+  });
+});
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 (file)
index 0000000..b8a2fc1
--- /dev/null
@@ -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<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));
+  }
+}
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 (file)
index 6dd4aaf..0000000
+++ /dev/null
@@ -1,193 +0,0 @@
-<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>
-
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 (file)
index f359d81..0000000
+++ /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 (file)
index 893cfcf..0000000
+++ /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<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();
-  });
-});
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 (file)
index 8bba56d..0000000
+++ /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<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;
-  }
-}
index c0c55839e30ea58da6c2f52cc98afd9d95833ea8..7c75ab25f9056e921a94e387840bb16c0e483a15 100644 (file)
@@ -29,7 +29,7 @@
       </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>
+}
+
index d7fa1dbc1481174bf8544e993ca56cf458c0fd5a..f520578e235fe279a93c18a30ed4894da57dd80a 100644 (file)
@@ -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);
index 02f1cbe55baf7e68a95e9af19306193e4def9762..c986f9a34f86e7f95d1fd4f4e4b629bcb40378d0 100644 (file)
@@ -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[] {
index f5b0fc1590dfd04de9be3f1a3ddbba0c64220fad..5248f2ce4baf2862d19c06f9569b27226852a6cc 100644 (file)
@@ -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())]
 })
index 003c48a82593580d455e212d23a467b66082c623..ba995dc16c313b90ceef0c8104156dcae9ea6acc 100644 (file)
                             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"
index 2d47aceed95efcfc8d79c33eda73cf3d637abccd..beff967f59a442ea481e3ed56f937f8514b31d83 100644 (file)
@@ -131,4 +131,18 @@ export class CephfsService {
   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
+    });
+  }
 }
index 4f3d4450ce5ff8ab60ea9018464783c8978e4f1c..1939ea4e590ae1b9e1e9b38fd419b5f0a54d9cf5 100644 (file)
@@ -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_-]/;
index de890d8d5cab68457a3510b4d2d3091f68ec8ae9..d15f973a756aeac39f7447bc1f4cb87dd8e6876f 100644 (file)
@@ -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`;
+  }
 }
index 28d785dfaeb720b3ce9ff1a083ed08e73efe4d91..1dc905d1e9e23261337af4f3544e65255ccef9df 100644 (file)
@@ -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: []