From: Devika Babrekar Date: Tue, 17 Mar 2026 09:21:58 +0000 (+0530) Subject: mgr/dashboard:Adding MSR EC Profile via dashboard X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=3c1ee9fe8a925cfa468d89ddda6446010e83c390;p=ceph.git mgr/dashboard:Adding MSR EC Profile via dashboard Fixes: https://tracker.ceph.com/issues/75547 Signed-off-by: Devika Babrekar --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html index 34d2e4fc12ce..d84a93db4983 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html @@ -491,20 +491,21 @@ [helperText]="tooltips.crushFailureDomain" i18n > - @if (!failureDomains) { + @if (!failureDomains) { - } - @for (domain of failureDomainKeys; track domain) { + } + @for (domain of failureDomainKeys; track domain) { - } + } @@ -514,9 +515,11 @@ class="form-item" > - @if (form.showError('crushNumFailureDomains', formDir, 'required')) { - - This field is required when crush osds per failure domain is set! - - } + @@ -543,9 +546,11 @@ class="form-item" > - @if (form.showError('crushOsdsPerFailureDomain', formDir, 'required')) { - - This field is required when crush num failure domain is set! - - } + + + @if (control?.errors) { + @if (control.hasError('required')) { + + {{ requiredText }} + + } + @if (showMaxFailureDomains && control.hasError('maxFailureDomains')) { + + The number of failure domains ({{ form.controls.crushNumFailureDomains.value }}) cannot exceed the available count ({{ failureDomains[form.controls.crushFailureDomain.value]?.length || 0 }}) for the selected failure domain type ({{ form.controls.crushFailureDomain.value }}). + + } + @if (control.hasError('pattern')) { + + Enter a valid positive number + + } + } + + @if (plugin === PLUGIN.LRC) {
{ */ nodes: [ // Root node - Mocks.getCrushNode('default', -1, 'root', 11, [-2, -3]), + Mocks.getCrushNode('default', -1, 'root', 11, [ + -2, + -3, + -6, + -7, + -8, + -9, + -10, + -11, + -12, + -13, + -14, + -15 + ]), // SSD host Mocks.getCrushNode('ssd-host', -2, 'host', 1, [1, 0, 2]), Mocks.getCrushNode('osd.0', 0, 'osd', 0, undefined, 'ssd'), @@ -76,6 +89,27 @@ describe('ErasureCodeProfileFormModalComponent', () => { Mocks.getCrushNode('osd.2', 2, 'osd', 0, undefined, 'ssd'), // SSD and HDD mixed devices host Mocks.getCrushNode('mix-host', -3, 'host', 1, [-4, -5]), + // Additional hosts to satisfy host default max validation (k+m+1 <= hosts) + Mocks.getCrushNode('host-3', -6, 'host', 1, [13]), + Mocks.getCrushNode('osd4.0', 13, 'osd', 0, undefined, 'ssd'), + Mocks.getCrushNode('host-4', -7, 'host', 1, [14]), + Mocks.getCrushNode('osd5.0', 14, 'osd', 0, undefined, 'hdd'), + Mocks.getCrushNode('host-5', -8, 'host', 1, [15]), + Mocks.getCrushNode('osd6.0', 15, 'osd', 0, undefined, 'ssd'), + Mocks.getCrushNode('host-6', -9, 'host', 1, [16]), + Mocks.getCrushNode('osd7.0', 16, 'osd', 0, undefined, 'hdd'), + Mocks.getCrushNode('host-7', -10, 'host', 1, [17]), + Mocks.getCrushNode('osd8.0', 17, 'osd', 0, undefined, 'ssd'), + Mocks.getCrushNode('host-8', -11, 'host', 1, [18]), + Mocks.getCrushNode('osd9.0', 18, 'osd', 0, undefined, 'hdd'), + Mocks.getCrushNode('host-9', -12, 'host', 1, [19]), + Mocks.getCrushNode('osd10.0', 19, 'osd', 0, undefined, 'ssd'), + Mocks.getCrushNode('host-10', -13, 'host', 1, [20]), + Mocks.getCrushNode('osd11.0', 20, 'osd', 0, undefined, 'hdd'), + Mocks.getCrushNode('host-11', -14, 'host', 1, [21]), + Mocks.getCrushNode('osd12.0', 21, 'osd', 0, undefined, 'ssd'), + Mocks.getCrushNode('host-12', -15, 'host', 1, [22]), + Mocks.getCrushNode('osd13.0', 22, 'osd', 0, undefined, 'hdd'), // HDD rack Mocks.getCrushNode('hdd-rack', -4, 'rack', 3, [3, 4, 5, 6, 7]), Mocks.getCrushNode('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'), @@ -491,7 +525,7 @@ describe('ErasureCodeProfileFormModalComponent', () => { ecp = new ErasureCodeProfile(); submittedEcp = new ErasureCodeProfile(); submittedEcp['crush-root'] = 'default'; - submittedEcp['crush-failure-domain'] = 'osd-rack'; + submittedEcp['crush-failure-domain'] = CrushFailureDomains.Host; submittedEcp['packetsize'] = 2048; submittedEcp['technique'] = 'reed_sol_van'; @@ -586,8 +620,13 @@ describe('ErasureCodeProfileFormModalComponent', () => { it('should send profile with all required fields and crush root and locality', () => { ecpChange('l', '6'); formHelper.setMultipleValues(ecp, true); - formHelper.setValue('crushRoot', component.buckets[2], true); + formHelper.setValue( + 'crushRoot', + component.buckets.find((bucket) => bucket.name === 'mix-host'), + true + ); submittedEcp['crush-root'] = 'mix-host'; + submittedEcp['crush-failure-domain'] = 'osd-rack'; formHelper.setValue('crushLocality', 'osd-rack', true); submittedEcp['crush-locality'] = 'osd-rack'; testCreation(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts index 8458116bc824..4c90ec76ae26 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts @@ -6,7 +6,7 @@ import { Output, ViewChild } from '@angular/core'; -import { FormGroupDirective, Validators } from '@angular/forms'; +import { AbstractControl, FormGroupDirective, ValidatorFn, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -108,14 +108,21 @@ export class ErasureCodeProfileFormModalComponent CdValidators.custom('max', () => this.baseValueValidation()) ] ], - crushFailureDomain: '', // Will be preselected + crushFailureDomain: CrushFailureDomains.Host, // Will be preselected crushNumFailureDomains: [ 0, - CdValidators.requiredIf({ crushOsdsPerFailureDomain: { op: 'minValue', arg1: 1 } }) + [ + CdValidators.requiredIf({ crushOsdsPerFailureDomain: { op: 'minValue', arg1: 1 } }), + CdValidators.number(false), + this.crushNumFailureDomainsValidator() + ] ], crushOsdsPerFailureDomain: [ 0, - CdValidators.requiredIf({ crushNumFailureDomains: { op: 'minValue', arg1: 1 } }) + [ + CdValidators.requiredIf({ crushNumFailureDomains: { op: 'minValue', arg1: 1 } }), + CdValidators.number(false) + ] ], crushRoot: null, // Will be preselected crushDeviceClass: '', // Will be preselected @@ -164,17 +171,36 @@ export class ErasureCodeProfileFormModalComponent }); this.form.get('plugin').valueChanges.subscribe((plugin) => this.onPluginChange(plugin)); this.form.get('scalar_mds').valueChanges.subscribe(() => this.setClayDefaultsForScalar()); + this.form.get('crushFailureDomain').valueChanges.subscribe(() => { + this.form.get('crushNumFailureDomains').updateValueAndValidity(); + this.form.get('crushOsdsPerFailureDomain').updateValueAndValidity(); + }); + this.form.get('crushNumFailureDomains').valueChanges.subscribe(() => { + this.form.get('k').updateValueAndValidity(); + this.form.get('m').updateValueAndValidity(); + }); + this.form.get('crushOsdsPerFailureDomain').valueChanges.subscribe(() => { + this.form.get('k').updateValueAndValidity(); + this.form.get('m').updateValueAndValidity(); + }); } private baseValueValidation(dataChunk: boolean = false): boolean { return this.validValidation(() => { - const kMSum = - this.form.get('crushFailureDomain').value === CrushFailureDomains.Host - ? this.getKMSum() + 1 - : this.getKMSum(); - return ( - kMSum > this.deviceCount && this.form.getValue('k') > this.form.getValue('m') === dataChunk - ); + const crushnumfailuredomain = this.form.get('crushNumFailureDomains').value; + const crushosdfailuredomain = this.form.get('crushOsdsPerFailureDomain').value; + if (crushnumfailuredomain > 0 || crushosdfailuredomain > 0) { + return false; + } else { + const kMSum = + this.form.get('crushFailureDomain').value === CrushFailureDomains.Host + ? this.getKMSum() + 1 + : this.getKMSum(); + return ( + kMSum > this.deviceCount && + this.form.getValue('k') > this.form.getValue('m') === dataChunk + ); + } }); } @@ -221,6 +247,45 @@ export class ErasureCodeProfileFormModalComponent }, 'shec'); } + /* + Following function is written to implement MSR EC profile validation + 1. When 'Crush num failure domain' >= 1 or 'Crush osds per failue domain' >= 1, it is MSR EC Profile + 2. k+m+1 rule does not applies to MSR EC Profiles + 3. 'Crush num failure domain' <= 'Crush failure domain' (host) + The function validates 3rd condition + */ + private crushNumFailureDomainsValidator(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + const v = control.value; + if (!v || v === 0) { + return null; + } + + if (!control.parent) { + return null; + } + + const crushFailureDomainControl = control.parent.get('crushFailureDomain'); + if (!crushFailureDomainControl) { + return null; // No validation if crushFailureDomain control doesn't exist + } + + const crushFailureDomain = crushFailureDomainControl.value; + + // Validate that we have a selected failure domain and it exists in failureDomains + if (!crushFailureDomain || !this.failureDomains || !this.failureDomains[crushFailureDomain]) { + return null; // No validation if failure domain is not selected or failureDomains not initialized + } + + // Get the count for the currently selected failure domain (dynamically based on user selection) + const availableCount = this.failureDomains[crushFailureDomain].length; + if (v > availableCount) { + return { maxFailureDomains: true }; + } + return null; + }; + } + private dMinValidation(d: number): boolean { return this.validValidation(() => this.getDMin() > d, 'clay'); } @@ -508,5 +573,7 @@ export class ErasureCodeProfileFormModalComponent onCrushFailureDomainChane() { this.form.get('k').updateValueAndValidity(); this.form.get('m').updateValueAndValidity(); + this.form.get('crushNumFailureDomains').updateValueAndValidity(); + this.form.get('crushOsdsPerFailureDomain').updateValueAndValidity(); } } 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 7bfab81aded5..b660da528b0c 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 @@ -226,10 +226,12 @@ describe('PoolFormComponent', () => { it('is invalid at the beginning all sub forms are valid', () => { expect(form.valid).toBeFalsy(); - ['name', 'poolType', 'pgNum'].forEach((name) => formHelper.expectError(name, 'required')); - ['size', 'crushRule', 'erasureProfile', 'ecOverwrites'].forEach((name) => - formHelper.expectValid(name) - ); + // With default poolType 'replicated', expect 'name' required. + ['name'].forEach((name) => formHelper.expectError(name, 'required')); + // For replicated type with multiple crush rules, 'crushRule' is required. + formHelper.expectError('crushRule', 'required'); + // Other fields are valid by default. + ['size', 'erasureProfile', 'ecOverwrites'].forEach((name) => formHelper.expectValid(name)); expect(component.form.get('compression').valid).toBeTruthy(); }); @@ -247,12 +249,13 @@ describe('PoolFormComponent', () => { }); it('validates poolType', () => { - formHelper.expectError('poolType', 'required'); + // Default is 'replicated' now, so just verify switching remains valid formHelper.expectValidChange('poolType', 'erasure'); formHelper.expectValidChange('poolType', 'replicated'); }); it('validates that pgNum is required creation mode', () => { + formHelper.setValue('pgNum', ''); formHelper.expectError(form.get('pgNum'), 'required'); }); @@ -270,6 +273,8 @@ describe('PoolFormComponent', () => { formHelper.setValue('name', 'some-name'); formHelper.setValue('poolType', 'erasure'); fixture.detectChanges(); + // Recompute crushRule validator since it depends on poolType + form.get('crushRule').updateValueAndValidity(); setPgNum(1); expect(form.valid).toBeTruthy(); }); @@ -1192,6 +1197,8 @@ describe('PoolFormComponent', () => { poolType: 'erasure', pgNum: 4 }); + // Ensure crushRule validator clears when switching to erasure + form.get('crushRule').updateValueAndValidity(); }); it('minimum requirements without ECP to create ec pool', () => { @@ -1204,6 +1211,8 @@ describe('PoolFormComponent', () => { poolType: 'erasure', pgNum: 4 }); + // Ensure crushRule validator clears when switching to erasure + form.get('crushRule').updateValueAndValidity(); expectValidSubmit({ pool: 'minECPool', pool_type: 'erasure', 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 2179a151beb0..99466c9fe56a 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 @@ -171,7 +171,7 @@ export class PoolFormComponent extends CdForm implements OnInit { }) ] }), - poolType: new UntypedFormControl('', { + poolType: new UntypedFormControl('replicated', { validators: [Validators.required] }), crushRule: new UntypedFormControl(null, { @@ -215,6 +215,7 @@ export class PoolFormComponent extends CdForm implements OnInit { } this.listenToChanges(); this.setComplexValidators(); + this.poolTypeChange('replicated'); }); this.loadingReady(); } @@ -569,10 +570,7 @@ export class PoolFormComponent extends CdForm implements OnInit { ]); } else { CdValidators.validateIf(this.form.get('size'), () => this.isReplicated, [ - CdValidators.custom( - 'min', - (value: number) => this.form.getValue('size') && value < this.getMinSize() - ), + CdValidators.number(false), CdValidators.custom( 'max', (value: number) => this.form.getValue('size') && this.getMaxSize() < value diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts index 7e2f27e582a3..123f14a91ced 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts @@ -138,7 +138,8 @@ describe('CrushNodeSelectionService', () => { it('should override automatic selections', () => { assert.formFieldValues(get.nodeByName('default'), 'osd-rack', ''); assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); - assert.valuesOnRootChange('mix-host', 'osd-rack', ''); + // After selecting 'ssd-host', switching to 'mix-host' keeps valid device 'ssd' + assert.valuesOnRootChange('mix-host', 'osd-rack', 'ssd'); }); it('should not override manual selections if possible', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts index 634bcf2b386c..3b4a8a00a9f3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts @@ -195,7 +195,7 @@ export class CrushNodeSelectionClass extends CdForm { } private getIncludedCustomValue(control: AbstractControl, includedIn: string[]) { - return control.dirty && includedIn.includes(control.value) ? control.value : ''; + return includedIn.includes(control.value) ? control.value : ''; } private setMostCommonDomain(failureControl: AbstractControl): string {