From afcead8a6ea8de83a6c25345eda92c5db5bef55a Mon Sep 17 00:00:00 2001 From: Jason Dillaman Date: Wed, 30 Oct 2019 14:55:41 -0400 Subject: [PATCH] mgr/dashboard: block mirroring bootstrap UI 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 --- .../bootstrap-create-modal.component.html | 91 +++++++++ .../bootstrap-create-modal.component.scss | 3 + .../bootstrap-create-modal.component.spec.ts | 117 +++++++++++ .../bootstrap-create-modal.component.ts | 156 +++++++++++++++ .../bootstrap-import-modal.component.html | 108 ++++++++++ .../bootstrap-import-modal.component.scss | 0 .../bootstrap-import-modal.component.spec.ts | 135 +++++++++++++ .../bootstrap-import-modal.component.ts | 185 ++++++++++++++++++ .../ceph/block/mirroring/mirroring.module.ts | 6 + .../mirroring/overview/overview.component.ts | 32 ++- .../src/app/shared/enum/icons.enum.ts | 2 + .../services/task-message.service.spec.ts | 12 ++ .../shared/services/task-message.service.ts | 16 ++ 13 files changed, 862 insertions(+), 1 deletion(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.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 index 0000000000000..9aab16390997f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html @@ -0,0 +1,91 @@ + + Create Bootstrap Token + + +
+ + + +
+
+
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 index 0000000000000..8dc4d1c738cc9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss @@ -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 index 0000000000000..eb288af885444 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts @@ -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; + 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 index 0000000000000..ce573f820bb86 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts @@ -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 index 0000000000000..6bb4cba0db769 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html @@ -0,0 +1,108 @@ + + Import Bootstrap Token + + +
+ + + +
+
+
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 index 0000000000000..e69de29bb2d1d 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 index 0000000000000..b9fa7d846ea88 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts @@ -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; + 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 index 0000000000000..9effef71d0cd2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts @@ -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 = [ + { 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 = 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(); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts index 9df7e83db1ea7..e5c8d240c991d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts @@ -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, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts index b2047f1c385df..5e2297082c910 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts @@ -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 }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 8e2d0c67eede2..5fb20ad604a7b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -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 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts index df2281283f63a..6ca2c4d02dcc5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts @@ -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); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index d1ede98fc299c..86a0def82f2ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -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, -- 2.39.5