From: Stephan Müller Date: Wed, 18 Mar 2020 14:57:05 +0000 (+0100) Subject: mgr/dashboard: Crush node selection class X-Git-Tag: v17.0.0~2471^2~3 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=59673eb4eecc7144c4e9ec56a765b5eb32235d0a;p=ceph.git mgr/dashboard: Crush node selection class 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 --- 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 index 0000000000000..f9a675b48bf48 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts @@ -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 index 0000000000000..7cc11df163fe3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts @@ -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; + } +}