]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Crush node selection class
authorStephan Müller <smueller@suse.com>
Wed, 18 Mar 2020 14:57:05 +0000 (15:57 +0100)
committerStephan Müller <smueller@suse.com>
Tue, 28 Apr 2020 15:45:28 +0000 (17:45 +0200)
This class helps the erasure code profile modal and the crush rule modal
to determine which inputs can be made to create a decent rule or
profile.

Fixes: https://tracker.ceph.com/issues/44621
Signed-off-by: Stephan Müller <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts [new file with mode: 0644]

diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts
new file mode 100644 (file)
index 0000000..f9a675b
--- /dev/null
@@ -0,0 +1,207 @@
+import { FormControl } from '@angular/forms';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { CrushNode } from '../models/crush-node';
+import { CrushRuleConfig } from '../models/crush-rule';
+import { CrushNodeSelectionClass } from './crush.node.selection.class';
+
+describe('CrushNodeSelectionService', () => {
+  let service: CrushNodeSelectionClass;
+
+  let controls: {
+    root: FormControl;
+    failure: FormControl;
+    device: FormControl;
+  };
+
+  // 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
+    }),
+    nodes: [] as CrushNode[]
+  };
+
+  /**
+   * 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
+   */
+  mock.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')
+  ];
+
+  // Object contains functions to get something
+  const get = {
+    nodeByName: (name: string): CrushNode => mock.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 = service.failureDomainKeys;
+      expect(keys).toEqual(types);
+      keys.forEach((key) => {
+        expect(service.failureDomains[key].length).toBe(expectation[key].length);
+      });
+    },
+    formFieldValues: (root: CrushNode, failureDomain: string, device: string) => {
+      expect(controls.root.value).toEqual(root);
+      expect(controls.failure.value).toBe(failureDomain);
+      expect(controls.device.value).toBe(device);
+    },
+    valuesOnRootChange: (
+      rootName: string,
+      expectedFailureDomain: string,
+      expectedDevice: string
+    ) => {
+      const node = get.nodeByName(rootName);
+      controls.root.setValue(node);
+      assert.formFieldValues(node, expectedFailureDomain, expectedDevice);
+    }
+  };
+
+  configureTestBed({
+    providers: [CrushNodeSelectionClass]
+  });
+
+  beforeEach(() => {
+    controls = {
+      root: new FormControl(null),
+      failure: new FormControl(''),
+      device: new FormControl('')
+    };
+    // Normally this should be extended by the class using it
+    service = new CrushNodeSelectionClass();
+    // Therefore to get it working correctly use "this" instead of "service"
+    service.initCrushNodeSelection(mock.nodes, controls.root, controls.failure, controls.device);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+    expect(mock.nodes.length).toBe(12);
+  });
+
+  describe('lists', () => {
+    afterEach(() => {
+      // The available buckets should not change
+      expect(service.buckets).toEqual(
+        get.nodesByNames(['default', 'hdd-rack', 'mix-host', 'ssd-host', 'ssd-rack'])
+      );
+    });
+
+    it('has the following lists after init', () => {
+      assert.failureDomains(mock.nodes, ['host', 'osd', 'osd-rack', 'rack']); // Not root as root only exist once
+      expect(service.devices).toEqual(['hdd', 'ssd']);
+    });
+
+    it('has the following lists after selection of ssd-host', () => {
+      controls.root.setValue(get.nodeByName('ssd-host'));
+      assert.failureDomains(get.nodesByNames(['osd.0', 'osd.1', 'osd.2']), ['osd']); // Not host as it only exist once
+      expect(service.devices).toEqual(['ssd']);
+    });
+
+    it('has the following lists after selection of mix-host', () => {
+      controls.root.setValue(get.nodeByName('mix-host'));
+      expect(service.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', () => {
+      controls.failure.setValue('rack');
+      controls.failure.markAsDirty();
+      controls.device.setValue('ssd');
+      controls.device.markAsDirty();
+      assert.valuesOnRootChange('mix-host', 'rack', 'ssd');
+    });
+
+    it('should preselect device by domain selection', () => {
+      controls.failure.setValue('osd');
+      assert.formFieldValues(get.nodeByName('default'), 'osd', 'ssd');
+    });
+  });
+
+  describe('get available OSDs count', () => {
+    it('should have 4 available OSDs with the default selection', () => {
+      expect(service.deviceCount).toBe(4);
+    });
+
+    it('should reduce available OSDs to 2 if a device type is set', () => {
+      controls.device.setValue('ssd');
+      controls.device.markAsDirty();
+      expect(service.deviceCount).toBe(2);
+    });
+
+    it('should show 3 OSDs when selecting "ssd-host"', () => {
+      assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+      expect(service.deviceCount).toBe(3);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts
new file mode 100644 (file)
index 0000000..7cc11df
--- /dev/null
@@ -0,0 +1,140 @@
+import { AbstractControl } from '@angular/forms';
+
+import * as _ from 'lodash';
+
+import { CrushNode } from '../models/crush-node';
+
+export class CrushNodeSelectionClass {
+  private nodes: CrushNode[] = [];
+  private easyNodes: { [id: number]: CrushNode } = {};
+  private allDevices: string[] = [];
+  private controls: {
+    root: AbstractControl;
+    failure: AbstractControl;
+    device: AbstractControl;
+  };
+
+  buckets: CrushNode[] = [];
+  failureDomains: { [type: string]: CrushNode[] } = {};
+  failureDomainKeys: string[] = [];
+  devices: string[] = [];
+  deviceCount = 0;
+
+  initCrushNodeSelection(
+    nodes: CrushNode[],
+    rootControl: AbstractControl,
+    failureControl: AbstractControl,
+    deviceControl: AbstractControl
+  ) {
+    this.nodes = nodes;
+    nodes.forEach((node) => {
+      this.easyNodes[node.id] = node;
+    });
+    this.buckets = _.sortBy(
+      nodes.filter((n) => n.children),
+      'name'
+    );
+    this.controls = {
+      root: rootControl,
+      failure: failureControl,
+      device: deviceControl
+    };
+    this.preSelectRoot();
+    this.controls.root.valueChanges.subscribe(() => this.onRootChange());
+    this.controls.failure.valueChanges.subscribe(() => this.onFailureDomainChange());
+    this.controls.device.valueChanges.subscribe(() => this.onDeviceChange());
+  }
+
+  private preSelectRoot() {
+    const rootNode = this.nodes.find((node) => node.type === 'root');
+    this.silentSet(this.controls.root, rootNode);
+    this.onRootChange();
+  }
+
+  private silentSet(control: AbstractControl, value: any) {
+    control.setValue(value, { emitEvent: false });
+  }
+
+  private onRootChange() {
+    const nodes = this.getSubNodes(this.controls.root.value);
+    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.failureDomainKeys = Object.keys(domains).sort();
+    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(
+      this.controls.failure,
+      Object.keys(this.failureDomains)
+    );
+    if (failureDomain === '') {
+      failureDomain = this.setMostCommonDomain(this.controls.failure);
+    }
+    this.updateDevices(failureDomain);
+  }
+
+  private getIncludedCustomValue(control: AbstractControl, includedIn: string[]) {
+    return control.dirty && includedIn.includes(control.value) ? control.value : '';
+  }
+
+  private setMostCommonDomain(failureControl: AbstractControl): 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.silentSet(failureControl, winner.type);
+    return winner.type;
+  }
+
+  private onFailureDomainChange() {
+    this.updateDevices();
+  }
+
+  private updateDevices(failureDomain: string = this.controls.failure.value) {
+    const subNodes = _.flatten(
+      this.failureDomains[failureDomain].map((node) => this.getSubNodes(node))
+    );
+    this.allDevices = subNodes.filter((n) => n.device_class).map((n) => n.device_class);
+    this.devices = _.uniq(this.allDevices).sort();
+    const device =
+      this.devices.length === 1
+        ? this.devices[0]
+        : this.getIncludedCustomValue(this.controls.device, this.devices);
+    this.silentSet(this.controls.device, device);
+    this.onDeviceChange(device);
+  }
+
+  private onDeviceChange(deviceType: string = this.controls.device.value) {
+    this.deviceCount =
+      deviceType === ''
+        ? this.allDevices.length
+        : this.allDevices.filter((type) => type === deviceType).length;
+  }
+}