From af75c4e0cdb62c98bf00185ebd3abf8cba7371af Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Wed, 6 May 2020 10:13:28 +0200 Subject: [PATCH] mgr/dashboard: Crush selection can handle any crush map MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit The crush selection class now provides a view static methods that can take a crush map in search for sub nodes of a node and provide a list of failure domains generated out of a list of nodes. Fixes: https://tracker.ceph.com/issues/44620 Signed-off-by: Stephan Müller --- .../crush.node.selection.class.spec.ts | 102 +++++++++++--- .../classes/crush.node.selection.class.ts | 127 ++++++++++++++---- 2 files changed, 190 insertions(+), 39 deletions(-) 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 index 59a26c5bd36..fd67cdec30c 100644 --- 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 @@ -1,11 +1,13 @@ 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: { @@ -22,15 +24,6 @@ describe('CrushNodeSelectionService', () => { // 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); @@ -44,6 +37,19 @@ describe('CrushNodeSelectionService', () => { 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]); + } + }); } }; @@ -77,23 +83,31 @@ describe('CrushNodeSelectionService', () => { }); 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'] + }); }); }); @@ -147,4 +161,60 @@ describe('CrushNodeSelectionService', () => { 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'] + } + ); + }); + }); }); 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 index 7cc11df163f..e1cf4b0ec3c 100644 --- 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 @@ -6,7 +6,7 @@ import { CrushNode } from '../models/crush-node'; export class CrushNodeSelectionClass { private nodes: CrushNode[] = []; - private easyNodes: { [id: number]: CrushNode } = {}; + private idTree: { [id: number]: CrushNode } = {}; private allDevices: string[] = []; private controls: { root: AbstractControl; @@ -20,6 +20,102 @@ export class CrushNodeSelectionClass { 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, @@ -27,8 +123,9 @@ export class CrushNodeSelectionClass { 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), @@ -56,14 +153,8 @@ export class CrushNodeSelectionClass { } 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]; @@ -74,18 +165,6 @@ export class CrushNodeSelectionClass { 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, @@ -119,7 +198,9 @@ export class CrushNodeSelectionClass { 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(); -- 2.39.5