--- /dev/null
+# -*- coding: utf-8 -*-
+
+from __future__ import absolute_import
+
+import six
+
+from .helper import DashboardTestCase, JObj, JList
+
+
+class CrushRuleTest(DashboardTestCase):
+
+ AUTH_ROLES = ['pool-manager']
+
+ rule_schema = JObj(sub_elems={
+ 'max_size': int,
+ 'min_size': int,
+ 'rule_id': int,
+ 'rule_name': six.string_types,
+ 'ruleset': int,
+ 'steps': JList(JObj({}, allow_unknown=True))
+ }, allow_unknown=True)
+
+ def create_and_delete_rule(self, data):
+ name = data['name']
+ # Creates rule
+ self._post('/api/crush_rule', data)
+ self.assertStatus(201)
+ # Makes sure rule exists
+ rule = self._get('/api/crush_rule/{}'.format(name))
+ self.assertStatus(200)
+ self.assertSchemaBody(self.rule_schema)
+ self.assertEqual(rule['rule_name'], name)
+ # Deletes rule
+ self._delete('/api/crush_rule/{}'.format(name))
+ self.assertStatus(204)
+
+ @DashboardTestCase.RunAs('test', 'test', ['rgw-manager'])
+ def test_read_access_permissions(self):
+ self._get('/api/crush_rule')
+ self.assertStatus(403)
+
+ @DashboardTestCase.RunAs('test', 'test', ['read-only'])
+ def test_write_access_permissions(self):
+ self._get('/api/crush_rule')
+ self.assertStatus(200)
+ data = {'name': 'some_rule', 'root': 'default', 'failure_domain': 'osd'}
+ self._post('/api/crush_rule', data)
+ self.assertStatus(403)
+ self._delete('/api/crush_rule/default')
+ self.assertStatus(403)
+
+ @classmethod
+ def tearDownClass(cls):
+ super(CrushRuleTest, cls).tearDownClass()
+ cls._ceph_cmd(['osd', 'crush', 'rule', 'rm', 'some_rule'])
+ cls._ceph_cmd(['osd', 'crush', 'rule', 'rm', 'another_rule'])
+
+ def test_list(self):
+ self._get('/api/crush_rule')
+ self.assertStatus(200)
+ self.assertSchemaBody(JList(self.rule_schema))
+
+ def test_create(self):
+ self.create_and_delete_rule({
+ 'name': 'some_rule',
+ 'root': 'default',
+ 'failure_domain': 'osd'
+ })
+
+ @DashboardTestCase.RunAs('test', 'test', ['pool-manager', 'cluster-manager'])
+ def test_create_with_ssd(self):
+ data = self._get('/api/osd/0')
+ self.assertStatus(200)
+ device_class = data['osd_metadata']['default_device_class']
+ self.create_and_delete_rule({
+ 'name': 'another_rule',
+ 'root': 'default',
+ 'failure_domain': 'osd',
+ 'device_class': device_class
+ })
+
+ def test_crush_rule_info(self):
+ self._get('/ui-api/crush_rule/info')
+ self.assertStatus(200)
+ self.assertSchemaBody(JObj({
+ 'names': JList(six.string_types),
+ 'nodes': JList(JObj({}, allow_unknown=True))
+ }))
+
--- /dev/null
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from cherrypy import NotFound
+
+from . import ApiController, RESTController, Endpoint, ReadPermission, UiApiController
+from ..security import Scope
+from ..services.ceph_service import CephService
+from .. import mgr
+
+
+@ApiController('/crush_rule', Scope.POOL)
+class CrushRule(RESTController):
+ def list(self):
+ return mgr.get('osd_map_crush')['rules']
+
+ def get(self, name):
+ rules = mgr.get('osd_map_crush')['rules']
+ for r in rules:
+ if r['rule_name'] == name:
+ return r
+ raise NotFound('No such crush rule')
+
+ def create(self, name, root, failure_domain, device_class=None):
+ rule = {
+ 'name': name,
+ 'root': root,
+ 'type': failure_domain,
+ 'class': device_class
+ }
+ CephService.send_command('mon', 'osd crush rule create-replicated', **rule)
+
+ def delete(self, name):
+ CephService.send_command('mon', 'osd crush rule rm', name=name)
+
+
+@UiApiController('/crush_rule', Scope.POOL)
+class CrushRuleUi(CrushRule):
+ @Endpoint()
+ @ReadPermission
+ def info(self):
+ '''Used for crush rule creation modal'''
+ return {
+ 'names': [r['rule_name'] for r in mgr.get('osd_map_crush')['rules']],
+ 'nodes': mgr.get('osd_map_tree')['nodes']
+ }
--- /dev/null
+<cd-modal [modalRef]="bsModalRef">
+ <ng-container i18n="form title|Example: Create Pool@@formTitle"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label for="name"
+ class="col-form-label col-sm-3">
+ <ng-container i18n>Name</ng-container>
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <input type="text"
+ id="name"
+ name="name"
+ class="form-control"
+ placeholder="Name..."
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'pattern')"
+ i18n>The name can only consist of alphanumeric characters, dashes and underscores.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'uniqueName')"
+ i18n>The chosen erasure code profile name is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Root -->
+ <div class="form-group row">
+ <label for="root"
+ class="col-form-label col-sm-3">
+ <ng-container i18n>Root</ng-container>
+ <cd-helper [html]="tooltips.root">
+ </cd-helper>
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <select class="form-control custom-select"
+ id="root"
+ name="root"
+ formControlName="root">
+ <option *ngIf="!buckets"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let bucket of buckets"
+ [ngValue]="bucket">
+ {{ bucket.name }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('root', frm, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Failure Domain Type -->
+ <div class="form-group row">
+ <label for="failure_domain"
+ class="col-form-label col-sm-3">
+ <ng-container i18n>Failure domain type</ng-container>
+ <cd-helper [html]="tooltips.failure_domain">
+ </cd-helper>
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <select class="form-control custom-select"
+ id="failure_domain"
+ name="failure_domain"
+ formControlName="failure_domain">
+ <option *ngIf="!failureDomains"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let domain of failureDomainKeys()"
+ [ngValue]="domain">
+ {{ domain }} ( {{failureDomains[domain].length}} )
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('failure_domain', frm, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Class -->
+ <div class="form-group row">
+ <label for="device_class"
+ class="col-form-label col-sm-3">
+ <ng-container i18n>Device class</ng-container>
+ <cd-helper [html]="tooltips.device_class">
+ </cd-helper>
+ </label>
+ <div class="col-sm-9">
+ <select class="form-control custom-select"
+ id="device_class"
+ name="device_class"
+ formControlName="device_class">
+ <option ngValue=""
+ i18n>Let Ceph decide</option>
+ <option *ngFor="let deviceClass of devices"
+ [ngValue]="deviceClass">
+ {{ deviceClass }}
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-submit-button (submitAction)="onSubmit()"
+ i18n="form action button|Example: Create Pool@@formActionButton"
+ [form]="frm">{{ action | titlecase }} {{ resource | upperFirst }}</cd-submit-button>
+ <cd-back-button [back]="bsModalRef.hide"></cd-back-button>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import {
+ configureTestBed,
+ FixtureHelper,
+ FormHelper,
+ i18nProviders
+} from '../../../../testing/unit-test-helper';
+import { CrushRuleService } from '../../../shared/api/crush-rule.service';
+import { CrushNode } from '../../../shared/models/crush-node';
+import { CrushRuleConfig } from '../../../shared/models/crush-rule';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { PoolModule } from '../pool.module';
+import { CrushRuleFormModalComponent } from './crush-rule-form-modal.component';
+
+describe('CrushRuleFormComponent', () => {
+ let component: CrushRuleFormModalComponent;
+ let crushRuleService: CrushRuleService;
+ let fixture: ComponentFixture<CrushRuleFormModalComponent>;
+ let formHelper: FormHelper;
+ let fixtureHelper: FixtureHelper;
+ let data: { names: string[]; nodes: CrushNode[] };
+
+ // Object contains mock functions
+ const mock = {
+ node: (
+ name: string,
+ id: number,
+ type: string,
+ type_id: number,
+ children?: number[],
+ device_class?: string
+ ): CrushNode => {
+ return { name, type, type_id, id, children, device_class };
+ },
+ rule: (
+ name: string,
+ root: string,
+ failure_domain: string,
+ device_class?: string
+ ): CrushRuleConfig => ({
+ name,
+ root,
+ failure_domain,
+ device_class
+ })
+ };
+
+ // Object contains functions to get something
+ const get = {
+ nodeByName: (name: string): CrushNode => data.nodes.find((node) => node.name === name),
+ nodesByNames: (names: string[]): CrushNode[] => names.map(get.nodeByName)
+ };
+
+ // Expects that are used frequently
+ const assert = {
+ failureDomains: (nodes: CrushNode[], types: string[]) => {
+ const expectation = {};
+ types.forEach((type) => (expectation[type] = nodes.filter((node) => node.type === type)));
+ const keys = component.failureDomainKeys();
+ expect(keys).toEqual(types);
+ keys.forEach((key) => {
+ expect(component.failureDomains[key].length).toBe(expectation[key].length);
+ });
+ },
+ formFieldValues: (root: CrushNode, failureDomain: string, device: string) => {
+ expect(component.form.value).toEqual({
+ name: '',
+ root,
+ failure_domain: failureDomain,
+ device_class: device
+ });
+ },
+ valuesOnRootChange: (
+ rootName: string,
+ expectedFailureDomain: string,
+ expectedDevice: string
+ ) => {
+ const node = get.nodeByName(rootName);
+ formHelper.setValue('root', node);
+ assert.formFieldValues(node, expectedFailureDomain, expectedDevice);
+ },
+ creation: (rule: CrushRuleConfig) => {
+ formHelper.setValue('name', rule.name);
+ fixture.detectChanges();
+ component.onSubmit();
+ expect(crushRuleService.create).toHaveBeenCalledWith(rule);
+ }
+ };
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ PoolModule,
+ NgBootstrapFormValidationModule.forRoot()
+ ],
+ providers: [CrushRuleService, BsModalRef, i18nProviders]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CrushRuleFormModalComponent);
+ fixtureHelper = new FixtureHelper(fixture);
+ component = fixture.componentInstance;
+ formHelper = new FormHelper(component.form);
+ crushRuleService = TestBed.get(CrushRuleService);
+ data = {
+ names: ['rule1', 'rule2'],
+ /**
+ * Create the following test crush map:
+ * > default
+ * --> ssd-host
+ * ----> 3x osd with ssd
+ * --> mix-host
+ * ----> hdd-rack
+ * ------> 2x osd-rack with hdd
+ * ----> ssd-rack
+ * ------> 2x osd-rack with ssd
+ */
+ nodes: [
+ // Root node
+ mock.node('default', -1, 'root', 11, [-2, -3]),
+ // SSD host
+ mock.node('ssd-host', -2, 'host', 1, [1, 0, 2]),
+ mock.node('osd.0', 0, 'osd', 0, undefined, 'ssd'),
+ mock.node('osd.1', 1, 'osd', 0, undefined, 'ssd'),
+ mock.node('osd.2', 2, 'osd', 0, undefined, 'ssd'),
+ // SSD and HDD mixed devices host
+ mock.node('mix-host', -3, 'host', 1, [-4, -5]),
+ // HDD rack
+ mock.node('hdd-rack', -4, 'rack', 3, [3, 4]),
+ mock.node('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'),
+ mock.node('osd2.1', 4, 'osd-rack', 0, undefined, 'hdd'),
+ // SSD rack
+ mock.node('ssd-rack', -5, 'rack', 3, [5, 6]),
+ mock.node('osd2.0', 5, 'osd-rack', 0, undefined, 'ssd'),
+ mock.node('osd2.1', 6, 'osd-rack', 0, undefined, 'ssd')
+ ]
+ };
+ spyOn(crushRuleService, 'getInfo').and.callFake(() => of(data));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('calls listing to get rules on ngInit', () => {
+ expect(crushRuleService.getInfo).toHaveBeenCalled();
+ expect(component.names.length).toBe(2);
+ expect(component['nodes'].length).toBe(12);
+ });
+
+ describe('lists', () => {
+ afterEach(() => {
+ // The available buckets should not change
+ expect(component.buckets).toEqual(
+ get.nodesByNames(['default', 'hdd-rack', 'mix-host', 'ssd-host', 'ssd-rack'])
+ );
+ });
+
+ it('has the following lists after init', () => {
+ assert.failureDomains(data.nodes, ['host', 'osd', 'osd-rack', 'rack']); // Not root as root only exist once
+ expect(component.devices).toEqual(['hdd', 'ssd']);
+ });
+
+ it('has the following lists after selection of ssd-host', () => {
+ formHelper.setValue('root', get.nodeByName('ssd-host'));
+ assert.failureDomains(get.nodesByNames(['osd.0', 'osd.1', 'osd.2']), ['osd']); // Not host as it only exist once
+ expect(component.devices).toEqual(['ssd']);
+ });
+
+ it('has the following lists after selection of mix-host', () => {
+ formHelper.setValue('root', get.nodeByName('mix-host'));
+ expect(component.devices).toEqual(['hdd', 'ssd']);
+ assert.failureDomains(
+ get.nodesByNames(['hdd-rack', 'ssd-rack', 'osd2.0', 'osd2.1', 'osd2.0', 'osd2.1']),
+ ['osd-rack', 'rack']
+ );
+ });
+ });
+
+ describe('selection', () => {
+ it('selects the first root after init automatically', () => {
+ assert.formFieldValues(get.nodeByName('default'), 'osd-rack', '');
+ });
+
+ it('should select all values automatically by selecting "ssd-host" as root', () => {
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ });
+
+ it('selects automatically the most common failure domain', () => {
+ // Select mix-host as mix-host has multiple failure domains (osd-rack and rack)
+ assert.valuesOnRootChange('mix-host', 'osd-rack', '');
+ });
+
+ it('should override automatic selections', () => {
+ assert.formFieldValues(get.nodeByName('default'), 'osd-rack', '');
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ assert.valuesOnRootChange('mix-host', 'osd-rack', '');
+ });
+
+ it('should not override manual selections if possible', () => {
+ formHelper.setValue('failure_domain', 'rack', true);
+ formHelper.setValue('device_class', 'ssd', true);
+ assert.valuesOnRootChange('mix-host', 'rack', 'ssd');
+ });
+
+ it('should preselect device by domain selection', () => {
+ formHelper.setValue('failure_domain', 'osd', true);
+ assert.formFieldValues(get.nodeByName('default'), 'osd', 'ssd');
+ });
+ });
+
+ describe('form validation', () => {
+ it(`isn't valid if name is not set`, () => {
+ expect(component.form.invalid).toBeTruthy();
+ formHelper.setValue('name', 'someProfileName');
+ expect(component.form.valid).toBeTruthy();
+ });
+
+ it('sets name invalid', () => {
+ component.names = ['awesomeProfileName'];
+ formHelper.expectErrorChange('name', 'awesomeProfileName', 'uniqueName');
+ formHelper.expectErrorChange('name', 'some invalid text', 'pattern');
+ formHelper.expectErrorChange('name', null, 'required');
+ });
+
+ it(`should show all default form controls`, () => {
+ // name
+ // root (preselected(first root))
+ // failure_domain (preselected=type that is most common)
+ // device_class (preselected=any if multiple or some type if only one device type)
+ fixtureHelper.expectIdElementsVisible(
+ ['name', 'root', 'failure_domain', 'device_class'],
+ true
+ );
+ });
+ });
+
+ describe('submission', () => {
+ beforeEach(() => {
+ const taskWrapper = TestBed.get(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ spyOn(crushRuleService, 'create').and.stub();
+ });
+
+ it('creates a rule with only required fields', () => {
+ assert.creation(mock.rule('default-rule', 'default', 'osd-rack'));
+ });
+
+ it('creates a rule with all fields', () => {
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ assert.creation(mock.rule('ssd-host-rule', 'ssd-host', 'osd', 'ssd'));
+ });
+ });
+});
--- /dev/null
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { CrushRuleService } from '../../../shared/api/crush-rule.service';
+import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { CrushNode } from '../../../shared/models/crush-node';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-crush-rule-form-modal',
+ templateUrl: './crush-rule-form-modal.component.html',
+ styleUrls: ['./crush-rule-form-modal.component.scss']
+})
+export class CrushRuleFormModalComponent implements OnInit {
+ @Output()
+ submitAction = new EventEmitter();
+
+ buckets: CrushNode[] = [];
+ failureDomains: { [type: string]: CrushNode[] } = {};
+ devices: string[] = [];
+ tooltips = this.crushRuleService.formTooltips;
+
+ form: CdFormGroup;
+ names: string[];
+ action: string;
+ resource: string;
+
+ private nodes: CrushNode[] = [];
+ private easyNodes: { [id: number]: CrushNode } = {};
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public bsModalRef: BsModalRef,
+ private taskWrapper: TaskWrapperService,
+ private crushRuleService: CrushRuleService,
+ private i18n: I18n,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.action = this.actionLabels.CREATE;
+ this.resource = this.i18n('Crush Rule');
+ this.createForm();
+ }
+
+ createForm() {
+ this.form = this.formBuilder.group({
+ // name: string
+ name: [
+ '',
+ [
+ Validators.required,
+ Validators.pattern('[A-Za-z0-9_-]+'),
+ CdValidators.custom(
+ 'uniqueName',
+ (value: any) => this.names && this.names.indexOf(value) !== -1
+ )
+ ]
+ ],
+ // root: CrushNode
+ root: null, // Replaced with first root
+ // failure_domain: string
+ failure_domain: '', // Replaced with most common type
+ // device_class: string
+ device_class: '' // Replaced with device type if only one exists beneath domain
+ });
+ }
+
+ ngOnInit() {
+ this.crushRuleService
+ .getInfo()
+ .subscribe(({ names, nodes }: { names: string[]; nodes: CrushNode[] }) => {
+ this.nodes = nodes;
+ nodes.forEach((node) => {
+ this.easyNodes[node.id] = node;
+ });
+ this.buckets = _.sortBy(nodes.filter((n) => n.children), 'name');
+ this.names = names;
+ this.preSelectRoot();
+ });
+ this.form.get('root').valueChanges.subscribe((root: CrushNode) => this.updateRoot(root));
+ this.form
+ .get('failure_domain')
+ .valueChanges.subscribe((domain: string) => this.updateDevices(domain));
+ }
+
+ private preSelectRoot() {
+ const rootNode = this.nodes.find((node) => node.type === 'root');
+ this.form.silentSet('root', rootNode);
+ this.updateRoot(rootNode);
+ }
+
+ private updateRoot(rootNode: CrushNode) {
+ const nodes = this.getSubNodes(rootNode);
+ const domains = {};
+ nodes.forEach((node) => {
+ if (!domains[node.type]) {
+ domains[node.type] = [];
+ }
+ domains[node.type].push(node);
+ });
+ Object.keys(domains).forEach((type) => {
+ if (domains[type].length <= 1) {
+ delete domains[type];
+ }
+ });
+ this.failureDomains = domains;
+ this.updateFailureDomain();
+ }
+
+ private getSubNodes(node: CrushNode): CrushNode[] {
+ let subNodes = [node]; // Includes parent node
+ if (!node.children) {
+ return subNodes;
+ }
+ node.children.forEach((id) => {
+ const childNode = this.easyNodes[id];
+ subNodes = subNodes.concat(this.getSubNodes(childNode));
+ });
+ return subNodes;
+ }
+
+ private updateFailureDomain() {
+ let failureDomain = this.getIncludedCustomValue(
+ 'failure_domain',
+ Object.keys(this.failureDomains)
+ );
+ if (failureDomain === '') {
+ failureDomain = this.setMostCommonDomain();
+ }
+ this.updateDevices(failureDomain);
+ }
+
+ private getIncludedCustomValue(controlName: string, includedIn: string[]) {
+ const control = this.form.get(controlName);
+ return control.dirty && includedIn.includes(control.value) ? control.value : '';
+ }
+
+ private setMostCommonDomain(): string {
+ let winner = { n: 0, type: '' };
+ Object.keys(this.failureDomains).forEach((type) => {
+ const n = this.failureDomains[type].length;
+ if (winner.n < n) {
+ winner = { n, type };
+ }
+ });
+ this.form.silentSet('failure_domain', winner.type);
+ return winner.type;
+ }
+
+ updateDevices(failureDomain: string) {
+ const subNodes = _.flatten(
+ this.failureDomains[failureDomain].map((node) => this.getSubNodes(node))
+ );
+ this.devices = _.uniq(subNodes.filter((n) => n.device_class).map((n) => n.device_class)).sort();
+ const device =
+ this.devices.length === 1
+ ? this.devices[0]
+ : this.getIncludedCustomValue('device_class', this.devices);
+ this.form.get('device_class').setValue(device);
+ }
+
+ failureDomainKeys(): string[] {
+ return Object.keys(this.failureDomains).sort();
+ }
+
+ onSubmit() {
+ if (this.form.invalid) {
+ this.form.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ const rule = _.cloneDeep(this.form.value);
+ rule.root = rule.root.name;
+ if (rule.device_class === '') {
+ delete rule.device_class;
+ }
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('crushRule/create', rule),
+ call: this.crushRuleService.create(rule)
+ })
+ .subscribe(
+ undefined,
+ () => {
+ this.form.setErrors({ cdSubmitButton: true });
+ },
+ () => {
+ this.bsModalRef.hide();
+ this.submitAction.emit(rule);
+ }
+ );
+ }
+}
<i [ngClass]="[icons.questionCircle]"
aria-hidden="true"></i>
</button>
+ <button class="btn btn-light"
+ type="button"
+ *ngIf="isReplicated && !editing"
+ (click)="addCrushRule()">
+ <i [ngClass]="[icons.add]"
+ aria-hidden="true"></i>
+ </button>
+ <button class="btn btn-light"
+ *ngIf="isReplicated && !editing"
+ type="button"
+ tooltip="This rule can't be deleted as it is in use."
+ i18n-tooltip
+ triggers=""
+ #crushDeletionBtn="bs-tooltip"
+ (click)="deleteCrushRule()">
+ <i [ngClass]="[icons.trash]"
+ aria-hidden="true"></i>
+ </button>
</span>
</div>
<span class="form-text text-muted"
id="crush-info-block"
*ngIf="data.crushInfo && form.getValue('crushRule')">
- <tabset>
+ <tabset #crushInfoTabs>
<tab i18n-heading
heading="Crush rule"
class="crush-rule-info">
</li>
</ol>
</tab>
+ <tab i18n-heading
+ heading="Used by pools"
+ class="used-by-pools">
+ <ng-template #ruleIsNotUsed>
+ <span i18n>Rule is not in use.</span>
+ </ng-template>
+ <ul *ngIf="crushUsage; else ruleIsNotUsed">
+ <li *ngFor="let pool of crushUsage">
+ {{ pool }}
+ </li>
+ </ul>
+ </tab>
</tabset>
</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('crushRule', formDir, 'required')"
+ i18n>This field is required!</span>
<span class="invalid-feedback"
*ngIf="form.showError('crushRule', formDir, 'tooFewOsds')"
i18n>The rule can't be used in the current cluster as it has
import { ActivatedRoute, Router, Routes } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
+import * as _ from 'lodash';
import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
import { BsModalService } from 'ngx-bootstrap/modal';
-import { TabsModule } from 'ngx-bootstrap/tabs';
+import { TabsetComponent, TabsModule } from 'ngx-bootstrap/tabs';
import { ToastrModule } from 'ngx-toastr';
import { of } from 'rxjs';
i18nProviders
} from '../../../../testing/unit-test-helper';
import { NotFoundComponent } from '../../../core/not-found/not-found.component';
+import { CrushRuleService } from '../../../shared/api/crush-rule.service';
import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
import { PoolService } from '../../../shared/api/pool.service';
import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
let form: CdFormGroup;
let router: Router;
let ecpService: ErasureCodeProfileService;
+ let crushRuleService: CrushRuleService;
const setPgNum = (pgs: number): AbstractControl => {
const control = formHelper.setValue('pgNum', pgs);
compression_modes: ['none', 'passive'],
crush_rules_replicated: [
createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' }),
- createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' })
+ createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' }),
+ createCrushRule({ id: 2, min: 1, max: 9, name: 'used_rule', type: 'replicated' })
],
crush_rules_erasure: [
createCrushRule({ id: 3, min: 1, max: 1, name: 'ecp1', type: 'erasure' })
erasure_code_profiles: [ecp1],
pg_autoscale_default_mode: 'off',
pg_autoscale_modes: ['off', 'warn', 'on'],
- pg_autoscale_config: { default: 'off', enum_values: ['on', 'warn', 'off'], value: [] },
- used_rules: {}
+ used_rules: {
+ used_rule: ['some.pool.uses.it']
+ }
};
};
spyOn(poolService, 'getInfo').and.callFake(() => of(infoReturn));
ecpService = TestBed.get(ErasureCodeProfileService);
+ crushRuleService = TestBed.get(CrushRuleService);
router = TestBed.get(Router);
navigationSpy = spyOn(router, 'navigate').and.stub();
it('validates crushRule with multiple crush rules', () => {
formHelper.expectValidChange('poolType', 'replicated');
+ form.get('crushRule').updateValueAndValidity();
formHelper.expectError('crushRule', 'required'); // As multiple rules exist
formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds');
});
setUpPoolComponent();
formHelper.expectValidChange('poolType', 'replicated');
formHelper.expectValid('crushRule');
- formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds');
});
it('validates size', () => {
});
it('has no effect if pool type is not set', () => {
- component['rulesChange']('');
+ component['poolTypeChange']('');
expect(component.current.rules).toEqual([]);
});
it('shows all replicated rules when pool type is "replicated"', () => {
formHelper.setValue('poolType', 'replicated');
expect(component.current.rules).toEqual(component.info.crush_rules_replicated);
- expect(component.current.rules.length).toBe(2);
+ expect(component.current.rules.length).toBe(3);
});
it('shows all erasure code rules when pool type is "erasure"', () => {
});
describe('crushRule', () => {
- const selectRuleById = (n: number) => {
+ const selectRuleByIndex = (n: number) => {
formHelper.setValue('crushRule', component.info.crush_rules_replicated[n]);
};
beforeEach(() => {
formHelper.setValue('poolType', 'replicated');
- selectRuleById(0);
+ selectRuleByIndex(0);
fixture.detectChanges();
});
expect(infoButton.classes['active']).toBeTruthy();
fixtureHelper.expectIdElementsVisible(['crushRule', 'crush-info-block'], true);
});
+
+ it('should know which rules are in use', () => {
+ selectRuleByIndex(2);
+ expect(component.crushUsage).toEqual(['some.pool.uses.it']);
+ });
+
+ describe('crush rule deletion', () => {
+ let taskWrapper: TaskWrapperService;
+ let deletion: CriticalConfirmationModalComponent;
+ let deleteSpy: jasmine.Spy;
+ let modalSpy: jasmine.Spy;
+
+ const callDeletion = () => {
+ component.deleteCrushRule();
+ deletion.submitActionObservable();
+ };
+
+ const callDeletionWithRuleByIndex = (index: number) => {
+ deleteSpy.calls.reset();
+ selectRuleByIndex(index);
+ callDeletion();
+ };
+
+ const expectSuccessfulDeletion = (name: string) => {
+ expect(crushRuleService.delete).toHaveBeenCalledWith(name);
+ expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
+ task: {
+ name: 'crushRule/delete',
+ metadata: {
+ name: name
+ }
+ },
+ call: undefined // because of stub
+ });
+ };
+
+ beforeEach(() => {
+ modalSpy = spyOn(TestBed.get(BsModalService), 'show').and.callFake(
+ (deletionClass, config) => {
+ deletion = Object.assign(new deletionClass(), config.initialState);
+ return {
+ content: deletion
+ };
+ }
+ );
+ deleteSpy = spyOn(crushRuleService, 'delete').and.callFake((name) => {
+ const rules = infoReturn.crush_rules_replicated;
+ const index = _.findIndex(rules, (rule) => rule.rule_name === name);
+ rules.splice(index, 1);
+ });
+ taskWrapper = TestBed.get(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ });
+
+ describe('with unused rule', () => {
+ beforeEach(() => {
+ callDeletionWithRuleByIndex(0);
+ });
+
+ it('should have called delete', () => {
+ expectSuccessfulDeletion('rep1');
+ });
+
+ it('should not open the tooltip nor the crush info', () => {
+ expect(component.crushDeletionBtn.isOpen).toBe(false);
+ expect(component.data.crushInfo).toBe(false);
+ });
+
+ it('should reload the rules after deletion', () => {
+ const expected = infoReturn.crush_rules_replicated;
+ const currentRules = component.current.rules;
+ expect(currentRules.length).toBe(expected.length);
+ expect(currentRules).toEqual(expected);
+ });
+ });
+
+ describe('rule in use', () => {
+ beforeEach(() => {
+ spyOn(global, 'setTimeout').and.callFake((fn: Function) => fn());
+ component.crushInfoTabs = { tabs: [{}, {}, {}] } as TabsetComponent; // Mock it
+ deleteSpy.calls.reset();
+ selectRuleByIndex(2);
+ component.deleteCrushRule();
+ });
+
+ it('should not have called delete and opened the tooltip', () => {
+ expect(crushRuleService.delete).not.toHaveBeenCalled();
+ expect(component.crushDeletionBtn.isOpen).toBe(true);
+ expect(component.data.crushInfo).toBe(true);
+ });
+
+ it('should open the third crush info tab', () => {
+ expect(component.crushInfoTabs).toEqual({
+ tabs: [{}, {}, { active: true }]
+ } as TabsetComponent);
+ });
+
+ it('should hide the tooltip when clicking on delete again', () => {
+ component.deleteCrushRule();
+ expect(component.crushDeletionBtn.isOpen).toBe(false);
+ });
+
+ it('should hide the tooltip when clicking on add', () => {
+ modalSpy.and.callFake((): any => ({
+ content: {
+ submitAction: of('someRule')
+ }
+ }));
+ component.addCrushRule();
+ expect(component.crushDeletionBtn.isOpen).toBe(false);
+ });
+
+ it('should hide the tooltip when changing the crush rule', () => {
+ selectRuleByIndex(0);
+ expect(component.crushDeletionBtn.isOpen).toBe(false);
+ });
+ });
+ });
});
describe('erasure code profile', () => {
import { I18n } from '@ngx-translate/i18n-polyfill';
import * as _ from 'lodash';
import { BsModalService } from 'ngx-bootstrap/modal';
+import { TabsetComponent } from 'ngx-bootstrap/tabs';
+import { TooltipDirective } from 'ngx-bootstrap/tooltip';
import { Subscription } from 'rxjs';
+import { CrushRuleService } from '../../../shared/api/crush-rule.service';
import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
import { PoolService } from '../../../shared/api/pool.service';
import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
RbdConfigurationEntry,
RbdConfigurationSourceField
} from '../../../shared/models/configuration';
-import { CrushRule } from '../../../shared/models/crush-rule';
+import { CrushRule, CrushRuleConfig } from '../../../shared/models/crush-rule';
import { CrushStep } from '../../../shared/models/crush-step';
import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
import { FinishedTask } from '../../../shared/models/finished-task';
import { AuthStorageService } from '../../../shared/services/auth-storage.service';
import { FormatterService } from '../../../shared/services/formatter.service';
import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { CrushRuleFormModalComponent } from '../crush-rule-form-modal/crush-rule-form-modal.component';
import { ErasureCodeProfileFormComponent } from '../erasure-code-profile-form/erasure-code-profile-form.component';
import { Pool } from '../pool';
import { PoolFormData } from './pool-form-data';
styleUrls: ['./pool-form.component.scss']
})
export class PoolFormComponent implements OnInit {
+ @ViewChild('crushInfoTabs', { static: false }) crushInfoTabs: TabsetComponent;
+ @ViewChild('crushDeletionBtn', { static: false }) crushDeletionBtn: TooltipDirective;
+
permission: Permission;
form: CdFormGroup;
ecProfiles: ErasureCodeProfile[];
resource: string;
icons = Icons;
pgAutoscaleModes: string[];
+ crushUsage: string[] = undefined; // Will only be set if a rule is used by some pool
private modalSubscription: Subscription;
private bsModalService: BsModalService,
private taskWrapper: TaskWrapperService,
private ecpService: ErasureCodeProfileService,
+ private crushRuleService: CrushRuleService,
private i18n: I18n,
public actionLabels: ActionLabelsI18n
) {
this.ecProfiles = ecProfiles;
}
+ /**
+ * Used to update the crush rule or erasure code profile listings.
+ *
+ * If only one rule or profile exists it will be selected.
+ * If nothing exists null will be selected.
+ * If more than one rule or profile exists the listing will be enabled,
+ * otherwise disabled.
+ */
private setListControlStatus(controlName: string, arr: any[]) {
const control = this.form.get(controlName);
- if (arr.length === 1) {
+ const value = control.value;
+ if (arr.length === 1 && (!value || !_.isEqual(value, arr[0]))) {
control.setValue(arr[0]);
+ } else if (arr.length === 0 && value) {
+ control.setValue(null);
}
if (arr.length <= 1) {
- control.disable();
+ if (control.enabled) {
+ control.disable();
+ }
} else if (control.disabled) {
control.enable();
}
initialData: pool.configuration,
sourceType: RbdConfigurationSourceField.pool
});
- this.rulesChange(pool.type);
+ this.poolTypeChange(pool.type);
const rules = this.info.crush_rules_replicated.concat(this.info.crush_rules_erasure);
const dataMap = {
name: pool.pool_name,
private listenToChangesDuringAdd() {
this.form.get('poolType').valueChanges.subscribe((poolType) => {
- this.rulesChange(poolType);
+ this.poolTypeChange(poolType);
});
- this.form.get('crushRule').valueChanges.subscribe(() => {
+ this.form.get('crushRule').valueChanges.subscribe((rule) => {
// The crush rule can only be changed if type 'replicated' is set.
+ if (this.crushDeletionBtn && this.crushDeletionBtn.isOpen) {
+ this.crushDeletionBtn.hide();
+ }
+ if (!rule) {
+ return;
+ }
+ this.crushRuleIsUsedBy(rule.rule_name);
this.replicatedRuleChange();
this.pgCalc();
});
});
}
- private rulesChange(poolType: string) {
+ private poolTypeChange(poolType: string) {
if (poolType === 'replicated') {
this.setTypeBooleans(true, false);
} else if (poolType === 'erasure') {
if (this.editing) {
return;
}
- const control = this.form.get('crushRule');
- if (this.isReplicated && !control.value) {
- if (rules.length === 1) {
- control.setValue(rules[0]);
- control.disable();
- } else {
- control.setValue(null);
- control.enable();
- }
+ if (this.isReplicated) {
+ this.setListControlStatus('crushRule', rules);
}
this.replicatedRuleChange();
this.pgCalc();
});
}
+ addCrushRule() {
+ if (this.crushDeletionBtn.isOpen) {
+ this.crushDeletionBtn.hide();
+ }
+ const modalRef = this.bsModalService.show(CrushRuleFormModalComponent);
+ modalRef.content.submitAction.subscribe((rule: CrushRuleConfig) => {
+ this.reloadCrushRules(rule.name);
+ });
+ }
+
+ private reloadCrushRules(ruleName?: string) {
+ if (this.modalSubscription) {
+ this.modalSubscription.unsubscribe();
+ }
+ this.poolService.getInfo().subscribe((info: PoolFormInfo) => {
+ this.initInfo(info);
+ this.poolTypeChange('replicated');
+ if (!ruleName) {
+ return;
+ }
+ const newRule = this.info.crush_rules_replicated.find((rule) => rule.rule_name === ruleName);
+ if (newRule) {
+ this.form.get('crushRule').setValue(newRule);
+ }
+ });
+ }
+
+ deleteCrushRule() {
+ const rule = this.form.getValue('crushRule');
+ if (!rule) {
+ return;
+ }
+ if (this.crushUsage) {
+ this.crushDeletionBtn.toggle();
+ this.data.crushInfo = true;
+ setTimeout(() => {
+ if (this.crushInfoTabs) {
+ this.crushInfoTabs.tabs[2].active = true;
+ }
+ }, 50);
+ return;
+ }
+ const name = rule.rule_name;
+ this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadCrushRules());
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ initialState: {
+ itemDescription: this.i18n('crush rule'),
+ itemNames: [name],
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('crushRule/delete', { name: name }),
+ call: this.crushRuleService.delete(name)
+ })
+ }
+ });
+ }
+
+ crushRuleIsUsedBy(ruleName: string) {
+ this.crushUsage = ruleName ? this.info.used_rules[ruleName] : undefined;
+ }
+
submit() {
if (this.form.invalid) {
this.form.setErrors({ cdSubmitButton: true });
import { SharedModule } from '../../shared/shared.module';
import { BlockModule } from '../block/block.module';
import { CephSharedModule } from '../shared/ceph-shared.module';
+import { CrushRuleFormModalComponent } from './crush-rule-form-modal/crush-rule-form-modal.component';
import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form/erasure-code-profile-form.component';
import { PoolDetailsComponent } from './pool-details/pool-details.component';
import { PoolFormComponent } from './pool-form/pool-form.component';
PoolListComponent,
PoolFormComponent,
ErasureCodeProfileFormComponent,
+ CrushRuleFormModalComponent,
PoolDetailsComponent
],
- entryComponents: [ErasureCodeProfileFormComponent]
+ entryComponents: [CrushRuleFormModalComponent, ErasureCodeProfileFormComponent]
})
export class PoolModule {}
--- /dev/null
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper';
+import { CrushRuleService } from './crush-rule.service';
+
+describe('CrushRuleService', () => {
+ let service: CrushRuleService;
+ let httpTesting: HttpTestingController;
+ const apiPath = 'api/crush_rule';
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [CrushRuleService, i18nProviders]
+ });
+
+ beforeEach(() => {
+ service = TestBed.get(CrushRuleService);
+ httpTesting = TestBed.get(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call create', () => {
+ service.create({ root: 'default', name: 'someRule', failure_domain: 'osd' }).subscribe();
+ const req = httpTesting.expectOne(apiPath);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call delete', () => {
+ service.delete('test').subscribe();
+ const req = httpTesting.expectOne(`${apiPath}/test`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call getInfo', () => {
+ service.getInfo().subscribe();
+ const req = httpTesting.expectOne(`ui-${apiPath}/info`);
+ expect(req.request.method).toBe('GET');
+ });
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+
+import { CrushRuleConfig } from '../models/crush-rule';
+import { ApiModule } from './api.module';
+
+@Injectable({
+ providedIn: ApiModule
+})
+export class CrushRuleService {
+ apiPath = 'api/crush_rule';
+
+ formTooltips = {
+ // Copied from /doc/rados/operations/crush-map.rst
+ root: this.i18n(`The name of the node under which data should be placed.`),
+ failure_domain: this.i18n(`The type of CRUSH nodes across which we should separate replicas.`),
+ device_class: this.i18n(`The device class data should be placed on.`)
+ };
+
+ constructor(private http: HttpClient, private i18n: I18n) {}
+
+ create(rule: CrushRuleConfig) {
+ return this.http.post(this.apiPath, rule, { observe: 'response' });
+ }
+
+ delete(name: string) {
+ return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' });
+ }
+
+ getInfo() {
+ return this.http.get(`ui-${this.apiPath}/info`);
+ }
+}
--- /dev/null
+export class CrushNode {
+ id: number;
+ name: string;
+ type: string;
+ type_id: number;
+ // For nodes with leafs (Buckets)
+ children?: number[]; // Holds node id's of children
+ // For non root nodes
+ pool_weights?: object;
+ // For leafs (Devices)
+ device_class?: string;
+ crush_weight?: number;
+ exists?: number;
+ primary_affinity?: number;
+ reweight?: number;
+ status?: string;
+}
ruleset: number;
steps: CrushStep[];
}
+
+export class CrushRuleConfig {
+ root: string; // The name of the node under which data should be placed.
+ name: string;
+ failure_domain: string; // The type of CRUSH nodes across which we should separate replicas.
+ device_class?: string; // The device class data should be placed on.
+}
});
});
+ describe('crush rule tasks', () => {
+ beforeEach(() => {
+ const metadata = {
+ name: 'someRuleName'
+ };
+ defaultMsg = `crush rule '${metadata.name}'`;
+ finishedTask.metadata = metadata;
+ });
+
+ it('tests crushRule/create messages', () => {
+ finishedTask.name = 'crushRule/create';
+ testCreate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests crushRule/delete messages', () => {
+ finishedTask.name = 'crushRule/delete';
+ testDelete(defaultMsg);
+ });
+ });
+
describe('rbd tasks', () => {
let metadata: Record<string, any>;
let childMsg: string;
'ecp/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.ecp(metadata)
),
+ // Crush rule tasks
+ 'crushRule/create': this.newTaskMessage(
+ this.commonOperations.create,
+ (metadata) => this.crushRule(metadata),
+ (metadata) => ({
+ '17': this.i18n('Name is already used by {{name}}.', {
+ name: this.crushRule(metadata)
+ })
+ })
+ ),
+ 'crushRule/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.crushRule(metadata)
+ ),
// RBD tasks
'rbd/create': this.newTaskMessage(
this.commonOperations.create,
return this.i18n(`erasure code profile '{{name}}'`, { name: metadata.name });
}
+ crushRule(metadata: any) {
+ return this.i18n(`crush rule '{{name}}'`, { name: metadata.name });
+ }
+
iscsiTarget(metadata: any) {
return this.i18n(`target '{{target_iqn}}'`, { target_iqn: metadata.target_iqn });
}