]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
import 15.2.1 Octopus source
[ceph.git] / ceph / src / pybind / mgr / dashboard / frontend / src / app / ceph / pool / pool-form / pool-form.component.spec.ts
1 import { HttpClientTestingModule } from '@angular/common/http/testing';
2 import { ComponentFixture, TestBed } from '@angular/core/testing';
3 import { AbstractControl } from '@angular/forms';
4 import { By } from '@angular/platform-browser';
5 import { ActivatedRoute, Router, Routes } from '@angular/router';
6 import { RouterTestingModule } from '@angular/router/testing';
7
8 import * as _ from 'lodash';
9 import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
10 import { BsModalService } from 'ngx-bootstrap/modal';
11 import { TabsetComponent, TabsModule } from 'ngx-bootstrap/tabs';
12 import { ToastrModule } from 'ngx-toastr';
13 import { of } from 'rxjs';
14
15 import {
16 configureTestBed,
17 FixtureHelper,
18 FormHelper,
19 i18nProviders
20 } from '../../../../testing/unit-test-helper';
21 import { NotFoundComponent } from '../../../core/not-found/not-found.component';
22 import { CrushRuleService } from '../../../shared/api/crush-rule.service';
23 import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
24 import { PoolService } from '../../../shared/api/pool.service';
25 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
26 import { SelectBadgesComponent } from '../../../shared/components/select-badges/select-badges.component';
27 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
28 import { CrushRule } from '../../../shared/models/crush-rule';
29 import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
30 import { Permission } from '../../../shared/models/permissions';
31 import { PoolFormInfo } from '../../../shared/models/pool-form-info';
32 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
33 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
34 import { Pool } from '../pool';
35 import { PoolModule } from '../pool.module';
36 import { PoolFormComponent } from './pool-form.component';
37
38 describe('PoolFormComponent', () => {
39 const OSDS = 8;
40 let formHelper: FormHelper;
41 let fixtureHelper: FixtureHelper;
42 let component: PoolFormComponent;
43 let fixture: ComponentFixture<PoolFormComponent>;
44 let poolService: PoolService;
45 let form: CdFormGroup;
46 let router: Router;
47 let ecpService: ErasureCodeProfileService;
48 let crushRuleService: CrushRuleService;
49
50 const setPgNum = (pgs: number): AbstractControl => {
51 const control = formHelper.setValue('pgNum', pgs);
52 fixture.debugElement.query(By.css('#pgNum')).nativeElement.dispatchEvent(new Event('blur'));
53 return control;
54 };
55
56 const testPgUpdate = (pgs: number, jump: number, returnValue: number) => {
57 if (pgs) {
58 setPgNum(pgs);
59 }
60 if (jump) {
61 setPgNum(form.getValue('pgNum') + jump);
62 }
63 expect(form.getValue('pgNum')).toBe(returnValue);
64 };
65
66 const createCrushRule = ({
67 id = 0,
68 name = 'somePoolName',
69 min = 1,
70 max = 10,
71 type = 'replicated'
72 }: {
73 max?: number;
74 min?: number;
75 id?: number;
76 name?: string;
77 type?: string;
78 }) => {
79 const typeNumber = type === 'erasure' ? 3 : 1;
80 const rule = new CrushRule();
81 rule.max_size = max;
82 rule.min_size = min;
83 rule.rule_id = id;
84 rule.ruleset = typeNumber;
85 rule.rule_name = name;
86 rule.steps = [
87 {
88 item_name: 'default',
89 item: -1,
90 op: 'take'
91 },
92 {
93 num: 0,
94 type: 'osd',
95 op: 'choose_firstn'
96 },
97 {
98 op: 'emit'
99 }
100 ];
101 return rule;
102 };
103
104 const expectValidSubmit = (
105 pool: any,
106 taskName = 'pool/create',
107 poolServiceMethod: 'create' | 'update' = 'create'
108 ) => {
109 spyOn(poolService, poolServiceMethod).and.stub();
110 const taskWrapper = TestBed.get(TaskWrapperService);
111 spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
112 component.submit();
113 expect(poolService[poolServiceMethod]).toHaveBeenCalledWith(pool);
114 expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
115 task: {
116 name: taskName,
117 metadata: {
118 pool_name: pool.pool
119 }
120 },
121 call: undefined // because of stub
122 });
123 };
124
125 let infoReturn: PoolFormInfo;
126 const setInfo = () => {
127 const ecp1 = new ErasureCodeProfile();
128 ecp1.name = 'ecp1';
129 infoReturn = {
130 pool_names: ['someExistingPoolName'],
131 osd_count: OSDS,
132 is_all_bluestore: true,
133 bluestore_compression_algorithm: 'snappy',
134 compression_algorithms: ['snappy'],
135 compression_modes: ['none', 'passive'],
136 crush_rules_replicated: [
137 createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' }),
138 createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' }),
139 createCrushRule({ id: 2, min: 1, max: 9, name: 'used_rule', type: 'replicated' })
140 ],
141 crush_rules_erasure: [
142 createCrushRule({ id: 3, min: 1, max: 1, name: 'ecp1', type: 'erasure' })
143 ],
144 erasure_code_profiles: [ecp1],
145 pg_autoscale_default_mode: 'off',
146 pg_autoscale_modes: ['off', 'warn', 'on'],
147 used_rules: {
148 used_rule: ['some.pool.uses.it']
149 }
150 };
151 };
152
153 const setUpPoolComponent = () => {
154 fixture = TestBed.createComponent(PoolFormComponent);
155 component = fixture.componentInstance;
156 fixture.detectChanges();
157
158 fixtureHelper = new FixtureHelper(fixture);
159 form = component.form;
160 formHelper = new FormHelper(form);
161 };
162
163 const routes: Routes = [{ path: '404', component: NotFoundComponent }];
164
165 configureTestBed({
166 declarations: [NotFoundComponent],
167 imports: [
168 HttpClientTestingModule,
169 RouterTestingModule.withRoutes(routes),
170 ToastrModule.forRoot(),
171 TabsModule.forRoot(),
172 PoolModule,
173 NgBootstrapFormValidationModule.forRoot()
174 ],
175 providers: [
176 ErasureCodeProfileService,
177 SelectBadgesComponent,
178 { provide: ActivatedRoute, useValue: { params: of({ name: 'somePoolName' }) } },
179 i18nProviders
180 ]
181 });
182
183 let navigationSpy: jasmine.Spy;
184
185 beforeEach(() => {
186 poolService = TestBed.get(PoolService);
187 setInfo();
188 spyOn(poolService, 'getInfo').and.callFake(() => of(infoReturn));
189
190 ecpService = TestBed.get(ErasureCodeProfileService);
191 crushRuleService = TestBed.get(CrushRuleService);
192
193 router = TestBed.get(Router);
194 navigationSpy = spyOn(router, 'navigate').and.stub();
195
196 setUpPoolComponent();
197 });
198
199 it('should create', () => {
200 expect(component).toBeTruthy();
201 });
202
203 describe('redirect not allowed users', () => {
204 let poolPermissions: Permission;
205 let authStorageService: AuthStorageService;
206
207 const expectRedirect = (redirected = true) => {
208 navigationSpy.calls.reset();
209 component.authenticate();
210 expect(navigationSpy).toHaveBeenCalledTimes(redirected ? 1 : 0);
211 };
212
213 beforeEach(() => {
214 poolPermissions = {
215 create: false,
216 update: false,
217 read: false,
218 delete: false
219 };
220 authStorageService = TestBed.get(AuthStorageService);
221 spyOn(authStorageService, 'getPermissions').and.callFake(() => ({
222 pool: poolPermissions
223 }));
224 });
225
226 it('navigates to 404 if not allowed', () => {
227 component.authenticate();
228 expect(router.navigate).toHaveBeenCalledWith(['/404']);
229 });
230
231 it('navigates if user is not allowed', () => {
232 expectRedirect();
233 poolPermissions.read = true;
234 expectRedirect();
235 poolPermissions.delete = true;
236 expectRedirect();
237 poolPermissions.update = true;
238 expectRedirect();
239 component.editing = true;
240 poolPermissions.update = false;
241 poolPermissions.create = true;
242 expectRedirect();
243 });
244
245 it('does not navigate users with right permissions', () => {
246 poolPermissions.read = true;
247 poolPermissions.create = true;
248 expectRedirect(false);
249 component.editing = true;
250 poolPermissions.update = true;
251 expectRedirect(false);
252 poolPermissions.create = false;
253 expectRedirect(false);
254 });
255 });
256
257 describe('pool form validation', () => {
258 beforeEach(() => {
259 fixture.detectChanges();
260 });
261
262 it('is invalid at the beginning all sub forms are valid', () => {
263 expect(form.valid).toBeFalsy();
264 ['name', 'poolType', 'pgNum'].forEach((name) => formHelper.expectError(name, 'required'));
265 ['size', 'crushRule', 'erasureProfile', 'ecOverwrites'].forEach((name) =>
266 formHelper.expectValid(name)
267 );
268 expect(component.form.get('compression').valid).toBeTruthy();
269 });
270
271 it('validates name', () => {
272 expect(component.editing).toBeFalsy();
273 formHelper.expectError('name', 'required');
274 formHelper.expectValidChange('name', 'some-name');
275 formHelper.expectValidChange('name', 'name/with/slash');
276 formHelper.expectErrorChange('name', 'someExistingPoolName', 'uniqueName');
277 formHelper.expectErrorChange('name', 'wrong format with spaces', 'pattern');
278 });
279
280 it('should validate with dots in pool name', () => {
281 formHelper.expectValidChange('name', 'pool.default.bar', true);
282 });
283
284 it('validates poolType', () => {
285 formHelper.expectError('poolType', 'required');
286 formHelper.expectValidChange('poolType', 'erasure');
287 formHelper.expectValidChange('poolType', 'replicated');
288 });
289
290 it('validates that pgNum is required creation mode', () => {
291 formHelper.expectError(form.get('pgNum'), 'required');
292 });
293
294 it('validates pgNum in edit mode', () => {
295 component.data.pool = new Pool('test');
296 component.data.pool.pg_num = 16;
297 component.editing = true;
298 component.ngOnInit(); // Switches form into edit mode
299 formHelper.setValue('poolType', 'erasure');
300 fixture.detectChanges();
301 formHelper.expectValid(setPgNum(8));
302 });
303
304 it('is valid if pgNum, poolType and name are valid', () => {
305 formHelper.setValue('name', 'some-name');
306 formHelper.setValue('poolType', 'erasure');
307 fixture.detectChanges();
308 setPgNum(1);
309 expect(form.valid).toBeTruthy();
310 });
311
312 it('validates crushRule with multiple crush rules', () => {
313 formHelper.expectValidChange('poolType', 'replicated');
314 form.get('crushRule').updateValueAndValidity();
315 formHelper.expectError('crushRule', 'required'); // As multiple rules exist
316 formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds');
317 });
318
319 it('validates crushRule with no crush rules', () => {
320 infoReturn.crush_rules_replicated = [];
321 setUpPoolComponent();
322 formHelper.expectValidChange('poolType', 'replicated');
323 formHelper.expectValid('crushRule');
324 });
325
326 it('validates size', () => {
327 formHelper.setValue('poolType', 'replicated');
328 formHelper.expectValid('size');
329 formHelper.setValue('crushRule', {
330 min_size: 2,
331 max_size: 6
332 });
333 formHelper.expectErrorChange('size', 1, 'min');
334 formHelper.expectErrorChange('size', 8, 'max');
335 formHelper.expectValidChange('size', 6);
336 });
337
338 it('validates compression mode default value', () => {
339 expect(form.getValue('mode')).toBe('none');
340 });
341
342 it('validate quotas', () => {
343 formHelper.expectValid('max_bytes');
344 formHelper.expectValid('max_objects');
345 formHelper.expectValidChange('max_bytes', '10 Gib');
346 formHelper.expectValidChange('max_bytes', '');
347 formHelper.expectValidChange('max_objects', '');
348 formHelper.expectErrorChange('max_objects', -1, 'min');
349 });
350
351 describe('compression form', () => {
352 beforeEach(() => {
353 formHelper.setValue('poolType', 'replicated');
354 formHelper.setValue('mode', 'passive');
355 });
356
357 it('is valid', () => {
358 expect(component.form.get('compression').valid).toBeTruthy();
359 });
360
361 it('validates minBlobSize to be only valid between 0 and maxBlobSize', () => {
362 formHelper.expectErrorChange('minBlobSize', -1, 'min');
363 formHelper.expectValidChange('minBlobSize', 0);
364 formHelper.setValue('maxBlobSize', '2 KiB');
365 formHelper.expectErrorChange('minBlobSize', '3 KiB', 'maximum');
366 formHelper.expectValidChange('minBlobSize', '1.9 KiB');
367 });
368
369 it('validates minBlobSize converts numbers', () => {
370 const control = formHelper.setValue('minBlobSize', '1');
371 fixture.detectChanges();
372 formHelper.expectValid(control);
373 expect(control.value).toBe('1 KiB');
374 });
375
376 it('validates maxBlobSize to be only valid bigger than minBlobSize', () => {
377 formHelper.expectErrorChange('maxBlobSize', -1, 'min');
378 formHelper.setValue('minBlobSize', '1 KiB');
379 formHelper.expectErrorChange('maxBlobSize', '0.5 KiB', 'minimum');
380 formHelper.expectValidChange('maxBlobSize', '1.5 KiB');
381 });
382
383 it('s valid to only use one blob size', () => {
384 formHelper.expectValid(formHelper.setValue('minBlobSize', '1 KiB'));
385 formHelper.expectValid(formHelper.setValue('maxBlobSize', ''));
386 formHelper.expectValid(formHelper.setValue('minBlobSize', ''));
387 formHelper.expectValid(formHelper.setValue('maxBlobSize', '1 KiB'));
388 });
389
390 it('dismisses any size error if one of the blob sizes is changed into a valid state', () => {
391 const min = formHelper.setValue('minBlobSize', '10 KiB');
392 const max = formHelper.setValue('maxBlobSize', '1 KiB');
393 fixture.detectChanges();
394 max.setValue('');
395 formHelper.expectValid(min);
396 formHelper.expectValid(max);
397 max.setValue('1 KiB');
398 fixture.detectChanges();
399 min.setValue('0.5 KiB');
400 formHelper.expectValid(min);
401 formHelper.expectValid(max);
402 });
403
404 it('validates maxBlobSize converts numbers', () => {
405 const control = formHelper.setValue('maxBlobSize', '2');
406 fixture.detectChanges();
407 expect(control.value).toBe('2 KiB');
408 });
409
410 it('validates that odd size validator works as expected', () => {
411 const odd = (min: string, max: string) => component['oddBlobSize'](min, max);
412 expect(odd('10', '8')).toBe(true);
413 expect(odd('8', '-')).toBe(false);
414 expect(odd('8', '10')).toBe(false);
415 expect(odd(null, '8')).toBe(false);
416 expect(odd('10', '')).toBe(false);
417 expect(odd('10', null)).toBe(false);
418 expect(odd(null, null)).toBe(false);
419 });
420
421 it('validates ratio to be only valid between 0 and 1', () => {
422 formHelper.expectValid('ratio');
423 formHelper.expectErrorChange('ratio', -0.1, 'min');
424 formHelper.expectValidChange('ratio', 0);
425 formHelper.expectValidChange('ratio', 1);
426 formHelper.expectErrorChange('ratio', 1.1, 'max');
427 });
428 });
429
430 it('validates application metadata name', () => {
431 formHelper.setValue('poolType', 'replicated');
432 fixture.detectChanges();
433 const selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
434 .componentInstance;
435 const control = selectBadges.cdSelect.filter;
436 formHelper.expectValid(control);
437 control.setValue('?');
438 formHelper.expectError(control, 'pattern');
439 control.setValue('Ab3_');
440 formHelper.expectValid(control);
441 control.setValue('a'.repeat(129));
442 formHelper.expectError(control, 'maxlength');
443 });
444 });
445
446 describe('pool type changes', () => {
447 beforeEach(() => {
448 component.ngOnInit();
449 createCrushRule({ id: 3, min: 1, max: 1, name: 'ep1', type: 'erasure' });
450 createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' });
451 createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' });
452 });
453
454 it('should have a default replicated size of 3', () => {
455 formHelper.setValue('poolType', 'replicated');
456 expect(form.getValue('size')).toBe(3);
457 });
458
459 describe('replicatedRuleChange', () => {
460 beforeEach(() => {
461 formHelper.setValue('poolType', 'replicated');
462 formHelper.setValue('size', 99);
463 });
464
465 it('should not set size if a replicated pool is not set', () => {
466 formHelper.setValue('poolType', 'erasure');
467 expect(form.getValue('size')).toBe(99);
468 formHelper.setValue('crushRule', component.info.crush_rules_replicated[1]);
469 expect(form.getValue('size')).toBe(99);
470 });
471
472 it('should set size to maximum if size exceeds maximum', () => {
473 formHelper.setValue('crushRule', component.info.crush_rules_replicated[0]);
474 expect(form.getValue('size')).toBe(4);
475 });
476
477 it('should set size to minimum if size is lower than minimum', () => {
478 formHelper.setValue('size', -1);
479 formHelper.setValue('crushRule', component.info.crush_rules_replicated[0]);
480 expect(form.getValue('size')).toBe(2);
481 });
482 });
483
484 describe('rulesChange', () => {
485 it('has no effect if info is not there', () => {
486 delete component.info;
487 formHelper.setValue('poolType', 'replicated');
488 expect(component.current.rules).toEqual([]);
489 });
490
491 it('has no effect if pool type is not set', () => {
492 component['poolTypeChange']('');
493 expect(component.current.rules).toEqual([]);
494 });
495
496 it('shows all replicated rules when pool type is "replicated"', () => {
497 formHelper.setValue('poolType', 'replicated');
498 expect(component.current.rules).toEqual(component.info.crush_rules_replicated);
499 expect(component.current.rules.length).toBe(3);
500 });
501
502 it('shows all erasure code rules when pool type is "erasure"', () => {
503 formHelper.setValue('poolType', 'erasure');
504 expect(component.current.rules).toEqual(component.info.crush_rules_erasure);
505 expect(component.current.rules.length).toBe(1);
506 });
507
508 it('disables rule field if only one rule exists which is used in the disabled field', () => {
509 infoReturn.crush_rules_replicated = [
510 createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' })
511 ];
512 setUpPoolComponent();
513 formHelper.setValue('poolType', 'replicated');
514 const control = form.get('crushRule');
515 expect(control.value).toEqual(component.info.crush_rules_replicated[0]);
516 expect(control.disabled).toBe(true);
517 });
518
519 it('does not select the first rule if more than one exist', () => {
520 formHelper.setValue('poolType', 'replicated');
521 const control = form.get('crushRule');
522 expect(control.value).toEqual(null);
523 expect(control.disabled).toBe(false);
524 });
525
526 it('changing between both pool types will not forget the crush rule selection', () => {
527 formHelper.setValue('poolType', 'replicated');
528 const control = form.get('crushRule');
529 const currentRule = component.info.crush_rules_replicated[0];
530 control.setValue(currentRule);
531 formHelper.setValue('poolType', 'erasure');
532 formHelper.setValue('poolType', 'replicated');
533 expect(control.value).toEqual(currentRule);
534 });
535 });
536 });
537
538 describe('getMaxSize and getMinSize', () => {
539 const setCrushRule = ({ min, max }: { min?: number; max?: number }) => {
540 formHelper.setValue('crushRule', {
541 min_size: min,
542 max_size: max
543 });
544 };
545
546 it('returns 0 if osd count is 0', () => {
547 component.info.osd_count = 0;
548 expect(component.getMinSize()).toBe(0);
549 expect(component.getMaxSize()).toBe(0);
550 });
551
552 it('returns 0 if info is not there', () => {
553 delete component.info;
554 expect(component.getMinSize()).toBe(0);
555 expect(component.getMaxSize()).toBe(0);
556 });
557
558 it('returns minimum and maximum of rule', () => {
559 setCrushRule({ min: 2, max: 6 });
560 expect(component.getMinSize()).toBe(2);
561 expect(component.getMaxSize()).toBe(6);
562 });
563
564 it('returns 1 as minimum and the osd count as maximum if no crush rule is available', () => {
565 expect(component.getMinSize()).toBe(1);
566 expect(component.getMaxSize()).toBe(OSDS);
567 });
568
569 it('returns the osd count as maximum if the rule maximum exceeds it', () => {
570 setCrushRule({ max: 100 });
571 expect(component.getMaxSize()).toBe(OSDS);
572 });
573
574 it('should return the osd count as minimum if its lower the the rule minimum', () => {
575 setCrushRule({ min: 10 });
576 expect(component.getMinSize()).toBe(10);
577 const control = form.get('crushRule');
578 expect(control.invalid).toBe(true);
579 formHelper.expectError(control, 'tooFewOsds');
580 });
581 });
582
583 describe('application metadata', () => {
584 let selectBadges: SelectBadgesComponent;
585
586 const testAddApp = (app?: string, result?: string[]) => {
587 selectBadges.cdSelect.filter.setValue(app);
588 selectBadges.cdSelect.updateFilter();
589 selectBadges.cdSelect.selectOption();
590 expect(component.data.applications.selected).toEqual(result);
591 };
592
593 const testRemoveApp = (app: string, result: string[]) => {
594 selectBadges.cdSelect.removeItem(app);
595 expect(component.data.applications.selected).toEqual(result);
596 };
597
598 const setCurrentApps = (apps: string[]) => {
599 component.data.applications.selected = apps;
600 fixture.detectChanges();
601 selectBadges.cdSelect.ngOnInit();
602 return apps;
603 };
604
605 beforeEach(() => {
606 formHelper.setValue('poolType', 'replicated');
607 fixture.detectChanges();
608 selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
609 .componentInstance;
610 });
611
612 it('adds all predefined and a custom applications to the application metadata array', () => {
613 testAddApp('g', ['rgw']);
614 testAddApp('b', ['rbd', 'rgw']);
615 testAddApp('c', ['cephfs', 'rbd', 'rgw']);
616 testAddApp('ownApp', ['cephfs', 'ownApp', 'rbd', 'rgw']);
617 });
618
619 it('only allows 4 apps to be added to the array', () => {
620 const apps = setCurrentApps(['d', 'c', 'b', 'a']);
621 testAddApp('e', apps);
622 });
623
624 it('can remove apps', () => {
625 setCurrentApps(['a', 'b', 'c', 'd']);
626 testRemoveApp('c', ['a', 'b', 'd']);
627 testRemoveApp('a', ['b', 'd']);
628 testRemoveApp('d', ['b']);
629 testRemoveApp('b', []);
630 });
631
632 it('does not remove any app that is not in the array', () => {
633 const apps = ['a', 'b', 'c', 'd'];
634 setCurrentApps(apps);
635 testRemoveApp('e', apps);
636 testRemoveApp('0', apps);
637 });
638 });
639
640 describe('pg number changes', () => {
641 beforeEach(() => {
642 formHelper.setValue('crushRule', {
643 min_size: 1,
644 max_size: 20
645 });
646 formHelper.setValue('poolType', 'erasure');
647 fixture.detectChanges();
648 setPgNum(256);
649 });
650
651 it('updates by value', () => {
652 testPgUpdate(10, undefined, 8);
653 testPgUpdate(22, undefined, 16);
654 testPgUpdate(26, undefined, 32);
655 testPgUpdate(200, undefined, 256);
656 testPgUpdate(300, undefined, 256);
657 testPgUpdate(350, undefined, 256);
658 });
659
660 it('updates by jump -> a magnitude of the power of 2', () => {
661 testPgUpdate(undefined, 1, 512);
662 testPgUpdate(undefined, -1, 256);
663 });
664
665 it('returns 1 as minimum for false numbers', () => {
666 testPgUpdate(-26, undefined, 1);
667 testPgUpdate(0, undefined, 1);
668 testPgUpdate(0, -1, 1);
669 testPgUpdate(undefined, -20, 1);
670 });
671
672 it('changes the value and than jumps', () => {
673 testPgUpdate(230, 1, 512);
674 testPgUpdate(3500, -1, 2048);
675 });
676
677 describe('pg power jump', () => {
678 it('should jump correctly at the beginning', () => {
679 testPgUpdate(1, -1, 1);
680 testPgUpdate(1, 1, 2);
681 testPgUpdate(2, -1, 1);
682 testPgUpdate(2, 1, 4);
683 testPgUpdate(4, -1, 2);
684 testPgUpdate(4, 1, 8);
685 testPgUpdate(4, 1, 8);
686 });
687
688 it('increments pg power if difference to the current number is 1', () => {
689 testPgUpdate(undefined, 1, 512);
690 testPgUpdate(undefined, 1, 1024);
691 testPgUpdate(undefined, 1, 2048);
692 testPgUpdate(undefined, 1, 4096);
693 });
694
695 it('decrements pg power if difference to the current number is -1', () => {
696 testPgUpdate(undefined, -1, 128);
697 testPgUpdate(undefined, -1, 64);
698 testPgUpdate(undefined, -1, 32);
699 testPgUpdate(undefined, -1, 16);
700 testPgUpdate(undefined, -1, 8);
701 });
702 });
703
704 describe('pgCalc', () => {
705 const PGS = 1;
706
707 const getValidCase = () => ({
708 type: 'replicated',
709 osds: OSDS,
710 size: 4,
711 ecp: {
712 k: 2,
713 m: 2
714 },
715 expected: 256
716 });
717
718 const testPgCalc = ({ type, osds, size, ecp, expected }: Record<string, any>) => {
719 component.info.osd_count = osds;
720 formHelper.setValue('poolType', type);
721 if (type === 'replicated') {
722 formHelper.setValue('size', size);
723 } else {
724 formHelper.setValue('erasureProfile', ecp);
725 }
726 expect(form.getValue('pgNum')).toBe(expected);
727 expect(component.externalPgChange).toBe(PGS !== expected);
728 };
729
730 beforeEach(() => {
731 setPgNum(PGS);
732 });
733
734 it('does not change anything if type is not valid', () => {
735 const test = getValidCase();
736 test.type = '';
737 test.expected = PGS;
738 testPgCalc(test);
739 });
740
741 it('does not change anything if ecp is not valid', () => {
742 const test = getValidCase();
743 test.expected = PGS;
744 test.type = 'erasure';
745 test.ecp = null;
746 testPgCalc(test);
747 });
748
749 it('calculates some replicated values', () => {
750 const test = getValidCase();
751 testPgCalc(test);
752 test.osds = 16;
753 test.expected = 512;
754 testPgCalc(test);
755 test.osds = 8;
756 test.size = 8;
757 test.expected = 128;
758 testPgCalc(test);
759 });
760
761 it('calculates erasure code values even if selection is disabled', () => {
762 component['initEcp']([{ k: 2, m: 2, name: 'bla', plugin: '', technique: '' }]);
763 const test = getValidCase();
764 test.type = 'erasure';
765 testPgCalc(test);
766 expect(form.get('erasureProfile').disabled).toBeTruthy();
767 });
768
769 it('calculates some erasure code values', () => {
770 const test = getValidCase();
771 test.type = 'erasure';
772 testPgCalc(test);
773 test.osds = 16;
774 test.ecp.m = 5;
775 test.expected = 256;
776 testPgCalc(test);
777 test.ecp.k = 5;
778 test.expected = 128;
779 testPgCalc(test);
780 });
781
782 it('should not change a manual set pg number', () => {
783 form.get('pgNum').markAsDirty();
784 const test = getValidCase();
785 test.expected = PGS;
786 testPgCalc(test);
787 });
788 });
789 });
790
791 describe('crushRule', () => {
792 const selectRuleByIndex = (n: number) => {
793 formHelper.setValue('crushRule', component.info.crush_rules_replicated[n]);
794 };
795
796 beforeEach(() => {
797 formHelper.setValue('poolType', 'replicated');
798 selectRuleByIndex(0);
799 fixture.detectChanges();
800 });
801
802 it('should not show info per default', () => {
803 fixtureHelper.expectElementVisible('#crushRule', true);
804 fixtureHelper.expectElementVisible('#crush-info-block', false);
805 });
806
807 it('should show info if the info button is clicked', () => {
808 const infoButton = fixture.debugElement.query(By.css('#crush-info-button'));
809 infoButton.triggerEventHandler('click', null);
810 expect(component.data.crushInfo).toBeTruthy();
811 fixture.detectChanges();
812 expect(infoButton.classes['active']).toBeTruthy();
813 fixtureHelper.expectIdElementsVisible(['crushRule', 'crush-info-block'], true);
814 });
815
816 it('should know which rules are in use', () => {
817 selectRuleByIndex(2);
818 expect(component.crushUsage).toEqual(['some.pool.uses.it']);
819 });
820
821 describe('crush rule deletion', () => {
822 let taskWrapper: TaskWrapperService;
823 let deletion: CriticalConfirmationModalComponent;
824 let deleteSpy: jasmine.Spy;
825 let modalSpy: jasmine.Spy;
826
827 const callDeletion = () => {
828 component.deleteCrushRule();
829 deletion.submitActionObservable();
830 };
831
832 const callDeletionWithRuleByIndex = (index: number) => {
833 deleteSpy.calls.reset();
834 selectRuleByIndex(index);
835 callDeletion();
836 };
837
838 const expectSuccessfulDeletion = (name: string) => {
839 expect(crushRuleService.delete).toHaveBeenCalledWith(name);
840 expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
841 task: {
842 name: 'crushRule/delete',
843 metadata: {
844 name: name
845 }
846 },
847 call: undefined // because of stub
848 });
849 };
850
851 beforeEach(() => {
852 modalSpy = spyOn(TestBed.get(BsModalService), 'show').and.callFake(
853 (deletionClass, config) => {
854 deletion = Object.assign(new deletionClass(), config.initialState);
855 return {
856 content: deletion
857 };
858 }
859 );
860 deleteSpy = spyOn(crushRuleService, 'delete').and.callFake((name) => {
861 const rules = infoReturn.crush_rules_replicated;
862 const index = _.findIndex(rules, (rule) => rule.rule_name === name);
863 rules.splice(index, 1);
864 });
865 taskWrapper = TestBed.get(TaskWrapperService);
866 spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
867 });
868
869 describe('with unused rule', () => {
870 beforeEach(() => {
871 callDeletionWithRuleByIndex(0);
872 });
873
874 it('should have called delete', () => {
875 expectSuccessfulDeletion('rep1');
876 });
877
878 it('should not open the tooltip nor the crush info', () => {
879 expect(component.crushDeletionBtn.isOpen).toBe(false);
880 expect(component.data.crushInfo).toBe(false);
881 });
882
883 it('should reload the rules after deletion', () => {
884 const expected = infoReturn.crush_rules_replicated;
885 const currentRules = component.current.rules;
886 expect(currentRules.length).toBe(expected.length);
887 expect(currentRules).toEqual(expected);
888 });
889 });
890
891 describe('rule in use', () => {
892 beforeEach(() => {
893 spyOn(global, 'setTimeout').and.callFake((fn: Function) => fn());
894 component.crushInfoTabs = { tabs: [{}, {}, {}] } as TabsetComponent; // Mock it
895 deleteSpy.calls.reset();
896 selectRuleByIndex(2);
897 component.deleteCrushRule();
898 });
899
900 it('should not have called delete and opened the tooltip', () => {
901 expect(crushRuleService.delete).not.toHaveBeenCalled();
902 expect(component.crushDeletionBtn.isOpen).toBe(true);
903 expect(component.data.crushInfo).toBe(true);
904 });
905
906 it('should open the third crush info tab', () => {
907 expect(component.crushInfoTabs).toEqual({
908 tabs: [{}, {}, { active: true }]
909 } as TabsetComponent);
910 });
911
912 it('should hide the tooltip when clicking on delete again', () => {
913 component.deleteCrushRule();
914 expect(component.crushDeletionBtn.isOpen).toBe(false);
915 });
916
917 it('should hide the tooltip when clicking on add', () => {
918 modalSpy.and.callFake((): any => ({
919 content: {
920 submitAction: of('someRule')
921 }
922 }));
923 component.addCrushRule();
924 expect(component.crushDeletionBtn.isOpen).toBe(false);
925 });
926
927 it('should hide the tooltip when changing the crush rule', () => {
928 selectRuleByIndex(0);
929 expect(component.crushDeletionBtn.isOpen).toBe(false);
930 });
931 });
932 });
933 });
934
935 describe('erasure code profile', () => {
936 const setSelectedEcp = (name: string) => {
937 formHelper.setValue('erasureProfile', { name: name });
938 };
939
940 beforeEach(() => {
941 formHelper.setValue('poolType', 'erasure');
942 fixture.detectChanges();
943 });
944
945 it('should not show info per default', () => {
946 fixtureHelper.expectElementVisible('#erasureProfile', true);
947 fixtureHelper.expectElementVisible('#ecp-info-block', false);
948 });
949
950 it('should show info if the info button is clicked', () => {
951 const infoButton = fixture.debugElement.query(By.css('#ecp-info-button'));
952 infoButton.triggerEventHandler('click', null);
953 expect(component.data.erasureInfo).toBeTruthy();
954 fixture.detectChanges();
955 expect(infoButton.classes['active']).toBeTruthy();
956 fixtureHelper.expectIdElementsVisible(['erasureProfile', 'ecp-info-block'], true);
957 });
958
959 describe('ecp deletion', () => {
960 let taskWrapper: TaskWrapperService;
961 let deletion: CriticalConfirmationModalComponent;
962
963 const callDeletion = () => {
964 component.deleteErasureCodeProfile();
965 deletion.submitActionObservable();
966 };
967
968 const testPoolDeletion = (name: string) => {
969 setSelectedEcp(name);
970 callDeletion();
971 expect(ecpService.delete).toHaveBeenCalledWith(name);
972 expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
973 task: {
974 name: 'ecp/delete',
975 metadata: {
976 name: name
977 }
978 },
979 call: undefined // because of stub
980 });
981 };
982
983 beforeEach(() => {
984 spyOn(TestBed.get(BsModalService), 'show').and.callFake((deletionClass, config) => {
985 deletion = Object.assign(new deletionClass(), config.initialState);
986 return {
987 content: deletion
988 };
989 });
990 spyOn(ecpService, 'delete').and.stub();
991 taskWrapper = TestBed.get(TaskWrapperService);
992 spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
993 });
994
995 it('should delete two different erasure code profiles', () => {
996 testPoolDeletion('someEcpName');
997 testPoolDeletion('aDifferentEcpName');
998 });
999 });
1000 });
1001
1002 describe('submit - create', () => {
1003 const setMultipleValues = (settings: object) => {
1004 Object.keys(settings).forEach((name) => {
1005 formHelper.setValue(name, settings[name]);
1006 });
1007 };
1008
1009 describe('erasure coded pool', () => {
1010 const expectEcSubmit = (o: any) =>
1011 expectValidSubmit(
1012 Object.assign(
1013 {
1014 pool: 'ecPool',
1015 pool_type: 'erasure',
1016 pg_autoscale_mode: 'off',
1017 erasure_code_profile: 'ecp1',
1018 pg_num: 4
1019 },
1020 o
1021 )
1022 );
1023
1024 beforeEach(() => {
1025 setMultipleValues({
1026 name: 'ecPool',
1027 poolType: 'erasure',
1028 pgNum: 4
1029 });
1030 });
1031
1032 it('minimum requirements without ECP to create ec pool', () => {
1033 // Mock that no ec profiles exist
1034 infoReturn.erasure_code_profiles = [];
1035 setUpPoolComponent();
1036 setMultipleValues({
1037 name: 'minECPool',
1038 poolType: 'erasure',
1039 pgNum: 4
1040 });
1041 expectValidSubmit({
1042 pool: 'minECPool',
1043 pool_type: 'erasure',
1044 pg_autoscale_mode: 'off',
1045 pg_num: 4
1046 });
1047 });
1048
1049 it('creates ec pool with erasure coded profile', () => {
1050 const ecp = { name: 'ecpMinimalMock' };
1051 setMultipleValues({
1052 erasureProfile: ecp
1053 });
1054 expectEcSubmit({
1055 erasure_code_profile: ecp.name
1056 });
1057 });
1058
1059 it('creates ec pool with ec_overwrite flag', () => {
1060 setMultipleValues({
1061 ecOverwrites: true
1062 });
1063 expectEcSubmit({
1064 flags: ['ec_overwrites']
1065 });
1066 });
1067
1068 it('should ignore replicated set settings for ec pools', () => {
1069 setMultipleValues({
1070 size: 2 // will be ignored
1071 });
1072 expectEcSubmit({});
1073 });
1074
1075 it('creates a pool with compression', () => {
1076 setMultipleValues({
1077 mode: 'passive',
1078 algorithm: 'lz4',
1079 minBlobSize: '4 K',
1080 maxBlobSize: '4 M',
1081 ratio: 0.7
1082 });
1083 expectEcSubmit({
1084 compression_mode: 'passive',
1085 compression_algorithm: 'lz4',
1086 compression_min_blob_size: 4096,
1087 compression_max_blob_size: 4194304,
1088 compression_required_ratio: 0.7
1089 });
1090 });
1091
1092 it('creates a pool with application metadata', () => {
1093 component.data.applications.selected = ['cephfs', 'rgw'];
1094 expectEcSubmit({
1095 application_metadata: ['cephfs', 'rgw']
1096 });
1097 });
1098 });
1099
1100 describe('with replicated pool', () => {
1101 const expectReplicatedSubmit = (o: any) =>
1102 expectValidSubmit(
1103 Object.assign(
1104 {
1105 pool: 'repPool',
1106 pool_type: 'replicated',
1107 pg_autoscale_mode: 'off',
1108 pg_num: 16,
1109 rule_name: 'rep1',
1110 size: 3
1111 },
1112 o
1113 )
1114 );
1115 beforeEach(() => {
1116 setMultipleValues({
1117 name: 'repPool',
1118 poolType: 'replicated',
1119 crushRule: infoReturn.crush_rules_replicated[0],
1120 size: 3,
1121 pgNum: 16
1122 });
1123 });
1124
1125 it('uses the minimum requirements for replicated pools', () => {
1126 // Mock that no replicated rules exist
1127 infoReturn.crush_rules_replicated = [];
1128 setUpPoolComponent();
1129
1130 setMultipleValues({
1131 name: 'minRepPool',
1132 poolType: 'replicated',
1133 size: 2,
1134 pgNum: 32
1135 });
1136 expectValidSubmit({
1137 pool: 'minRepPool',
1138 pool_type: 'replicated',
1139 pg_num: 32,
1140 pg_autoscale_mode: 'off',
1141 size: 2
1142 });
1143 });
1144
1145 it('ignores erasure only set settings for replicated pools', () => {
1146 setMultipleValues({
1147 erasureProfile: { name: 'ecpMinimalMock' }, // Will be ignored
1148 ecOverwrites: true // Will be ignored
1149 });
1150 /**
1151 * As pgCalc is triggered through profile changes, which is normally not possible,
1152 * if type `replicated` is set, pgNum will be set to 256 with the current rule for
1153 * a replicated pool.
1154 */
1155 expectReplicatedSubmit({
1156 pg_num: 256
1157 });
1158 });
1159
1160 it('creates a pool with quotas', () => {
1161 setMultipleValues({
1162 max_bytes: 1024 * 1024,
1163 max_objects: 3000
1164 });
1165 expectReplicatedSubmit({
1166 quota_max_bytes: 1024 * 1024,
1167 quota_max_objects: 3000
1168 });
1169 });
1170
1171 it('creates a pool with rbd qos settings', () => {
1172 component.currentConfigurationValues = {
1173 rbd_qos_bps_limit: 55
1174 };
1175 expectReplicatedSubmit({
1176 configuration: {
1177 rbd_qos_bps_limit: 55
1178 }
1179 });
1180 });
1181 });
1182 });
1183
1184 describe('edit mode', () => {
1185 const setUrl = (url: string) => {
1186 Object.defineProperty(router, 'url', { value: url });
1187 setUpPoolComponent(); // Renew of component needed because the constructor has to be called
1188 };
1189
1190 let pool: Pool;
1191 beforeEach(() => {
1192 pool = new Pool('somePoolName');
1193 pool.type = 'replicated';
1194 pool.size = 3;
1195 pool.crush_rule = 'rep1';
1196 pool.pg_num = 32;
1197 pool.options = {};
1198 pool.options.compression_mode = 'passive';
1199 pool.options.compression_algorithm = 'lz4';
1200 pool.options.compression_min_blob_size = 1024 * 512;
1201 pool.options.compression_max_blob_size = 1024 * 1024;
1202 pool.options.compression_required_ratio = 0.8;
1203 pool.flags_names = 'someFlag1,someFlag2';
1204 pool.application_metadata = ['rbd', 'ownApp'];
1205 pool.quota_max_bytes = 1024 * 1024 * 1024;
1206 pool.quota_max_objects = 3000;
1207
1208 createCrushRule({ name: 'someRule' });
1209 spyOn(poolService, 'get').and.callFake(() => of(pool));
1210 });
1211
1212 it('is not in edit mode if edit is not included in url', () => {
1213 setUrl('/pool/add');
1214 expect(component.editing).toBeFalsy();
1215 });
1216
1217 it('is in edit mode if edit is included in url', () => {
1218 setUrl('/pool/edit/somePoolName');
1219 expect(component.editing).toBeTruthy();
1220 });
1221
1222 describe('after ngOnInit', () => {
1223 beforeEach(() => {
1224 setUrl('/pool/edit/somePoolName');
1225 fixture.detectChanges();
1226 });
1227
1228 it('disabled inputs', () => {
1229 fixture.detectChanges();
1230 const disabled = ['poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'];
1231 disabled.forEach((controlName) => {
1232 return expect(form.get(controlName).disabled).toBeTruthy();
1233 });
1234 const enabled = [
1235 'name',
1236 'pgNum',
1237 'mode',
1238 'algorithm',
1239 'minBlobSize',
1240 'maxBlobSize',
1241 'ratio',
1242 'max_bytes',
1243 'max_objects'
1244 ];
1245 enabled.forEach((controlName) => {
1246 return expect(form.get(controlName).enabled).toBeTruthy();
1247 });
1248 });
1249
1250 it('should include the custom app as valid option', () => {
1251 expect(
1252 component.data.applications.available.map((app: Record<string, any>) => app.name)
1253 ).toEqual(['cephfs', 'ownApp', 'rbd', 'rgw']);
1254 });
1255
1256 it('set all control values to the given pool', () => {
1257 expect(form.getValue('name')).toBe(pool.pool_name);
1258 expect(form.getValue('poolType')).toBe(pool.type);
1259 expect(form.getValue('crushRule')).toEqual(component.info.crush_rules_replicated[0]);
1260 expect(form.getValue('size')).toBe(pool.size);
1261 expect(form.getValue('pgNum')).toBe(pool.pg_num);
1262 expect(form.getValue('mode')).toBe(pool.options.compression_mode);
1263 expect(form.getValue('algorithm')).toBe(pool.options.compression_algorithm);
1264 expect(form.getValue('minBlobSize')).toBe('512 KiB');
1265 expect(form.getValue('maxBlobSize')).toBe('1 MiB');
1266 expect(form.getValue('ratio')).toBe(pool.options.compression_required_ratio);
1267 expect(form.getValue('max_bytes')).toBe('1 GiB');
1268 expect(form.getValue('max_objects')).toBe(pool.quota_max_objects);
1269 });
1270
1271 it('updates pgs on every change', () => {
1272 testPgUpdate(undefined, -1, 16);
1273 testPgUpdate(undefined, -1, 8);
1274 });
1275
1276 it('is possible to use less or more pgs than before', () => {
1277 formHelper.expectValid(setPgNum(64));
1278 formHelper.expectValid(setPgNum(4));
1279 });
1280
1281 describe('submit', () => {
1282 const markControlAsPreviouslySet = (controlName: string) =>
1283 form.get(controlName).markAsPristine();
1284
1285 beforeEach(() => {
1286 [
1287 'algorithm',
1288 'maxBlobSize',
1289 'minBlobSize',
1290 'mode',
1291 'pgNum',
1292 'ratio',
1293 'name'
1294 ].forEach((name) => markControlAsPreviouslySet(name));
1295 fixture.detectChanges();
1296 });
1297
1298 it(`always provides the application metadata array with submit even if it's empty`, () => {
1299 expect(form.get('mode').dirty).toBe(false);
1300 component.data.applications.selected = [];
1301 expectValidSubmit(
1302 {
1303 application_metadata: [],
1304 pool: 'somePoolName'
1305 },
1306 'pool/edit',
1307 'update'
1308 );
1309 });
1310
1311 it(`will always provide reset value for compression options`, () => {
1312 formHelper.setValue('minBlobSize', '').markAsDirty();
1313 formHelper.setValue('maxBlobSize', '').markAsDirty();
1314 formHelper.setValue('ratio', '').markAsDirty();
1315 expectValidSubmit(
1316 {
1317 application_metadata: ['ownApp', 'rbd'],
1318 compression_max_blob_size: 0,
1319 compression_min_blob_size: 0,
1320 compression_required_ratio: 0,
1321 pool: 'somePoolName'
1322 },
1323 'pool/edit',
1324 'update'
1325 );
1326 });
1327
1328 it(`will unset mode not used anymore`, () => {
1329 formHelper.setValue('mode', 'none').markAsDirty();
1330 expectValidSubmit(
1331 {
1332 application_metadata: ['ownApp', 'rbd'],
1333 compression_mode: 'unset',
1334 pool: 'somePoolName'
1335 },
1336 'pool/edit',
1337 'update'
1338 );
1339 });
1340 });
1341 });
1342 });
1343
1344 describe('test pool configuration component', () => {
1345 it('is visible for replicated pools with rbd application', () => {
1346 const poolType = component.form.get('poolType');
1347 poolType.markAsDirty();
1348 poolType.setValue('replicated');
1349 component.data.applications.selected = ['rbd'];
1350 fixture.detectChanges();
1351 expect(
1352 fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
1353 .hidden
1354 ).toBe(false);
1355 });
1356
1357 it('is invisible for erasure coded pools', () => {
1358 const poolType = component.form.get('poolType');
1359 poolType.markAsDirty();
1360 poolType.setValue('erasure');
1361 fixture.detectChanges();
1362 expect(
1363 fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
1364 .hidden
1365 ).toBe(true);
1366 });
1367 });
1368 });