[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
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';
*/
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'),
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'),
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';
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();
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';
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
});
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
+ );
+ }
});
}
}, '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');
}
onCrushFailureDomainChane() {
this.form.get('k').updateValueAndValidity();
this.form.get('m').updateValueAndValidity();
+ this.form.get('crushNumFailureDomains').updateValueAndValidity();
+ this.form.get('crushOsdsPerFailureDomain').updateValueAndValidity();
}
}
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();
});
});
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');
});
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();
});
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', () => {
poolType: 'erasure',
pgNum: 4
});
+ // Ensure crushRule validator clears when switching to erasure
+ form.get('crushRule').updateValueAndValidity();
expectValidSubmit({
pool: 'minECPool',
pool_type: 'erasure',
})
]
}),
- poolType: new UntypedFormControl('', {
+ poolType: new UntypedFormControl('replicated', {
validators: [Validators.required]
}),
crushRule: new UntypedFormControl(null, {
}
this.listenToChanges();
this.setComplexValidators();
+ this.poolTypeChange('replicated');
});
this.loadingReady();
}
]);
} 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
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', () => {
}
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 {