]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard:Adding MSR EC Profile via dashboard 67872/head
authorDevika Babrekar <devika.babrekar@ibm.com>
Tue, 17 Mar 2026 09:21:58 +0000 (14:51 +0530)
committerDevika Babrekar <devika.babrekar@ibm.com>
Tue, 7 Apr 2026 08:19:00 +0000 (13:49 +0530)
Fixes: https://tracker.ceph.com/issues/75547
Signed-off-by: Devika Babrekar <devika.babrekar@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts

index 34d2e4fc12ce021b5b1a5a19fe1f0821eaca243f..d84a93db49837617c328d0824319f5726a531ea1 100644 (file)
           [helperText]="tooltips.crushFailureDomain"
           i18n
         >
-          @if (!failureDomains) {
+        @if (!failureDomains) {
           <option
             value=""
           >
             Loading...
           </option>
-          }
-          @for (domain of failureDomainKeys; track domain) {
+        }
+        @for (domain of failureDomainKeys; track domain) {
           <option
             [value]="domain"
+            [selected]="domain.toLowerCase() === CrushFailureDomains.Host"
           >
             {{ domain }} ( {{failureDomains[domain].length}} )
           </option>
-          }
+        }
         </cds-select>
       </div>
 
         class="form-item"
       >
         <cds-number
+          cdValidate
+          #crushNumFailureDomainsRef="cdValidate"
           label="Crush num failure domain"
           [helperText]="tooltips.crushNumFailureDomains"
-          [invalid]="form.controls.crushNumFailureDomains.invalid && form.controls.crushNumFailureDomains.dirty"
+          [invalid]="crushNumFailureDomainsRef.isInvalid"
           [invalidText]="crushNumFailureDomainsError"
           formControlName="crushNumFailureDomains"
           min="0"
         <ng-template
           #crushNumFailureDomainsError
         >
-          @if (form.showError('crushNumFailureDomains', formDir, 'required')) {
-          <span
-            class="invalid-feedback"
-            i18n
+          <ng-container
+            *ngTemplateOutlet="crushFailureDomainValidationErrors; context: {
+            control: form.get('crushNumFailureDomains'),
+            requiredText: 'This field is required when crush osds per failure domain is set!',
+            showMaxFailureDomains: true
+            }"
           >
-            This field is required when crush osds per failure domain is set!
-          </span>
-          }
+          </ng-container>
         </ng-template>
       </div>
 
         class="form-item"
       >
         <cds-number
+          cdValidate
+          #crushOsdsPerFailureDomainRef="cdValidate"
           label="Crush osds per failure domain"
           [helperText]="tooltips.crushOsdsPerFailureDomain"
-          [invalid]="form.controls.crushOsdsPerFailureDomain.invalid && form.controls.crushOsdsPerFailureDomain.dirty"
+          [invalid]="crushOsdsPerFailureDomainRef.isInvalid"
           [invalidText]="crushOsdsPerFailureDomainError"
           formControlName="crushOsdsPerFailureDomain"
           min="0"
         <ng-template
           #crushOsdsPerFailureDomainError
         >
-          @if (form.showError('crushOsdsPerFailureDomain', formDir, 'required')) {
-          <span
-            class="invalid-feedback"
-            i18n
+          <ng-container
+            *ngTemplateOutlet="crushFailureDomainValidationErrors; context: {
+            control: form.get('crushOsdsPerFailureDomain'),
+            requiredText: 'This field is required when crush num failure domain is set!',
+            showMaxFailureDomains: false
+            }"
           >
-            This field is required when crush num failure domain is set!
-          </span>
-          }
+          </ng-container>
         </ng-template>
       </div>
 
+      <ng-template
+        #crushFailureDomainValidationErrors
+        let-control="control"
+        let-requiredText="requiredText"
+        let-showMaxFailureDomains="showMaxFailureDomains"
+      >
+        @if (control?.errors) {
+        @if (control.hasError('required')) {
+        <span
+          class="invalid-feedback"
+          i18n
+        >
+          {{ requiredText }}
+        </span>
+        }
+        @if (showMaxFailureDomains && control.hasError('maxFailureDomains')) {
+        <span
+          class="invalid-feedback"
+          i18n
+        >
+          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 }}).
+        </span>
+        }
+        @if (control.hasError('pattern')) {
+        <span
+          class="invalid-feedback"
+          i18n
+        >
+          Enter a valid positive number
+        </span>
+        }
+        }
+      </ng-template>
+
       <!-- Crush locality -->
       @if (plugin === PLUGIN.LRC) {
       <div
index abdc7006f117970ce26e8372e71632314073f9d0..975b28a2a9abc359b0cb3b8f9c7caf31678b6e5d 100644 (file)
@@ -8,7 +8,7 @@ import { of } from 'rxjs';
 
 import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
 import { CrushNode } from '~/app/shared/models/crush-node';
-import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { CrushFailureDomains, ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { configureTestBed, FixtureHelper, FormHelper, Mocks } from '~/testing/unit-test-helper';
 import { PoolModule } from '../pool.module';
@@ -68,7 +68,20 @@ describe('ErasureCodeProfileFormModalComponent', () => {
        */
       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();
index 8458116bc824c15494e06fe1731be3a3ff1ce427..4c90ec76ae261b0ab8bb04557ff3903c8b757015 100644 (file)
@@ -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();
   }
 }
index 7bfab81aded5668b29a049085221c8abc7de66af..b660da528b0ca09e14c8580ccab6d9e34d9da2e6 100644 (file)
@@ -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',
index 2179a151beb0b1f2b4bfa58e6fec325cb198e50d..99466c9fe56af17ca10b77c72411c5aa660786c9 100644 (file)
@@ -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
index 7e2f27e582a3358f6da52f31d7a8bf7411810b9b..123f14a91ced5b7f179dd41e4d0f0b217a9d824e 100644 (file)
@@ -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', () => {
index 634bcf2b386c5b352bf7c714b31036a4cbe94279..3b4a8a00a9f361d998d2c19cb28ec7379a86da02 100644 (file)
@@ -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 {