]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: block mirroring bootstrap UI 31062/head
authorJason Dillaman <dillaman@redhat.com>
Wed, 30 Oct 2019 18:55:41 +0000 (14:55 -0400)
committerJason Dillaman <dillaman@redhat.com>
Thu, 5 Dec 2019 14:32:42 +0000 (09:32 -0500)
Two new modal windows allow an admin to create a base64-encoded bootstrap token
which can then be imported into dashboard on another cluster. The bootstrap
token embeds all the necessary data required to connect to a peer cluster for
RBD mirroring.

Fixes: http://tracker.ceph.com/issues/42355
Signed-off-by: Jason Dillaman <dillaman@redhat.com>
13 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html
new file mode 100644 (file)
index 0000000..9aab163
--- /dev/null
@@ -0,0 +1,91 @@
+<cd-modal [modalRef]="modalRef">
+  <ng-container i18n
+                class="modal-title">Create Bootstrap Token</ng-container>
+
+  <ng-container class="modal-content">
+    <form name="createBootstrapForm"
+          class="form"
+          #formDir="ngForm"
+          [formGroup]="createBootstrapForm"
+          novalidate>
+      <div class="modal-body">
+        <p>
+          <ng-container i18n>To create a bootstrap token which can be imported
+          by a peer site cluster, provide the local site's name, select
+          which pools will have mirroring enabled, and click&nbsp;
+          <kbd>Generate</kbd>.</ng-container>
+        </p>
+
+        <div class="form-group">
+          <label class="col-form-label"
+                 for="siteName">
+            <span i18n>Site Name</span>
+            <span class="required"></span>
+          </label>
+          <input class="form-control"
+                 type="text"
+                 placeholder="Name..."
+                 i18n-placeholder
+                 id="siteName"
+                 name="siteName"
+                 formControlName="siteName"
+                 autofocus>
+          <span *ngIf="createBootstrapForm.showError('siteName', formDir, 'required')"
+                class="invalid-feedback"
+                i18n>This field is required.</span>
+        </div>
+
+        <div class="form-group"
+             formGroupName="pools">
+          <label class="col-form-label"
+                 for="pools">
+            <span i18n>Pools</span>
+            <span class="required"></span>
+          </label>
+          <div class="custom-control custom-checkbox"
+               *ngFor="let pool of pools">
+            <input type="checkbox"
+                   class="custom-control-input"
+                   id="{{ pool.name }}"
+                   name="{{ pool.name }}"
+                   formControlName="{{ pool.name }}">
+            <label class="custom-control-label"
+                   for="{{ pool.name }}">{{ pool.name }}</label>
+          </div>
+          <span *ngIf="createBootstrapForm.showError('pools', formDir, 'requirePool')"
+                class="invalid-feedback"
+                i18n>At least one pool is required.</span>
+        </div>
+
+        <div class="button-group text-right">
+          <cd-submit-button i18n
+                            [form]="createBootstrapForm"
+                            (submitAction)="generate()">Generate</cd-submit-button>
+        </div>
+
+        <div class="form-group">
+          <label class="col-form-label"
+                 for="token">
+            <span i18n>Token</span>
+          </label>
+          <textarea class="form-control resize-vertical"
+                    placeholder="Generated token..."
+                    i18n-placeholder
+                    id="token"
+                    formControlName="token"
+                    readonly>
+          </textarea>
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <div class="button-group text-right">
+          <cd-back-button [back]="modalRef.hide"
+                          name="Close"
+                          i18n-name>
+          </cd-back-button>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss
new file mode 100644 (file)
index 0000000..8dc4d1c
--- /dev/null
@@ -0,0 +1,3 @@
+.form-group.ng-invalid .invalid-feedback {
+  display: block;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..eb288af
--- /dev/null
@@ -0,0 +1,117 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import {
+  configureTestBed,
+  FormHelper,
+  i18nProviders
+} from '../../../../../testing/unit-test-helper';
+import { RbdMirroringService } from '../../../../shared/api/rbd-mirroring.service';
+import { NotificationService } from '../../../../shared/services/notification.service';
+import { SharedModule } from '../../../../shared/shared.module';
+import { BootstrapCreateModalComponent } from './bootstrap-create-modal.component';
+
+describe('BootstrapCreateModalComponent', () => {
+  let component: BootstrapCreateModalComponent;
+  let fixture: ComponentFixture<BootstrapCreateModalComponent>;
+  let notificationService: NotificationService;
+  let rbdMirroringService: RbdMirroringService;
+  let formHelper: FormHelper;
+
+  configureTestBed({
+    declarations: [BootstrapCreateModalComponent],
+    imports: [
+      HttpClientTestingModule,
+      ReactiveFormsModule,
+      RouterTestingModule,
+      SharedModule,
+      ToastrModule.forRoot()
+    ],
+    providers: [BsModalRef, BsModalService, i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(BootstrapCreateModalComponent);
+    component = fixture.componentInstance;
+    component.siteName = 'site-A';
+
+    notificationService = TestBed.get(NotificationService);
+    spyOn(notificationService, 'show').and.stub();
+
+    rbdMirroringService = TestBed.get(RbdMirroringService);
+
+    formHelper = new FormHelper(component.createBootstrapForm);
+
+    spyOn(rbdMirroringService, 'getSiteName').and.callFake(() => of({ site_name: 'site-A' }));
+    spyOn(rbdMirroringService, 'subscribeSummary').and.callFake((call) =>
+      of({
+        content_data: {
+          pools: [
+            { name: 'pool1', mirror_mode: 'disabled' },
+            { name: 'pool2', mirror_mode: 'disabled' },
+            { name: 'pool3', mirror_mode: 'disabled' }
+          ]
+        }
+      }).subscribe(call)
+    );
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('generate token', () => {
+    beforeEach(() => {
+      spyOn(rbdMirroringService, 'refresh').and.stub();
+      spyOn(component.modalRef, 'hide').and.callThrough();
+      fixture.detectChanges();
+    });
+
+    afterEach(() => {
+      expect(rbdMirroringService.getSiteName).toHaveBeenCalledTimes(1);
+      expect(rbdMirroringService.subscribeSummary).toHaveBeenCalledTimes(1);
+      expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+    });
+
+    it('should generate a bootstrap token', () => {
+      spyOn(rbdMirroringService, 'setSiteName').and.callFake(() => of({ site_name: 'new-site-A' }));
+      spyOn(rbdMirroringService, 'updatePool').and.callFake(() => of({}));
+      spyOn(rbdMirroringService, 'createBootstrapToken').and.callFake(() => of({ token: 'token' }));
+
+      component.createBootstrapForm.patchValue({
+        siteName: 'new-site-A',
+        pools: { pool1: true, pool3: true }
+      });
+      component.generate();
+      expect(rbdMirroringService.setSiteName).toHaveBeenCalledWith('new-site-A');
+      expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool1', {
+        mirror_mode: 'image'
+      });
+      expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool3', {
+        mirror_mode: 'image'
+      });
+      expect(rbdMirroringService.createBootstrapToken).toHaveBeenCalledWith('pool3');
+      expect(component.createBootstrapForm.getValue('token')).toBe('token');
+    });
+  });
+
+  describe('form validation', () => {
+    beforeEach(() => {
+      fixture.detectChanges();
+    });
+
+    it('should require a site name', () => {
+      formHelper.expectErrorChange('siteName', '', 'required');
+    });
+
+    it('should require at least one pool', () => {
+      formHelper.expectError(component.createBootstrapForm.get('pools'), 'requirePool');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts
new file mode 100644 (file)
index 0000000..ce573f8
--- /dev/null
@@ -0,0 +1,156 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+import { concat, forkJoin, Subscription } from 'rxjs';
+import { last, tap } from 'rxjs/operators';
+
+import { RbdMirroringService } from '../../../../shared/api/rbd-mirroring.service';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+import { FinishedTask } from '../../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service';
+
+@Component({
+  selector: 'cd-bootstrap-create-modal',
+  templateUrl: './bootstrap-create-modal.component.html',
+  styleUrls: ['./bootstrap-create-modal.component.scss']
+})
+export class BootstrapCreateModalComponent implements OnDestroy, OnInit {
+  siteName: string;
+  pools: any[] = [];
+  token: string;
+
+  subs: Subscription;
+
+  createBootstrapForm: CdFormGroup;
+
+  constructor(
+    public modalRef: BsModalRef,
+    private rbdMirroringService: RbdMirroringService,
+    private taskWrapper: TaskWrapperService
+  ) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.createBootstrapForm = new CdFormGroup({
+      siteName: new FormControl('', {
+        validators: [Validators.required]
+      }),
+      pools: new FormGroup(
+        {},
+        {
+          validators: [this.validatePools()]
+        }
+      ),
+      token: new FormControl('', {})
+    });
+  }
+
+  ngOnInit() {
+    this.createBootstrapForm.get('siteName').setValue(this.siteName);
+    this.rbdMirroringService.getSiteName().subscribe((response: any) => {
+      this.createBootstrapForm.get('siteName').setValue(response.site_name);
+    });
+
+    this.subs = this.rbdMirroringService.subscribeSummary((data: any) => {
+      if (!data) {
+        return;
+      }
+
+      const pools = data.content_data.pools;
+      this.pools = pools.reduce((acc, pool) => {
+        acc.push({
+          name: pool['name'],
+          mirror_mode: pool['mirror_mode']
+        });
+        return acc;
+      }, []);
+
+      const poolsControl = this.createBootstrapForm.get('pools') as FormGroup;
+      _.each(this.pools, (pool) => {
+        const poolName = pool['name'];
+        const mirroring_disabled = pool['mirror_mode'] === 'disabled';
+        const control = poolsControl.controls[poolName];
+        if (control) {
+          if (mirroring_disabled && control.disabled) {
+            control.enable();
+          } else if (!mirroring_disabled && control.enabled) {
+            control.disable();
+            control.setValue(true);
+          }
+        } else {
+          poolsControl.addControl(
+            poolName,
+            new FormControl({ value: !mirroring_disabled, disabled: !mirroring_disabled })
+          );
+        }
+      });
+    });
+  }
+
+  ngOnDestroy() {
+    if (this.subs) {
+      this.subs.unsubscribe();
+    }
+  }
+
+  validatePools(): ValidatorFn {
+    return (poolsControl: FormGroup): { [key: string]: any } => {
+      let checkedCount = 0;
+      _.each(poolsControl.controls, (control) => {
+        if (control.value === true) {
+          ++checkedCount;
+        }
+      });
+
+      if (checkedCount > 0) {
+        return null;
+      }
+
+      return { requirePool: true };
+    };
+  }
+
+  generate() {
+    this.createBootstrapForm.get('token').setValue('');
+
+    let bootstrapPoolName = '';
+    const poolNames: string[] = [];
+    const poolsControl = this.createBootstrapForm.get('pools') as FormGroup;
+    _.each(poolsControl.controls, (control, poolName) => {
+      if (control.value === true) {
+        bootstrapPoolName = poolName;
+        if (!control.disabled) {
+          poolNames.push(poolName);
+        }
+      }
+    });
+
+    const poolModeRequest = {
+      mirror_mode: 'image'
+    };
+
+    const apiActionsObs = concat(
+      this.rbdMirroringService.setSiteName(this.createBootstrapForm.getValue('siteName')),
+      forkJoin(
+        poolNames.map((poolName) => this.rbdMirroringService.updatePool(poolName, poolModeRequest))
+      ),
+      this.rbdMirroringService
+        .createBootstrapToken(bootstrapPoolName)
+        .pipe(tap((data) => this.createBootstrapForm.get('token').setValue(data['token'])))
+    ).pipe(last());
+
+    const finishHandler = () => {
+      this.rbdMirroringService.refresh();
+      this.createBootstrapForm.setErrors({ cdSubmitButton: true });
+    };
+
+    const taskObs = this.taskWrapper.wrapTaskAroundCall({
+      task: new FinishedTask('rbd/mirroring/bootstrap/create', {}),
+      call: apiActionsObs
+    });
+    taskObs.subscribe(undefined, finishHandler, finishHandler);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html
new file mode 100644 (file)
index 0000000..6bb4cba
--- /dev/null
@@ -0,0 +1,108 @@
+<cd-modal [modalRef]="modalRef">
+  <ng-container i18n
+                class="modal-title">Import Bootstrap Token</ng-container>
+
+  <ng-container class="modal-content">
+    <form name="importBootstrapForm"
+          class="form"
+          #formDir="ngForm"
+          [formGroup]="importBootstrapForm"
+          novalidate>
+      <div class="modal-body">
+        <p>
+          <ng-container i18n>To import a bootstrap token which was created
+          by a peer site cluster, provide the local site's name, select
+          which pools will have mirroring enabled, provide the generated
+          token, and click&nbsp;<kbd>Import</kbd>.</ng-container>
+        </p>
+
+        <div class="form-group">
+          <label class="col-form-label"
+                 for="siteName">
+            <span i18n>Site Name</span>
+            <span class="required"></span>
+          </label>
+          <input class="form-control"
+                 type="text"
+                 placeholder="Name..."
+                 i18n-placeholder
+                 id="siteName"
+                 name="siteName"
+                 formControlName="siteName"
+                 autofocus>
+          <span *ngIf="importBootstrapForm.showError('siteName', formDir, 'required')"
+                class="invalid-feedback"
+                i18n>This field is required.</span>
+        </div>
+
+        <div class="form-group">
+          <label class="col-form-label"
+                 for="direction">
+            <span i18n>Direction</span>
+          </label>
+          <select id="direction"
+                  name="direction"
+                  class="form-control custom-select"
+                  formControlName="direction">
+            <option *ngFor="let direction of directions"
+                    [value]="direction.key">{{ direction.desc }}</option>
+          </select>
+        </div>
+
+        <div class="form-group"
+             formGroupName="pools">
+          <label class="col-form-label"
+                 for="pools">
+            <span i18n>Pools</span>
+            <span class="required"></span>
+          </label>
+          <div class="custom-control custom-checkbox"
+               *ngFor="let pool of pools">
+            <input type="checkbox"
+                   class="custom-control-input"
+                   id="{{ pool.name }}"
+                   name="{{ pool.name }}"
+                   formControlName="{{ pool.name }}">
+            <label class="custom-control-label"
+                   for="{{ pool.name }}">{{ pool.name }}</label>
+          </div>
+          <span *ngIf="importBootstrapForm.showError('pools', formDir, 'requirePool')"
+                class="invalid-feedback"
+                i18n>At least one pool is required.</span>
+        </div>
+
+        <div class="form-group">
+          <label class="col-form-label"
+                 for="token">
+            <span i18n>Token</span>
+            <span class="required"></span>
+          </label>
+          <textarea class="form-control resize-vertical"
+                    placeholder="Generated token..."
+                    i18n-placeholder
+                    id="token"
+                    formControlName="token">
+          </textarea>
+          <span *ngIf="importBootstrapForm.showError('token', formDir, 'required')"
+                class="invalid-feedback"
+                i18n>This field is required.</span>
+          <span *ngIf="importBootstrapForm.showError('token', formDir, 'invalidToken')"
+                class="invalid-feedback"
+                i18n>The token is invalid.</span>
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <div class="button-group text-right">
+          <cd-submit-button i18n
+                            [form]="importBootstrapForm"
+                            (submitAction)="import()">Import</cd-submit-button>
+          <cd-back-button [back]="modalRef.hide"
+                          name="Close"
+                          i18n-name>
+          </cd-back-button>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..b9fa7d8
--- /dev/null
@@ -0,0 +1,135 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import {
+  configureTestBed,
+  FormHelper,
+  i18nProviders
+} from '../../../../../testing/unit-test-helper';
+import { RbdMirroringService } from '../../../../shared/api/rbd-mirroring.service';
+import { NotificationService } from '../../../../shared/services/notification.service';
+import { SharedModule } from '../../../../shared/shared.module';
+import { BootstrapImportModalComponent } from './bootstrap-import-modal.component';
+
+describe('BootstrapImportModalComponent', () => {
+  let component: BootstrapImportModalComponent;
+  let fixture: ComponentFixture<BootstrapImportModalComponent>;
+  let notificationService: NotificationService;
+  let rbdMirroringService: RbdMirroringService;
+  let formHelper: FormHelper;
+
+  configureTestBed({
+    declarations: [BootstrapImportModalComponent],
+    imports: [
+      HttpClientTestingModule,
+      ReactiveFormsModule,
+      RouterTestingModule,
+      SharedModule,
+      ToastrModule.forRoot()
+    ],
+    providers: [BsModalRef, BsModalService, i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(BootstrapImportModalComponent);
+    component = fixture.componentInstance;
+    component.siteName = 'site-A';
+
+    notificationService = TestBed.get(NotificationService);
+    spyOn(notificationService, 'show').and.stub();
+
+    rbdMirroringService = TestBed.get(RbdMirroringService);
+
+    formHelper = new FormHelper(component.importBootstrapForm);
+
+    spyOn(rbdMirroringService, 'getSiteName').and.callFake(() => of({ site_name: 'site-A' }));
+    spyOn(rbdMirroringService, 'subscribeSummary').and.callFake((call) =>
+      of({
+        content_data: {
+          pools: [
+            { name: 'pool1', mirror_mode: 'disabled' },
+            { name: 'pool2', mirror_mode: 'disabled' },
+            { name: 'pool3', mirror_mode: 'disabled' }
+          ]
+        }
+      }).subscribe(call)
+    );
+  });
+
+  it('should import', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('import token', () => {
+    beforeEach(() => {
+      spyOn(rbdMirroringService, 'refresh').and.stub();
+      spyOn(component.modalRef, 'hide').and.callThrough();
+      fixture.detectChanges();
+    });
+
+    afterEach(() => {
+      expect(rbdMirroringService.getSiteName).toHaveBeenCalledTimes(1);
+      expect(rbdMirroringService.subscribeSummary).toHaveBeenCalledTimes(1);
+      expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+    });
+
+    it('should generate a bootstrap token', () => {
+      spyOn(rbdMirroringService, 'setSiteName').and.callFake(() => of({ site_name: 'new-site-A' }));
+      spyOn(rbdMirroringService, 'updatePool').and.callFake(() => of({}));
+      spyOn(rbdMirroringService, 'importBootstrapToken').and.callFake(() => of({ token: 'token' }));
+
+      component.importBootstrapForm.patchValue({
+        siteName: 'new-site-A',
+        pools: { pool1: true, pool3: true },
+        token: 'e30='
+      });
+      component.import();
+      expect(rbdMirroringService.setSiteName).toHaveBeenCalledWith('new-site-A');
+      expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool1', {
+        mirror_mode: 'image'
+      });
+      expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool3', {
+        mirror_mode: 'image'
+      });
+      expect(rbdMirroringService.importBootstrapToken).toHaveBeenCalledWith(
+        'pool1',
+        'rx-tx',
+        'e30='
+      );
+      expect(rbdMirroringService.importBootstrapToken).toHaveBeenCalledWith(
+        'pool3',
+        'rx-tx',
+        'e30='
+      );
+    });
+  });
+
+  describe('form validation', () => {
+    beforeEach(() => {
+      fixture.detectChanges();
+    });
+
+    it('should require a site name', () => {
+      formHelper.expectErrorChange('siteName', '', 'required');
+    });
+
+    it('should require at least one pool', () => {
+      formHelper.expectError(component.importBootstrapForm.get('pools'), 'requirePool');
+    });
+
+    it('should require a token', () => {
+      formHelper.expectErrorChange('token', '', 'required');
+    });
+
+    it('should verify token is base64-encoded JSON', () => {
+      formHelper.expectErrorChange('token', 'VEVTVA==', 'invalidToken');
+      formHelper.expectErrorChange('token', 'e2RmYXNqZGZrbH0=', 'invalidToken');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts
new file mode 100644 (file)
index 0000000..9effef7
--- /dev/null
@@ -0,0 +1,185 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+import { concat, forkJoin, Observable, Subscription } from 'rxjs';
+import { last } from 'rxjs/operators';
+
+import { RbdMirroringService } from '../../../../shared/api/rbd-mirroring.service';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+import { FinishedTask } from '../../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service';
+
+@Component({
+  selector: 'cd-bootstrap-import-modal',
+  templateUrl: './bootstrap-import-modal.component.html',
+  styleUrls: ['./bootstrap-import-modal.component.scss']
+})
+export class BootstrapImportModalComponent implements OnInit, OnDestroy {
+  siteName: string;
+  pools: any[] = [];
+  token: string;
+
+  subs: Subscription;
+
+  importBootstrapForm: CdFormGroup;
+
+  directions: Array<any> = [
+    { key: 'rx-tx', desc: 'Bidirectional' },
+    { key: 'rx', desc: 'Unidirectional (receive-only)' }
+  ];
+
+  constructor(
+    public modalRef: BsModalRef,
+    private rbdMirroringService: RbdMirroringService,
+    private taskWrapper: TaskWrapperService
+  ) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.importBootstrapForm = new CdFormGroup({
+      siteName: new FormControl('', {
+        validators: [Validators.required]
+      }),
+      direction: new FormControl('rx-tx', {}),
+      pools: new FormGroup(
+        {},
+        {
+          validators: [this.validatePools()]
+        }
+      ),
+      token: new FormControl('', {
+        validators: [Validators.required, this.validateToken()]
+      })
+    });
+  }
+
+  ngOnInit() {
+    this.rbdMirroringService.getSiteName().subscribe((response: any) => {
+      this.importBootstrapForm.get('siteName').setValue(response.site_name);
+    });
+
+    this.subs = this.rbdMirroringService.subscribeSummary((data: any) => {
+      if (!data) {
+        return;
+      }
+
+      const pools = data.content_data.pools;
+      this.pools = pools.reduce((acc, pool) => {
+        acc.push({
+          name: pool['name'],
+          mirror_mode: pool['mirror_mode']
+        });
+        return acc;
+      }, []);
+
+      const poolsControl = this.importBootstrapForm.get('pools') as FormGroup;
+      _.each(this.pools, (pool) => {
+        const poolName = pool['name'];
+        const mirroring_disabled = pool['mirror_mode'] === 'disabled';
+        const control = poolsControl.controls[poolName];
+        if (control) {
+          if (mirroring_disabled && control.disabled) {
+            control.enable();
+          } else if (!mirroring_disabled && control.enabled) {
+            control.disable();
+            control.setValue(true);
+          }
+        } else {
+          poolsControl.addControl(
+            poolName,
+            new FormControl({ value: !mirroring_disabled, disabled: !mirroring_disabled })
+          );
+        }
+      });
+    });
+  }
+
+  ngOnDestroy() {
+    if (this.subs) {
+      this.subs.unsubscribe();
+    }
+  }
+
+  validatePools(): ValidatorFn {
+    return (poolsControl: FormGroup): { [key: string]: any } => {
+      let checkedCount = 0;
+      _.each(poolsControl.controls, (control) => {
+        if (control.value === true) {
+          ++checkedCount;
+        }
+      });
+
+      if (checkedCount > 0) {
+        return null;
+      }
+
+      return { requirePool: true };
+    };
+  }
+
+  validateToken(): ValidatorFn {
+    return (token: FormControl): { [key: string]: any } => {
+      try {
+        if (JSON.parse(atob(token.value))) {
+          return null;
+        }
+      } catch (error) {}
+      return { invalidToken: true };
+    };
+  }
+
+  import() {
+    const bootstrapPoolNames: string[] = [];
+    const poolNames: string[] = [];
+    const poolsControl = this.importBootstrapForm.get('pools') as FormGroup;
+    _.each(poolsControl.controls, (control, poolName) => {
+      if (control.value === true) {
+        bootstrapPoolNames.push(poolName);
+        if (!control.disabled) {
+          poolNames.push(poolName);
+        }
+      }
+    });
+
+    const poolModeRequest = {
+      mirror_mode: 'image'
+    };
+
+    let apiActionsObs: Observable<any> = concat(
+      this.rbdMirroringService.setSiteName(this.importBootstrapForm.getValue('siteName')),
+      forkJoin(
+        poolNames.map((poolName) => this.rbdMirroringService.updatePool(poolName, poolModeRequest))
+      )
+    );
+
+    apiActionsObs = bootstrapPoolNames
+      .reduce((obs, poolName) => {
+        return concat(
+          obs,
+          this.rbdMirroringService.importBootstrapToken(
+            poolName,
+            this.importBootstrapForm.getValue('direction'),
+            this.importBootstrapForm.getValue('token')
+          )
+        );
+      }, apiActionsObs)
+      .pipe(last());
+
+    const finishHandler = () => {
+      this.rbdMirroringService.refresh();
+      this.importBootstrapForm.setErrors({ cdSubmitButton: true });
+    };
+
+    const taskObs = this.taskWrapper.wrapTaskAroundCall({
+      task: new FinishedTask('rbd/mirroring/bootstrap/import', {}),
+      call: apiActionsObs
+    });
+    taskObs.subscribe(undefined, finishHandler, () => {
+      finishHandler();
+      this.modalRef.hide();
+    });
+  }
+}
index 9df7e83db1ea77ba7f34702cc32f55fab4573511..e5c8d240c991d0ec5901fa4d54ad5a6d14df6630 100644 (file)
@@ -14,6 +14,8 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { SharedModule } from '../../../shared/shared.module';
 
+import { BootstrapCreateModalComponent } from './bootstrap-create-modal/bootstrap-create-modal.component';
+import { BootstrapImportModalComponent } from './bootstrap-import-modal/bootstrap-import-modal.component';
 import { DaemonListComponent } from './daemon-list/daemon-list.component';
 import { EditSiteNameModalComponent } from './edit-site-name-modal/edit-site-name-modal.component';
 import { ImageListComponent } from './image-list/image-list.component';
@@ -25,6 +27,8 @@ import { PoolListComponent } from './pool-list/pool-list.component';
 
 @NgModule({
   entryComponents: [
+    BootstrapCreateModalComponent,
+    BootstrapImportModalComponent,
     EditSiteNameModalComponent,
     OverviewComponent,
     PoolEditModeModalComponent,
@@ -46,6 +50,8 @@ import { PoolListComponent } from './pool-list/pool-list.component';
     NgBootstrapFormValidationModule
   ],
   declarations: [
+    BootstrapCreateModalComponent,
+    BootstrapImportModalComponent,
     DaemonListComponent,
     EditSiteNameModalComponent,
     ImageListComponent,
index b2047f1c385df569813791eb8f3a271f3f019f8c..5e2297082c910c3c65a396f50a90dbe1bd1b9545 100644 (file)
@@ -11,6 +11,8 @@ import { CdTableAction } from '../../../../shared/models/cd-table-action';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
 import { Permission } from '../../../../shared/models/permissions';
 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
+import { BootstrapCreateModalComponent } from '../bootstrap-create-modal/bootstrap-create-modal.component';
+import { BootstrapImportModalComponent } from '../bootstrap-import-modal/bootstrap-import-modal.component';
 import { EditSiteNameModalComponent } from '../edit-site-name-modal/edit-site-name-modal.component';
 
 @Component({
@@ -47,7 +49,21 @@ export class OverviewComponent implements OnInit, OnDestroy {
       canBePrimary: () => true,
       disable: () => false
     };
-    this.tableActions = [editSiteNameAction];
+    const createBootstrapAction: CdTableAction = {
+      permission: 'update',
+      icon: Icons.upload,
+      click: () => this.createBootstrapModal(),
+      name: this.i18n('Create Bootstrap Token'),
+      disable: () => false
+    };
+    const importBootstrapAction: CdTableAction = {
+      permission: 'update',
+      icon: Icons.download,
+      click: () => this.importBootstrapModal(),
+      name: this.i18n('Import Bootstrap Token'),
+      disable: () => this.peersExist
+    };
+    this.tableActions = [editSiteNameAction, createBootstrapAction, importBootstrapAction];
   }
 
   ngOnInit() {
@@ -72,4 +88,18 @@ export class OverviewComponent implements OnInit, OnDestroy {
     };
     this.modalRef = this.modalService.show(EditSiteNameModalComponent, { initialState });
   }
+
+  createBootstrapModal() {
+    const initialState = {
+      siteName: this.siteName
+    };
+    this.modalRef = this.modalService.show(BootstrapCreateModalComponent, { initialState });
+  }
+
+  importBootstrapModal() {
+    const initialState = {
+      siteName: this.siteName
+    };
+    this.modalRef = this.modalService.show(BootstrapImportModalComponent, { initialState });
+  }
 }
index 8e2d0c67eede2daac395fa2135a0ecd5bb25044b..5fb20ad604a7b71f01ab22e8d9937749000a1840 100644 (file)
@@ -58,6 +58,8 @@ export enum Icons {
   rightArrowDouble = 'fa fa-angle-double-right', // Left facing Double angle
   flag = 'fa fa-flag', // OSD configuration
   clearFilters = 'fa fa-window-close', // Clear filters, solid x
+  download = 'fa fa-download', // Download
+  upload = 'fa fa-upload', // Upload
 
   /* Icons for special effect */
   large = 'fa fa-lg', // icon becomes 33% larger
index df2281283f63a580eb7554d1d420b4d0fd3902ca..6ca2c4d02dcc53fe25b3ec9108b6ddf4d923d3da 100644 (file)
@@ -67,6 +67,10 @@ describe('TaskManagerMessageService', () => {
       testMessages(new TaskMessageOperation('Deleting', 'delete', 'Deleted'), involves);
     };
 
+    const testImport = (involves: string) => {
+      testMessages(new TaskMessageOperation('Importing', 'import', 'Imported'), involves);
+    };
+
     const testErrorCode = (code: number, msg: string) => {
       finishedTask.exception = _.assign(new TaskException(), {
         code: code
@@ -256,6 +260,14 @@ describe('TaskManagerMessageService', () => {
         finishedTask.name = 'rbd/mirroring/site_name/edit';
         testUpdate('mirroring site name');
       });
+      it('tests rbd/mirroring/bootstrap/create messages', () => {
+        finishedTask.name = 'rbd/mirroring/bootstrap/create';
+        testCreate('bootstrap token');
+      });
+      it('tests rbd/mirroring/bootstrap/import messages', () => {
+        finishedTask.name = 'rbd/mirroring/bootstrap/import';
+        testImport('bootstrap token');
+      });
       it('tests rbd/mirroring/pool/edit messages', () => {
         finishedTask.name = 'rbd/mirroring/pool/edit';
         testUpdate(modeMsg);
index d1ede98fc299c1fc841b1a046b5a3f446425c4ca..86a0def82f2eac93b8472f08c5dbc5ea49b71baa 100644 (file)
@@ -94,6 +94,11 @@ export class TaskMessageService {
       this.i18n('Removing'),
       this.i18n('remove'),
       this.i18n('Removed')
+    ),
+    import: new TaskMessageOperation(
+      this.i18n('Importing'),
+      this.i18n('import'),
+      this.i18n('Imported')
     )
   };
 
@@ -140,6 +145,7 @@ export class TaskMessageService {
 
   rbd_mirroring = {
     site_name: () => this.i18n('mirroring site name'),
+    bootstrap: () => this.i18n('bootstrap token'),
     pool: (metadata) =>
       this.i18n(`mirror mode for pool '{{id}}'`, {
         id: `${metadata.pool_name}`
@@ -333,6 +339,16 @@ export class TaskMessageService {
       this.rbd_mirroring.site_name,
       () => ({})
     ),
+    'rbd/mirroring/bootstrap/create': this.newTaskMessage(
+      this.commonOperations.create,
+      this.rbd_mirroring.bootstrap,
+      () => ({})
+    ),
+    'rbd/mirroring/bootstrap/import': this.newTaskMessage(
+      this.commonOperations.import,
+      this.rbd_mirroring.bootstrap,
+      () => ({})
+    ),
     'rbd/mirroring/pool/edit': this.newTaskMessage(
       this.commonOperations.update,
       this.rbd_mirroring.pool,