]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Crush rule modal 33620/head
authorStephan Müller <smueller@suse.com>
Wed, 15 Jan 2020 13:39:13 +0000 (14:39 +0100)
committerStephan Müller <smueller@suse.com>
Mon, 9 Mar 2020 11:35:58 +0000 (12:35 +0100)
Now a crush rule can be created and deleted through the pool form,
similar to the ECP profile.

The creation form is somewhat more intelligent as it checks the crush
map to help create a usable rule, with only a few clicks
through preselections.

Fixes: https://tracker.ceph.com/issues/43260
Signed-off-by: Stephan Müller <smueller@suse.com>
16 files changed:
qa/tasks/mgr/dashboard/test_crush_rule.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/crush_rule.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

diff --git a/qa/tasks/mgr/dashboard/test_crush_rule.py b/qa/tasks/mgr/dashboard/test_crush_rule.py
new file mode 100644 (file)
index 0000000..a0bca63
--- /dev/null
@@ -0,0 +1,89 @@
+# -*- 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))
+        }))
+
diff --git a/src/pybind/mgr/dashboard/controllers/crush_rule.py b/src/pybind/mgr/dashboard/controllers/crush_rule.py
new file mode 100644 (file)
index 0000000..6a2ffd8
--- /dev/null
@@ -0,0 +1,46 @@
+# -*- 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']
+        }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html
new file mode 100644 (file)
index 0000000..c2e9938
--- /dev/null
@@ -0,0 +1,124 @@
+<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..84d0d26
--- /dev/null
@@ -0,0 +1,265 @@
+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'));
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts
new file mode 100644 (file)
index 0000000..58d7a45
--- /dev/null
@@ -0,0 +1,199 @@
+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);
+        }
+      );
+  }
+}
index 7e87dc8576803eb79702a782590d2ded612c8800..3ccfd2a024be6fe3e34d4b63cd4e7d8d7b23bb85 100644 (file)
                       <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
index d4f8fc16e4d888e6199b1f9a7d6dbef504063afd..8a9128ce1fb2168e71683e48e30d4ad5f45653c0 100644 (file)
@@ -5,9 +5,10 @@ import { By } from '@angular/platform-browser';
 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';
 
@@ -18,6 +19,7 @@ import {
   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';
@@ -43,6 +45,7 @@ describe('PoolFormComponent', () => {
   let form: CdFormGroup;
   let router: Router;
   let ecpService: ErasureCodeProfileService;
+  let crushRuleService: CrushRuleService;
 
   const setPgNum = (pgs: number): AbstractControl => {
     const control = formHelper.setValue('pgNum', pgs);
@@ -132,7 +135,8 @@ describe('PoolFormComponent', () => {
       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' })
@@ -140,8 +144,9 @@ describe('PoolFormComponent', () => {
       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']
+      }
     };
   };
 
@@ -183,6 +188,7 @@ describe('PoolFormComponent', () => {
     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();
@@ -305,6 +311,7 @@ describe('PoolFormComponent', () => {
 
     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');
     });
@@ -314,7 +321,6 @@ describe('PoolFormComponent', () => {
       setUpPoolComponent();
       formHelper.expectValidChange('poolType', 'replicated');
       formHelper.expectValid('crushRule');
-      formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds');
     });
 
     it('validates size', () => {
@@ -483,14 +489,14 @@ describe('PoolFormComponent', () => {
       });
 
       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"', () => {
@@ -783,13 +789,13 @@ describe('PoolFormComponent', () => {
   });
 
   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();
     });
 
@@ -806,6 +812,124 @@ describe('PoolFormComponent', () => {
       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', () => {
index 92bb9522db38c39c94c4b3718e50699d3e778cdc..25edf591d335abb83a201afd322da2296608c886 100644 (file)
@@ -5,8 +5,11 @@ import { ActivatedRoute, Router } from '@angular/router';
 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';
@@ -19,7 +22,7 @@ import {
   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';
@@ -29,6 +32,7 @@ import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
 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';
@@ -48,6 +52,9 @@ interface FormFieldDescription {
   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[];
@@ -70,6 +77,7 @@ export class PoolFormComponent implements OnInit {
   resource: string;
   icons = Icons;
   pgAutoscaleModes: string[];
+  crushUsage: string[] = undefined; // Will only be set if a rule is used by some pool
 
   private modalSubscription: Subscription;
 
@@ -84,6 +92,7 @@ export class PoolFormComponent implements OnInit {
     private bsModalService: BsModalService,
     private taskWrapper: TaskWrapperService,
     private ecpService: ErasureCodeProfileService,
+    private crushRuleService: CrushRuleService,
     private i18n: I18n,
     public actionLabels: ActionLabelsI18n
   ) {
@@ -196,13 +205,26 @@ export class PoolFormComponent implements OnInit {
     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();
     }
@@ -229,7 +251,7 @@ export class PoolFormComponent implements OnInit {
       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,
@@ -300,10 +322,17 @@ export class PoolFormComponent implements OnInit {
 
   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();
     });
@@ -328,7 +357,7 @@ export class PoolFormComponent implements OnInit {
     });
   }
 
-  private rulesChange(poolType: string) {
+  private poolTypeChange(poolType: string) {
     if (poolType === 'replicated') {
       this.setTypeBooleans(true, false);
     } else if (poolType === 'erasure') {
@@ -345,15 +374,8 @@ export class PoolFormComponent implements OnInit {
     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();
@@ -548,6 +570,67 @@ export class PoolFormComponent implements OnInit {
     });
   }
 
+  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 });
index dc47e3c646654370f0b23535a87c52de8e2f0dd1..ee768f8e60da558efd007bdd6f5e697d8083ae70 100644 (file)
@@ -13,6 +13,7 @@ import { ActionLabels, URLVerbs } from '../../shared/constants/app.constants';
 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';
@@ -37,9 +38,10 @@ import { PoolListComponent } from './pool-list/pool-list.component';
     PoolListComponent,
     PoolFormComponent,
     ErasureCodeProfileFormComponent,
+    CrushRuleFormModalComponent,
     PoolDetailsComponent
   ],
-  entryComponents: [ErasureCodeProfileFormComponent]
+  entryComponents: [CrushRuleFormModalComponent, ErasureCodeProfileFormComponent]
 })
 export class PoolModule {}
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts
new file mode 100644 (file)
index 0000000..4a20094
--- /dev/null
@@ -0,0 +1,47 @@
+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');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts
new file mode 100644 (file)
index 0000000..506fa23
--- /dev/null
@@ -0,0 +1,35 @@
+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`);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts
new file mode 100644 (file)
index 0000000..a8c8288
--- /dev/null
@@ -0,0 +1,17 @@
+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;
+}
index ef0508508ff0147e808cb09ffcb4e5cbdd973681..c7c6d56ca0b248396f12cf81c9c34374c84a9b57 100644 (file)
@@ -8,3 +8,10 @@ export class CrushRule {
   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.
+}
index a88bfbf14761cc4a9ede093a3f7567317b6dbaa0..5910dbf310ae0095868930b0650c7e5d003bb454 100644 (file)
@@ -126,6 +126,27 @@ describe('TaskManagerMessageService', () => {
       });
     });
 
+    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;
index 51d1ff06dfec148d27036c24c0a31c7d8e3d49b4..c2e44aa376aa519385e2ab8d7069340a01c2aec4 100644 (file)
@@ -201,6 +201,19 @@ export class TaskMessageService {
     '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,
@@ -429,6 +442,10 @@ export class TaskMessageService {
     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 });
   }