diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts new file mode 100644 index 00000000000..c74a6bd3715 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts @@ -0,0 +1,63 @@ +export class ErasureCodeProfileFormTooltips { + // Copied from /srv/cephmgr/ceph-dev/doc/rados/operations/erasure-code.*.rst + k = `Each object is split in data-chunks parts, each stored on a different OSD.`; + + m = `Compute coding chunks for each object and store them on different OSDs. + The number of coding chunks is also the number of OSDs that can be down without losing data.`; + + plugins = { + jerasure: { + description: `The jerasure plugin is the most generic and flexible plugin, + it is also the default for Ceph erasure coded pools.`, + technique: `The more flexible technique is reed_sol_van : it is enough to set k and m. + The cauchy_good technique can be faster but you need to chose the packetsize carefully. + All of reed_sol_r6_op, liberation, blaum_roth, liber8tion are RAID6 equivalents in the + sense that they can only be configured with m=2.`, + packetSize: `The encoding will be done on packets of bytes size at a time. + Chosing the right packet size is difficult. + The jerasure documentation contains extensive information on this topic.` + }, + lrc: { + description: `With the jerasure plugin, when an erasure coded object is stored + on multiple OSDs, recovering from the loss of one OSD requires reading from all the others. + For instance if jerasure is configured with k=8 and m=4, losing one OSD requires + reading from the eleven others to repair. + + The lrc erasure code plugin creates local parity chunks to be able to recover using + less OSDs. For instance if lrc is configured with k=8, m=4 and l=4, it will create + an additional parity chunk for every four OSDs. When a single OSD is lost, it can be + recovered with only four OSDs instead of eleven.`, + l: `Group the coding and data chunks into sets of size locality. For instance, + for k=4 and m=2, when locality=3 two groups of three are created. Each set can + be recovered without reading chunks from another set.`, + crushLocality: `The type of the crush bucket in which each set of chunks defined by l + will be stored. For instance, if it is set to rack, each group of l chunks will be placed + in a different rack. It is used to create a CRUSH rule step such as step choose rack. + If it is not set, no such grouping is done.` + }, + isa: { + description: `The isa plugin encapsulates the ISA library. It only runs on Intel processors.`, + technique: `The ISA plugin comes in two Reed Solomon forms. + If reed_sol_van is set, it is Vandermonde, if cauchy is set, it is Cauchy.` + }, + shec: { + description: `The shec plugin encapsulates the multiple SHEC library. + It allows ceph to recover data more efficiently than Reed Solomon codes.`, + c: `The number of parity chunks each of which includes each data chunk in its calculation + range. The number is used as a durability estimator. For instance, if c=2, 2 OSDs can + be down without losing data.` + } + }; + + crushRoot = `The name of the crush bucket used for the first step of the CRUSH rule. + For instance step take default.`; + + crushFailureDomain = `Ensure that no two chunks are in a bucket with the same failure domain. + For instance, if the failure domain is host no two chunks will be stored on the same host. + It is used to create a CRUSH rule step such as step chooseleaf host.`; + + crushDeviceClass = `Restrict placement to devices of a specific class (e.g., ssd or hdd), + using the crush device class names in the CRUSH map.`; + + directory = `Set the directory name from which the erasure code plugin is loaded.`; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html new file mode 100644 index 00000000000..43be4265dc3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html @@ -0,0 +1,394 @@ + + +
+ + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts new file mode 100644 index 00000000000..b5819e9c0ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts @@ -0,0 +1,324 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastModule } from 'ng2-toastr'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { of } from 'rxjs'; + +import { configureTestBed, FormHelper } from '../../../../testing/unit-test-helper'; +import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; +import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile'; +import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { PoolModule } from '../pool.module'; +import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form.component'; + +describe('ErasureCodeProfileFormComponent', () => { + let component: ErasureCodeProfileFormComponent; + let ecpService: ErasureCodeProfileService; + let fixture: ComponentFixture; + let formHelper: FormHelper; + let data: {}; + + configureTestBed({ + imports: [HttpClientTestingModule, RouterTestingModule, ToastModule.forRoot(), PoolModule], + providers: [ErasureCodeProfileService, BsModalRef] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ErasureCodeProfileFormComponent); + component = fixture.componentInstance; + formHelper = new FormHelper(component.form); + ecpService = TestBed.get(ErasureCodeProfileService); + data = { + failure_domains: ['host', 'osd'], + plugins: ['isa', 'jerasure', 'shec', 'lrc'], + names: ['ecp1', 'ecp2'], + devices: ['ssd', 'hdd'] + }; + spyOn(ecpService, 'getInfo').and.callFake(() => of(data)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('calls listing to get ecps on ngInit', () => { + expect(ecpService.getInfo).toHaveBeenCalled(); + expect(component.names.length).toBe(2); + }); + + describe('form validation', () => { + it(`isn't valid if name is not set`, () => { + expect(component.form.invalid).toBeTruthy(); + formHelper.setValue('name', 'someProfileName'); + expect(component.form.valid).toBeTruthy(); + }); + + it('sets name invalid', () => { + component.names = ['awesomeProfileName']; + formHelper.expectErrorChange('name', 'awesomeProfileName', 'uniqueName'); + formHelper.expectErrorChange('name', 'some invalid text', 'pattern'); + formHelper.expectErrorChange('name', null, 'required'); + }); + + it('sets k to min error', () => { + formHelper.expectErrorChange('k', 0, 'min'); + }); + + it('sets m to min error', () => { + formHelper.expectErrorChange('m', 0, 'min'); + }); + + it(`should show all default form controls`, () => { + const showDefaults = (plugin) => { + formHelper.setValue('plugin', plugin); + formHelper.expectIdElementsVisible( + fixture, + [ + 'name', + 'plugin', + 'k', + 'm', + 'crushFailureDomain', + 'crushRoot', + 'crushDeviceClass', + 'directory' + ], + true + ); + }; + showDefaults('jerasure'); + showDefaults('shec'); + showDefaults('lrc'); + showDefaults('isa'); + }); + + describe(`for 'jerasure' plugin (default)`, () => { + it(`requires 'm' and 'k'`, () => { + formHelper.expectErrorChange('k', null, 'required'); + formHelper.expectErrorChange('m', null, 'required'); + }); + + it(`should show 'packetSize' and 'technique'`, () => { + formHelper.expectIdElementsVisible(fixture, ['packetSize', 'technique'], true); + }); + + it(`should not show any other plugin specific form control`, () => { + formHelper.expectIdElementsVisible(fixture, ['c', 'l', 'crushLocality'], false); + }); + }); + + describe(`for 'isa' plugin`, () => { + beforeEach(() => { + formHelper.setValue('plugin', 'isa'); + }); + + it(`does not require 'm' and 'k'`, () => { + formHelper.setValue('k', null); + formHelper.expectValidChange('k', null); + formHelper.expectValidChange('m', null); + }); + + it(`should show 'technique'`, () => { + formHelper.expectIdElementsVisible(fixture, ['technique'], true); + expect(fixture.debugElement.query(By.css('#technique'))).toBeTruthy(); + }); + + it(`should not show any other plugin specific form control`, () => { + formHelper.expectIdElementsVisible( + fixture, + ['c', 'l', 'crushLocality', 'packetSize'], + false + ); + }); + }); + + describe(`for 'lrc' plugin`, () => { + beforeEach(() => { + formHelper.setValue('plugin', 'lrc'); + }); + + it(`requires 'm', 'l' and 'k'`, () => { + formHelper.expectErrorChange('k', null, 'required'); + formHelper.expectErrorChange('m', null, 'required'); + }); + + it(`should show 'l' and 'crushLocality'`, () => { + formHelper.expectIdElementsVisible(fixture, ['l', 'crushLocality'], true); + }); + + it(`should not show any other plugin specific form control`, () => { + formHelper.expectIdElementsVisible(fixture, ['c', 'packetSize', 'technique'], false); + }); + }); + + describe(`for 'shec' plugin`, () => { + beforeEach(() => { + formHelper.setValue('plugin', 'shec'); + }); + + it(`does not require 'm' and 'k'`, () => { + formHelper.expectValidChange('k', null); + formHelper.expectValidChange('m', null); + }); + + it(`should show 'c'`, () => { + formHelper.expectIdElementsVisible(fixture, ['c'], true); + }); + + it(`should not show any other plugin specific form control`, () => { + formHelper.expectIdElementsVisible( + fixture, + ['l', 'crushLocality', 'packetSize', 'technique'], + false + ); + }); + }); + }); + + describe('submission', () => { + let ecp: ErasureCodeProfile; + + const testCreation = () => { + fixture.detectChanges(); + component.onSubmit(); + expect(ecpService.create).toHaveBeenCalledWith(ecp); + }; + + beforeEach(() => { + ecp = new ErasureCodeProfile(); + const taskWrapper = TestBed.get(TaskWrapperService); + spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough(); + spyOn(ecpService, 'create').and.stub(); + }); + + describe(`'jerasure' usage`, () => { + beforeEach(() => { + ecp.name = 'jerasureProfile'; + }); + + it('should be able to create a profile with only required fields', () => { + formHelper.setMultipleValues(ecp, true); + ecp.k = 4; + ecp.m = 2; + testCreation(); + }); + + it(`does not create with missing 'k' or invalid form`, () => { + ecp.k = 0; + formHelper.setMultipleValues(ecp, true); + component.onSubmit(); + expect(ecpService.create).not.toHaveBeenCalled(); + }); + + it('should be able to create a profile with m, k, name, directory and packetSize', () => { + ecp.m = 3; + ecp.directory = '/different/ecp/path'; + formHelper.setMultipleValues(ecp, true); + ecp.k = 4; + formHelper.setValue('packetSize', 8192, true); + ecp.packetsize = 8192; + testCreation(); + }); + + it('should not send the profile with unsupported fields', () => { + formHelper.setMultipleValues(ecp, true); + ecp.k = 4; + ecp.m = 2; + formHelper.setValue('crushLocality', 'osd', true); + testCreation(); + }); + }); + + describe(`'isa' usage`, () => { + beforeEach(() => { + ecp.name = 'isaProfile'; + ecp.plugin = 'isa'; + }); + + it('should be able to create a profile with only plugin and name', () => { + formHelper.setMultipleValues(ecp, true); + testCreation(); + }); + + it('should send profile with plugin, name, failure domain and technique only', () => { + ecp.technique = 'cauchy'; + formHelper.setMultipleValues(ecp, true); + formHelper.setValue('crushFailureDomain', 'osd', true); + ecp['crush-failure-domain'] = 'osd'; + testCreation(); + }); + + it('should not send the profile with unsupported fields', () => { + formHelper.setMultipleValues(ecp, true); + formHelper.setValue('packetSize', 'osd', true); + testCreation(); + }); + }); + + describe(`'lrc' usage`, () => { + beforeEach(() => { + ecp.name = 'lreProfile'; + ecp.plugin = 'lrc'; + }); + + it('should be able to create a profile with only required fields', () => { + formHelper.setMultipleValues(ecp, true); + ecp.k = 4; + ecp.m = 2; + ecp.l = 3; + testCreation(); + }); + + it('should send profile with all required fields and crush root and locality', () => { + ecp.l = 8; + formHelper.setMultipleValues(ecp, true); + ecp.k = 4; + ecp.m = 2; + formHelper.setValue('crushLocality', 'osd', true); + formHelper.setValue('crushRoot', 'rack', true); + ecp['crush-locality'] = 'osd'; + ecp['crush-root'] = 'rack'; + testCreation(); + }); + + it('should not send the profile with unsupported fields', () => { + formHelper.setMultipleValues(ecp, true); + ecp.k = 4; + ecp.m = 2; + ecp.l = 3; + formHelper.setValue('c', 4, true); + testCreation(); + }); + }); + + describe(`'shec' usage`, () => { + beforeEach(() => { + ecp.name = 'shecProfile'; + ecp.plugin = 'shec'; + }); + + it('should be able to create a profile with only plugin and name', () => { + formHelper.setMultipleValues(ecp, true); + testCreation(); + }); + + it('should send profile with plugin, name, c and crush device class only', () => { + ecp.c = 4; + formHelper.setMultipleValues(ecp, true); + formHelper.setValue('crushDeviceClass', 'ssd', true); + ecp['crush-device-class'] = 'ssd'; + testCreation(); + }); + + it('should not send the profile with unsupported fields', () => { + formHelper.setMultipleValues(ecp, true); + formHelper.setValue('l', 8, true); + testCreation(); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts new file mode 100644 index 00000000000..6e3218b47f7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts @@ -0,0 +1,252 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { Validators } from '@angular/forms'; + +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; +import { CdFormBuilder } from '../../../shared/forms/cd-form-builder'; +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { CdValidators } from '../../../shared/forms/cd-validators'; +import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile'; +import { FinishedTask } from '../../../shared/models/finished-task'; +import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { ErasureCodeProfileFormTooltips } from './erasure-code-profile-form-tooltips'; + +@Component({ + selector: 'cd-erasure-code-profile-form', + templateUrl: './erasure-code-profile-form.component.html', + styleUrls: ['./erasure-code-profile-form.component.scss'] +}) +export class ErasureCodeProfileFormComponent implements OnInit { + @Output() + submitAction = new EventEmitter(); + + form: CdFormGroup; + failureDomains: string[]; + plugins: string[]; + names: string[]; + techniques: string[]; + requiredControls: string[] = []; + devices: string[] = []; + tooltips = new ErasureCodeProfileFormTooltips(); + + PLUGIN = { + LRC: 'lrc', // Locally Repairable Erasure Code + SHEC: 'shec', // Shingled Erasure Code + JERASURE: 'jerasure', // default + ISA: 'isa' // Intel Storage Acceleration + }; + plugin = this.PLUGIN.JERASURE; + + constructor( + private formBuilder: CdFormBuilder, + public bsModalRef: BsModalRef, + private taskWrapper: TaskWrapperService, + private ecpService: ErasureCodeProfileService + ) { + this.createForm(); + this.setJerasureDefaults(); + } + + createForm() { + this.form = this.formBuilder.group({ + name: [ + null, + [ + Validators.required, + Validators.pattern('[A-Za-z0-9_-]+'), + CdValidators.custom( + 'uniqueName', + (value) => this.names && this.names.indexOf(value) !== -1 + ) + ] + ], + plugin: [this.PLUGIN.JERASURE, [Validators.required]], + k: [1], // Will be replaced by plugin defaults + m: [1], // Will be replaced by plugin defaults + crushFailureDomain: ['host'], + crushRoot: ['default'], // default for all - is a list possible??? + crushDeviceClass: [''], // set none to empty at submit - get list from configs? + directory: [''], + // Only for 'jerasure' and 'isa' use + technique: ['reed_sol_van'], + // Only for 'jerasure' use + packetSize: [2048, [Validators.min(1)]], + // Only for 'lrc' use + l: [1, [Validators.required, Validators.min(1)]], + crushLocality: [''], // set to none at the end (same list as for failure domains) + // Only for 'shec' use + c: [1, [Validators.required, Validators.min(1)]] + }); + this.form.get('plugin').valueChanges.subscribe((plugin) => this.onPluginChange(plugin)); + } + + onPluginChange(plugin) { + this.plugin = plugin; + if (plugin === this.PLUGIN.JERASURE) { + this.setJerasureDefaults(); + } else if (plugin === this.PLUGIN.LRC) { + this.setLrcDefaults(); + } else if (plugin === this.PLUGIN.ISA) { + this.setIsaDefaults(); + } else if (plugin === this.PLUGIN.SHEC) { + this.setShecDefaults(); + } + } + + private setNumberValidators(name: string, required: boolean) { + const validators = [Validators.min(1)]; + if (required) { + validators.push(Validators.required); + } + this.form.get(name).setValidators(validators); + } + + private setKMValidators(required: boolean) { + ['k', 'm'].forEach((name) => this.setNumberValidators(name, required)); + } + + private setJerasureDefaults() { + this.requiredControls = ['k', 'm']; + this.setDefaults({ + k: 4, + m: 2 + }); + this.setKMValidators(true); + this.techniques = [ + 'reed_sol_van', + 'reed_sol_r6_op', + 'cauchy_orig', + 'cauchy_good', + 'liberation', + 'blaum_roth', + 'liber8tion' + ]; + } + + private setLrcDefaults() { + this.requiredControls = ['k', 'm', 'l']; + this.setKMValidators(true); + this.setNumberValidators('l', true); + this.setDefaults({ + k: 4, + m: 2, + l: 3 + }); + } + + private setIsaDefaults() { + this.requiredControls = []; + this.setKMValidators(false); + this.setDefaults({ + k: 7, + m: 3 + }); + this.techniques = ['reed_sol_van', 'cauchy']; + } + + private setShecDefaults() { + this.requiredControls = []; + this.setKMValidators(false); + this.setDefaults({ + k: 4, + m: 3, + c: 2 + }); + } + + private setDefaults(defaults: object) { + Object.keys(defaults).forEach((controlName) => { + if (this.form.get(controlName).pristine) { + this.form.silentSet(controlName, defaults[controlName]); + } + }); + } + + ngOnInit() { + this.ecpService + .getInfo() + .subscribe( + ({ + failure_domains, + plugins, + names, + directory, + devices + }: { + failure_domains: string[]; + plugins: string[]; + names: string[]; + directory: string; + devices: string[]; + }) => { + this.failureDomains = failure_domains; + this.plugins = plugins; + this.names = names; + this.devices = devices; + this.form.silentSet('directory', directory); + } + ); + } + + private createJson() { + const pluginControls = { + technique: [this.PLUGIN.ISA, this.PLUGIN.JERASURE], + packetSize: [this.PLUGIN.JERASURE], + l: [this.PLUGIN.LRC], + crushLocality: [this.PLUGIN.LRC], + c: [this.PLUGIN.SHEC] + }; + const ecp = new ErasureCodeProfile(); + const plugin = this.form.getValue('plugin'); + Object.keys(this.form.controls) + .filter((name) => { + const pluginControl = pluginControls[name]; + const control = this.form.get(name); + const usable = (pluginControl && pluginControl.includes(plugin)) || !pluginControl; + return ( + usable && + (control.dirty || this.requiredControls.includes(name)) && + this.form.getValue(name) + ); + }) + .forEach((name) => { + this.extendJson(name, ecp); + }); + return ecp; + } + + private extendJson(name: string, ecp: ErasureCodeProfile) { + const differentApiAttributes = { + crushFailureDomain: 'crush-failure-domain', + crushRoot: 'crush-root', + crushDeviceClass: 'crush-device-class', + packetSize: 'packetsize', + crushLocality: 'crush-locality' + }; + ecp[differentApiAttributes[name] || name] = this.form.getValue(name); + } + + onSubmit() { + if (this.form.invalid) { + this.form.setErrors({ cdSubmitButton: true }); + return; + } + const profile = this.createJson(); + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('ecp/create', { name: profile.name }), + call: this.ecpService.create(profile) + }) + .subscribe( + undefined, + (resp) => { + this.form.setErrors({ cdSubmitButton: true }); + }, + () => { + this.bsModalRef.hide(); + this.submitAction.emit(profile); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts index b10599ec5ab..971aece1413 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts @@ -6,6 +6,7 @@ import { Pool } from '../pool'; export class PoolFormData { poolTypes = ['erasure', 'replicated']; + erasureInfo = false; applications = { selected: [], available: [ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html index f67bbdbba72..fe04c437fe1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html @@ -230,30 +230,64 @@ Erasure code profile
- +
+ + + + + + +
+ + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts index c5ad64d39a2..361f8e6d187 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts @@ -6,15 +6,18 @@ import { ActivatedRoute, Router, Routes } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { ToastModule } from 'ng2-toastr'; +import { BsModalService } from 'ngx-bootstrap/modal'; import { of } from 'rxjs'; import { configureTestBed, FormHelper } from '../../../../testing/unit-test-helper'; import { NotFoundComponent } from '../../../core/not-found/not-found.component'; import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; import { PoolService } from '../../../shared/api/pool.service'; +import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; import { SelectBadgesComponent } from '../../../shared/components/select-badges/select-badges.component'; import { CdFormGroup } from '../../../shared/forms/cd-form-group'; import { CrushRule } from '../../../shared/models/crush-rule'; +import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile'; import { Permission } from '../../../shared/models/permissions'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; @@ -30,6 +33,7 @@ describe('PoolFormComponent', () => { let poolService: PoolService; let form: CdFormGroup; let router: Router; + let ecpService: ErasureCodeProfileService; const setPgNum = (pgs): AbstractControl => { formHelper.setValue('poolType', 'erasure'); @@ -108,7 +112,9 @@ describe('PoolFormComponent', () => { crush_rules_replicated: [], crush_rules_erasure: [] }; - component.ecProfiles = []; + const ecp1 = new ErasureCodeProfile(); + ecp1.name = 'ecp1'; + component.ecProfiles = [ecp1]; form = component.form; formHelper = new FormHelper(form); }; @@ -134,7 +140,7 @@ describe('PoolFormComponent', () => { setUpPoolComponent(); poolService = TestBed.get(PoolService); spyOn(poolService, 'getInfo').and.callFake(() => [component.info]); - const ecpService = TestBed.get(ErasureCodeProfileService); + ecpService = TestBed.get(ErasureCodeProfileService); spyOn(ecpService, 'list').and.callFake(() => [component.ecProfiles]); router = TestBed.get(Router); spyOn(router, 'navigate').and.stub(); @@ -728,6 +734,73 @@ describe('PoolFormComponent', () => { }); }); + describe('erasure code profile', () => { + const setSelectedEcp = (name: string) => { + formHelper.setValue('erasureProfile', { name: name }); + }; + + beforeEach(() => { + formHelper.setValue('poolType', 'erasure'); + fixture.detectChanges(); + }); + + it('should not show info per default', () => { + formHelper.expectElementVisible(fixture, '#erasureProfile', true); + formHelper.expectElementVisible(fixture, '#ecp-info-block', false); + }); + + it('should show info if the info button is clicked', () => { + const infoButton = fixture.debugElement.query(By.css('#ecp-info-button')); + infoButton.triggerEventHandler('click', null); + expect(component.data.erasureInfo).toBeTruthy(); + fixture.detectChanges(); + expect(infoButton.classes['active']).toBeTruthy(); + formHelper.expectIdElementsVisible(fixture, ['erasureProfile', 'ecp-info-block'], true); + }); + + describe('ecp deletion', () => { + let taskWrapper: TaskWrapperService; + let deletion: CriticalConfirmationModalComponent; + + const callDeletion = () => { + component.deleteErasureCodeProfile(); + deletion.submitActionObservable(); + }; + + const testPoolDeletion = (name) => { + setSelectedEcp(name); + callDeletion(); + expect(ecpService.delete).toHaveBeenCalledWith(name); + expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({ + task: { + name: 'ecp/delete', + metadata: { + name: name + } + }, + call: undefined // because of stub + }); + }; + + beforeEach(() => { + spyOn(TestBed.get(BsModalService), 'show').and.callFake((deletionClass, config) => { + deletion = Object.assign(new deletionClass(), config.initialState); + return { + content: deletion + }; + }); + spyOn(ecpService, 'delete').and.stub(); + taskWrapper = TestBed.get(TaskWrapperService); + spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough(); + }); + + it('should delete two different erasure code profiles', () => { + testPoolDeletion('someEcpName'); + testPoolDeletion('aDifferentEcpName'); + }); + }); + }); + describe('submit - create', () => { const setMultipleValues = (settings: {}) => { Object.keys(settings).forEach((name) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts index 1412fbea588..a43198ff222 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts @@ -3,10 +3,12 @@ import { FormControl, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import * as _ from 'lodash'; -import { forkJoin } from 'rxjs'; +import { BsModalService } from 'ngx-bootstrap/modal'; +import { forkJoin, Subscription } from 'rxjs'; import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; import { PoolService } from '../../../shared/api/pool.service'; +import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; import { CdFormGroup } from '../../../shared/forms/cd-form-group'; import { CdValidators } from '../../../shared/forms/cd-validators'; import { CrushRule } from '../../../shared/models/crush-rule'; @@ -18,6 +20,7 @@ import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; import { FormatterService } from '../../../shared/services/formatter.service'; import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { ErasureCodeProfileFormComponent } from '../erasure-code-profile-form/erasure-code-profile-form.component'; import { Pool } from '../pool'; import { PoolFormData } from './pool-form-data'; import { PoolFormInfo } from './pool-form-info'; @@ -37,6 +40,7 @@ export class PoolFormComponent implements OnInit { editing = false; data = new PoolFormData(); externalPgChange = false; + private modalSubscription: Subscription; current = { rules: [] }; @@ -45,9 +49,11 @@ export class PoolFormComponent implements OnInit { private dimlessBinaryPipe: DimlessBinaryPipe, private route: ActivatedRoute, private router: Router, + private modalService: BsModalService, private poolService: PoolService, private authStorageService: AuthStorageService, private formatter: FormatterService, + private bsModalService: BsModalService, private taskWrapper: TaskWrapperService, private ecpService: ErasureCodeProfileService ) { @@ -138,11 +144,15 @@ export class PoolFormComponent implements OnInit { } private initEcp(ecProfiles: ErasureCodeProfile[]) { - if (ecProfiles.length === 1) { - const control = this.form.get('erasureProfile'); - control.setValue(ecProfiles[0]); + const control = this.form.get('erasureProfile'); + if (ecProfiles.length <= 1) { control.disable(); } + if (ecProfiles.length === 1) { + control.setValue(ecProfiles[0]); + } else if (ecProfiles.length > 1 && control.disabled) { + control.enable(); + } this.ecProfiles = ecProfiles; } @@ -424,6 +434,35 @@ export class PoolFormComponent implements OnInit { ].join(' '); } + addErasureCodeProfile() { + this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs()); + this.bsModalService.show(ErasureCodeProfileFormComponent); + } + + private reloadECPs() { + this.ecpService.list().subscribe((profiles: ErasureCodeProfile[]) => this.initEcp(profiles)); + this.modalSubscription.unsubscribe(); + } + + deleteErasureCodeProfile() { + const ecp = this.form.getValue('erasureProfile'); + if (!ecp) { + return; + } + const name = ecp.name; + this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs()); + this.modalService.show(CriticalConfirmationModalComponent, { + initialState: { + itemDescription: 'erasure code profile', + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('ecp/delete', { name: name }), + call: this.ecpService.delete(name) + }) + } + }); + } + submit() { if (this.form.invalid) { this.form.setErrors({ cdSubmitButton: true }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts index 1ca6427b272..d086dd05cc2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts @@ -9,6 +9,7 @@ import { TabsModule } from 'ngx-bootstrap/tabs'; import { ServicesModule } from '../../shared/services/services.module'; import { SharedModule } from '../../shared/shared.module'; +import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form/erasure-code-profile-form.component'; import { PoolFormComponent } from './pool-form/pool-form.component'; import { PoolListComponent } from './pool-list/pool-list.component'; @@ -24,6 +25,7 @@ import { PoolListComponent } from './pool-list/pool-list.component'; ServicesModule ], exports: [PoolListComponent, PoolFormComponent], - declarations: [PoolListComponent, PoolFormComponent] + declarations: [PoolListComponent, PoolFormComponent, ErasureCodeProfileFormComponent], + entryComponents: [ErasureCodeProfileFormComponent] }) export class PoolModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts index 6284fbbba97..180a3507347 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts @@ -2,11 +2,14 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { TestBed } from '@angular/core/testing'; import { configureTestBed } from '../../../testing/unit-test-helper'; +import { ErasureCodeProfile } from '../models/erasure-code-profile'; import { ErasureCodeProfileService } from './erasure-code-profile.service'; describe('ErasureCodeProfileService', () => { let service: ErasureCodeProfileService; let httpTesting: HttpTestingController; + const apiPath = 'api/erasure_code_profile'; + const testProfile: ErasureCodeProfile = { name: 'test', plugin: 'jerasure', k: 2, m: 1 }; configureTestBed({ imports: [HttpClientTestingModule], @@ -18,13 +21,47 @@ describe('ErasureCodeProfileService', () => { httpTesting = TestBed.get(HttpTestingController); }); + afterEach(() => { + httpTesting.verify(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); it('should call list', () => { service.list().subscribe(); - const req = httpTesting.expectOne('api/erasure_code_profile'); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + service.create(testProfile).subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('POST'); + }); + + it('should call update', () => { + service.update(testProfile).subscribe(); + const req = httpTesting.expectOne(`${apiPath}/test`); + expect(req.request.method).toBe('PUT'); + }); + + it('should call delete', () => { + service.delete('test').subscribe(); + const req = httpTesting.expectOne(`${apiPath}/test`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call get', () => { + service.get('test').subscribe(); + const req = httpTesting.expectOne(`${apiPath}/test`); + expect(req.request.method).toBe('GET'); + }); + + it('should call getInfo', () => { + service.getInfo().subscribe(); + const req = httpTesting.expectOne(`${apiPath}/_info`); expect(req.request.method).toBe('GET'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts index c0193558746..165a5d6a562 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts @@ -1,15 +1,38 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { ErasureCodeProfile } from '../models/erasure-code-profile'; import { ApiModule } from './api.module'; @Injectable({ providedIn: ApiModule }) export class ErasureCodeProfileService { + apiPath = 'api/erasure_code_profile'; + constructor(private http: HttpClient) {} list() { - return this.http.get('api/erasure_code_profile'); + return this.http.get(this.apiPath); + } + + create(ecp: ErasureCodeProfile) { + return this.http.post(this.apiPath, ecp, { observe: 'response' }); + } + + update(ecp: ErasureCodeProfile) { + return this.http.put(`${this.apiPath}/${ecp.name}`, ecp, { observe: 'response' }); + } + + delete(name: string) { + return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' }); + } + + get(name: string) { + return this.http.get(`${this.apiPath}/${name}`); + } + + getInfo() { + return this.http.get(`${this.apiPath}/_info`); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts index 37ed147b441..17f48acd53b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts @@ -1,7 +1,15 @@ -export interface ErasureCodeProfile { - k: number; - m: number; +export class ErasureCodeProfile { name: string; plugin: string; - technique: string; + k?: number; + m?: number; + c?: number; + l?: number; + packetsize?: number; + technique?: string; + 'crush-root'?: string; + 'crush-locality'?: string; + 'crush-failure-domain'?: string; + 'crush-device-class'?: string; + 'directory'?: string; } 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 c8f0ae67706..37956f16ac3 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 @@ -90,6 +90,27 @@ describe('TaskManagerMessageService', () => { }); }); + describe('erasure code profile tasks', () => { + beforeEach(() => { + const metadata = { + name: 'someEcpName' + }; + defaultMsg = `erasure code profile '${metadata.name}'`; + finishedTask.metadata = metadata; + }); + + it('tests ecp/create messages', () => { + finishedTask.name = 'ecp/create'; + testCreate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests ecp/delete messages', () => { + finishedTask.name = 'ecp/delete'; + testDelete(defaultMsg); + }); + }); + describe('rbd tasks', () => { let metadata; let childMsg: string; 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 9cea92c3c05..51db2e7a82e 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 @@ -76,6 +76,7 @@ export class TaskMessageService { }; messages = { + // Pool tasks 'pool/create': new TaskMessage(this.commonOperations.create, this.pool, (metadata) => ({ '17': `Name is already used by ${this.pool(metadata)}.` })), @@ -83,6 +84,12 @@ export class TaskMessageService { '17': `Name is already used by ${this.pool(metadata)}.` })), 'pool/delete': new TaskMessage(this.commonOperations.delete, this.pool), + // Erasure code profile tasks + 'ecp/create': new TaskMessage(this.commonOperations.create, this.ecp, (metadata) => ({ + '17': `Name is already used by ${this.ecp(metadata)}.` + })), + 'ecp/delete': new TaskMessage(this.commonOperations.delete, this.ecp), + // RBD tasks 'rbd/create': new TaskMessage(this.commonOperations.create, this.rbd.default, (metadata) => ({ '17': `Name is already used by ${this.rbd.default(metadata)}.` })), @@ -111,6 +118,7 @@ export class TaskMessageService { new TaskMessageOperation('Flattening', 'flatten', 'Flattened'), this.rbd.default ), + // RBD snapshot tasks 'rbd/snap/create': new TaskMessage( this.commonOperations.create, this.rbd.snapshot, @@ -136,6 +144,7 @@ export class TaskMessageService { new TaskMessageOperation('Rolling back', 'rollback', 'Rolled back'), this.rbd.snapshot ), + // RBD trash tasks 'rbd/trash/move': new TaskMessage( new TaskMessageOperation('Moving', 'move', 'Moved'), (metadata) => `image '${metadata.pool_name}/${metadata.image_name}' to trash`, @@ -172,6 +181,10 @@ export class TaskMessageService { return `pool '${metadata.pool_name}'`; } + ecp(metadata) { + return `erasure code profile '${metadata.name}'`; + } + _getTaskTitle(task: Task) { return this.messages[task.name] || this.defaultMessage; }