import { FormControl } from '@angular/forms';
+import * as _ from 'lodash';
+
import { configureTestBed, Mocks } from '../../../testing/unit-test-helper';
import { CrushNode } from '../models/crush-node';
import { CrushNodeSelectionClass } from './crush.node.selection.class';
describe('CrushNodeSelectionService', () => {
- const nodes = Mocks.getCrushNodes()
+ const nodes = Mocks.getCrushMap();
let service: CrushNodeSelectionClass;
let controls: {
// 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);
const node = get.nodeByName(rootName);
controls.root.setValue(node);
assert.formFieldValues(node, expectedFailureDomain, expectedDevice);
+ },
+ failureDomainNodes: (
+ failureDomains: { [failureDomain: string]: CrushNode[] },
+ expected: { [failureDomains: string]: string[] | CrushNode[] }
+ ) => {
+ expect(Object.keys(failureDomains)).toEqual(Object.keys(expected));
+ Object.keys(failureDomains).forEach((key) => {
+ if (_.isString(expected[key][0])) {
+ expect(failureDomains[key]).toEqual(get.nodesByNames(expected[key] as string[]));
+ } else {
+ expect(failureDomains[key]).toEqual(expected[key]);
+ }
+ });
}
};
});
it('has the following lists after init', () => {
- assert.failureDomains(nodes, ['host', 'osd', 'osd-rack', 'rack']); // Not root as root only exist once
+ assert.failureDomainNodes(service.failureDomains, {
+ host: ['ssd-host', 'mix-host'],
+ osd: ['osd.1', 'osd.0', 'osd.2'],
+ rack: ['hdd-rack', 'ssd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1']
+ });
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
+ assert.failureDomainNodes(service.failureDomains, {
+ // Not host as it only exist once
+ osd: ['osd.1', 'osd.0', 'osd.2']
+ });
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', 'osd3.0', 'osd3.1']),
- ['osd-rack', 'rack']
- );
+ assert.failureDomainNodes(service.failureDomains, {
+ rack: ['hdd-rack', 'ssd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1']
+ });
});
});
expect(service.deviceCount).toBe(3);
});
});
+
+ describe('search tree', () => {
+ it('returns the following list after searching for mix-host', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host');
+ expect(subNodes).toEqual(
+ get.nodesByNames([
+ 'mix-host',
+ 'hdd-rack',
+ 'osd2.0',
+ 'osd2.1',
+ 'ssd-rack',
+ 'osd3.0',
+ 'osd3.1'
+ ])
+ );
+ });
+
+ it('returns the following list after searching for mix-host with SSDs', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host~ssd');
+ expect(subNodes.map((n) => n.name)).toEqual(['mix-host', 'ssd-rack', 'osd3.0', 'osd3.1']);
+ });
+
+ it('returns an empty array if node can not be found', () => {
+ expect(CrushNodeSelectionClass.search(nodes, 'not-there')).toEqual([]);
+ });
+
+ it('returns the following list after searching for mix-host failure domains', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host');
+ assert.failureDomainNodes(CrushNodeSelectionClass.getFailureDomains(subNodes), {
+ host: ['mix-host'],
+ rack: ['hdd-rack', 'ssd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1']
+ });
+ });
+
+ it('returns the following list after searching for mix-host failure domains for a specific type', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host~hdd');
+ const hddHost = _.cloneDeep(get.nodesByNames(['mix-host'])[0]);
+ hddHost.children = [-4];
+ assert.failureDomainNodes(CrushNodeSelectionClass.getFailureDomains(subNodes), {
+ host: [hddHost],
+ rack: ['hdd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1']
+ });
+ const ssdHost = _.cloneDeep(get.nodesByNames(['mix-host'])[0]);
+ ssdHost.children = [-5];
+ assert.failureDomainNodes(
+ CrushNodeSelectionClass.searchFailureDomains(nodes, 'mix-host~ssd'),
+ {
+ host: [ssdHost],
+ rack: ['ssd-rack'],
+ 'osd-rack': ['osd3.0', 'osd3.1']
+ }
+ );
+ });
+ });
});
export class CrushNodeSelectionClass {
private nodes: CrushNode[] = [];
- private easyNodes: { [id: number]: CrushNode } = {};
+ private idTree: { [id: number]: CrushNode } = {};
private allDevices: string[] = [];
private controls: {
root: AbstractControl;
devices: string[] = [];
deviceCount = 0;
+ static searchFailureDomains(
+ nodes: CrushNode[],
+ s: string
+ ): { [failureDomain: string]: CrushNode[] } {
+ return this.getFailureDomains(this.search(nodes, s));
+ }
+
+ /**
+ * Filters crush map for a node and it's tree.
+ * The node name as provided in crush rules attribute item_name is supported.
+ * This means that '$name~$deviceType' can be used and will result in a crush map
+ * that only include buckets with the specified device in use as their leaf.
+ */
+ static search(nodes: CrushNode[], s: string): CrushNode[] {
+ const [search, deviceType] = s.split('~'); // Used inside item_name in crush rules
+ const node = nodes.find((n) => ['name', 'id', 'type'].some((attr) => n[attr] === search));
+ if (!node) {
+ return [];
+ }
+ nodes = this.getSubNodes(node, this.createIdTreeFromNodes(nodes));
+ if (deviceType) {
+ nodes = this.filterNodesByDeviceType(nodes, deviceType);
+ }
+ return nodes;
+ }
+
+ static createIdTreeFromNodes(nodes: CrushNode[]): { [id: number]: CrushNode } {
+ const idTree = {};
+ nodes.forEach((node) => {
+ idTree[node.id] = node;
+ });
+ return idTree;
+ }
+
+ static getSubNodes(node: CrushNode, idTree: { [id: number]: CrushNode }): CrushNode[] {
+ let subNodes = [node]; // Includes parent node
+ if (!node.children) {
+ return subNodes;
+ }
+ node.children.forEach((id) => {
+ const childNode = idTree[id];
+ subNodes = subNodes.concat(this.getSubNodes(childNode, idTree));
+ });
+ return subNodes;
+ }
+
+ static filterNodesByDeviceType(nodes: CrushNode[], deviceType: string): any {
+ let doNotInclude = nodes
+ .filter((n) => n.device_class && n.device_class !== deviceType)
+ .map((n) => n.id);
+ let foundNewNode: boolean;
+ let childrenToRemove = doNotInclude;
+
+ // Filters out all unwanted nodes
+ do {
+ foundNewNode = false;
+ nodes = nodes.filter((n) => !doNotInclude.includes(n.id)); // Unwanted nodes
+ // Find nodes where all children were filtered
+ const toRemoveNext: number[] = [];
+ nodes.forEach((n) => {
+ if (n.children && n.children.every((id) => doNotInclude.includes(id))) {
+ toRemoveNext.push(n.id);
+ foundNewNode = true;
+ }
+ });
+ if (foundNewNode) {
+ doNotInclude = toRemoveNext; // Reduces array length
+ childrenToRemove = childrenToRemove.concat(toRemoveNext);
+ }
+ } while (foundNewNode);
+
+ // Removes filtered out children in all left nodes with children
+ nodes = _.cloneDeep(nodes); // Clone objects to not change original objects
+ nodes = nodes.map((n) => {
+ if (!n.children) {
+ return n;
+ }
+ n.children = n.children.filter((id) => !childrenToRemove.includes(id));
+ return n;
+ });
+
+ return nodes;
+ }
+
+ static getFailureDomains(nodes: CrushNode[]): { [failureDomain: string]: CrushNode[] } {
+ const domains = {};
+ nodes.forEach((node) => {
+ const type = node.type;
+ if (!domains[type]) {
+ domains[type] = [];
+ }
+ domains[type].push(node);
+ });
+ return domains;
+ }
+
initCrushNodeSelection(
nodes: CrushNode[],
rootControl: AbstractControl,
deviceControl: AbstractControl
) {
this.nodes = nodes;
+ this.idTree = CrushNodeSelectionClass.createIdTreeFromNodes(nodes);
nodes.forEach((node) => {
- this.easyNodes[node.id] = node;
+ this.idTree[node.id] = node;
});
this.buckets = _.sortBy(
nodes.filter((n) => n.children),
}
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);
- });
+ const nodes = CrushNodeSelectionClass.getSubNodes(this.controls.root.value, this.idTree);
+ const domains = CrushNodeSelectionClass.getFailureDomains(nodes);
Object.keys(domains).forEach((type) => {
if (domains[type].length <= 1) {
delete domains[type];
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,
private updateDevices(failureDomain: string = this.controls.failure.value) {
const subNodes = _.flatten(
- this.failureDomains[failureDomain].map((node) => this.getSubNodes(node))
+ this.failureDomains[failureDomain].map((node) =>
+ CrushNodeSelectionClass.getSubNodes(node, this.idTree)
+ )
);
this.allDevices = subNodes.filter((n) => n.device_class).map((n) => n.device_class);
this.devices = _.uniq(this.allDevices).sort();