From: Tatjana Dehler Date: Mon, 27 Jul 2020 09:33:19 +0000 (+0200) Subject: mgr/dashboard: assign flags to single OSDs X-Git-Tag: v15.2.9~107^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=18dab75126b5bb4354a7178f77a12b6c837426a5;p=ceph.git mgr/dashboard: assign flags to single OSDs Add the possibility to assign the flags ['noup', 'nodown', 'noin', 'noout'] to single OSDs. Fixes: https://tracker.ceph.com/issues/40739 Signed-off-by: Tatjana Dehler (cherry picked from commit 3639332f34344f97ce5bd88d2fb17a659ca281a2) Conflicts: src/pybind/mgr/dashboard/controllers/osd.py src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts src/pybind/mgr/dashboard/openapi.yaml Fixed conflicts because of missing octopus backports. --- diff --git a/qa/suites/rados/dashboard/tasks/dashboard.yaml b/qa/suites/rados/dashboard/tasks/dashboard.yaml index f210fc1c86df..27f466ebdb7d 100644 --- a/qa/suites/rados/dashboard/tasks/dashboard.yaml +++ b/qa/suites/rados/dashboard/tasks/dashboard.yaml @@ -22,6 +22,7 @@ tasks: - \(OSD_HOST_DOWN\) - \(POOL_APP_NOT_ENABLED\) - \(OSDMAP_FLAGS\) + - \(OSD_FLAGS\) - pauserd,pausewr flag\(s\) set - Monitor daemon marked osd\.[[:digit:]]+ down, but it is still running - evicting unresponsive client .+ diff --git a/qa/tasks/mgr/dashboard/test_osd.py b/qa/tasks/mgr/dashboard/test_osd.py index 0bd3f93f3249..99389527b6d0 100644 --- a/qa/tasks/mgr/dashboard/test_osd.py +++ b/qa/tasks/mgr/dashboard/test_osd.py @@ -231,36 +231,139 @@ class OsdTest(DashboardTestCase): class OsdFlagsTest(DashboardTestCase): def __init__(self, *args, **kwargs): super(OsdFlagsTest, self).__init__(*args, **kwargs) - self._initial_flags = sorted( # These flags cannot be unset - ['sortbitwise', 'recovery_deletes', 'purged_snapdirs', - 'pglog_hardlimit']) + self._initial_flags = ['sortbitwise', 'recovery_deletes', 'purged_snapdirs', + 'pglog_hardlimit'] # These flags cannot be unset @classmethod - def _get_cluster_osd_flags(cls): - return sorted( - json.loads(cls._ceph_cmd(['osd', 'dump', - '--format=json']))['flags_set']) + def _put_flags(cls, flags, ids=None): + url = '/api/osd/flags' + data = {'flags': flags} - @classmethod - def _put_flags(cls, flags): - cls._put('/api/osd/flags', data={'flags': flags}) - return sorted(cls._resp.json()) + if ids: + url = url + '/individual' + data['ids'] = ids + + cls._put(url, data=data) + return cls._resp.json() def test_list_osd_flags(self): flags = self._get('/api/osd/flags') self.assertStatus(200) self.assertEqual(len(flags), 4) - self.assertEqual(sorted(flags), self._initial_flags) + self.assertCountEqual(flags, self._initial_flags) def test_add_osd_flag(self): flags = self._put_flags([ 'sortbitwise', 'recovery_deletes', 'purged_snapdirs', 'noout', 'pause', 'pglog_hardlimit' ]) - self.assertEqual(flags, sorted([ + self.assertCountEqual(flags, [ 'sortbitwise', 'recovery_deletes', 'purged_snapdirs', 'noout', 'pause', 'pglog_hardlimit' - ])) + ]) # Restore flags self._put_flags(self._initial_flags) + + def test_get_indiv_flag(self): + initial = self._get('/api/osd/flags/individual') + self.assertStatus(200) + self.assertSchema(initial, JList(JObj({ + 'osd': int, + 'flags': JList(str) + }))) + + self._ceph_cmd(['osd', 'set-group', 'noout,noin', 'osd.0', 'osd.1', 'osd.2']) + flags_added = self._get('/api/osd/flags/individual') + self.assertStatus(200) + for osd in flags_added: + if osd['osd'] in [0, 1, 2]: + self.assertIn('noout', osd['flags']) + self.assertIn('noin', osd['flags']) + for osd_initial in initial: + if osd['osd'] == osd_initial['osd']: + self.assertGreater(len(osd['flags']), len(osd_initial['flags'])) + + self._ceph_cmd(['osd', 'unset-group', 'noout,noin', 'osd.0', 'osd.1', 'osd.2']) + flags_removed = self._get('/api/osd/flags/individual') + self.assertStatus(200) + for osd in flags_removed: + if osd['osd'] in [0, 1, 2]: + self.assertNotIn('noout', osd['flags']) + self.assertNotIn('noin', osd['flags']) + + def test_add_indiv_flag(self): + flags_update = {'noup': None, 'nodown': None, 'noin': None, 'noout': True} + svc_id = 0 + + resp = self._put_flags(flags_update, [svc_id]) + self._check_indiv_flags_resp(resp, [svc_id], ['noout'], [], ['noup', 'nodown', 'noin']) + self._check_indiv_flags_osd([svc_id], ['noout'], ['noup', 'nodown', 'noin']) + + self._ceph_cmd(['osd', 'unset-group', 'noout', 'osd.{}'.format(svc_id)]) + + def test_add_multiple_indiv_flags(self): + flags_update = {'noup': None, 'nodown': None, 'noin': True, 'noout': True} + svc_id = 0 + + resp = self._put_flags(flags_update, [svc_id]) + self._check_indiv_flags_resp(resp, [svc_id], ['noout', 'noin'], [], ['noup', 'nodown']) + self._check_indiv_flags_osd([svc_id], ['noout', 'noin'], ['noup', 'nodown']) + + self._ceph_cmd(['osd', 'unset-group', 'noout,noin', 'osd.{}'.format(svc_id)]) + + def test_add_multiple_indiv_flags_multiple_osds(self): + flags_update = {'noup': None, 'nodown': None, 'noin': True, 'noout': True} + svc_id = [0, 1, 2] + + resp = self._put_flags(flags_update, svc_id) + self._check_indiv_flags_resp(resp, svc_id, ['noout', 'noin'], [], ['noup', 'nodown']) + self._check_indiv_flags_osd([svc_id], ['noout', 'noin'], ['noup', 'nodown']) + + self._ceph_cmd(['osd', 'unset-group', 'noout,noin', 'osd.0', 'osd.1', 'osd.2']) + + def test_remove_indiv_flag(self): + flags_update = {'noup': None, 'nodown': None, 'noin': None, 'noout': False} + svc_id = 0 + self._ceph_cmd(['osd', 'set-group', 'noout', 'osd.{}'.format(svc_id)]) + + resp = self._put_flags(flags_update, [svc_id]) + self._check_indiv_flags_resp(resp, [svc_id], [], ['noout'], ['noup', 'nodown', 'noin']) + self._check_indiv_flags_osd([svc_id], [], ['noup', 'nodown', 'noin', 'noout']) + + def test_remove_multiple_indiv_flags(self): + flags_update = {'noup': None, 'nodown': None, 'noin': False, 'noout': False} + svc_id = 0 + self._ceph_cmd(['osd', 'set-group', 'noout,noin', 'osd.{}'.format(svc_id)]) + + resp = self._put_flags(flags_update, [svc_id]) + self._check_indiv_flags_resp(resp, [svc_id], [], ['noout', 'noin'], ['noup', 'nodown']) + self._check_indiv_flags_osd([svc_id], [], ['noout', 'noin', 'noup', 'nodown']) + + def test_remove_multiple_indiv_flags_multiple_osds(self): + flags_update = {'noup': None, 'nodown': None, 'noin': False, 'noout': False} + svc_id = [0, 1, 2] + self._ceph_cmd(['osd', 'unset-group', 'noout,noin', 'osd.0', 'osd.1', 'osd.2']) + + resp = self._put_flags(flags_update, svc_id) + self._check_indiv_flags_resp(resp, svc_id, [], ['noout', 'noin'], ['noup', 'nodown']) + self._check_indiv_flags_osd([svc_id], [], ['noout', 'noin', 'noup', 'nodown']) + + def _check_indiv_flags_resp(self, resp, ids, added, removed, ignored): + self.assertStatus(200) + self.assertCountEqual(resp['ids'], ids) + self.assertCountEqual(resp['added'], added) + self.assertCountEqual(resp['removed'], removed) + + for flag in ignored: + self.assertNotIn(flag, resp['added']) + self.assertNotIn(flag, resp['removed']) + + def _check_indiv_flags_osd(self, ids, activated_flags, deactivated_flags): + osds = json.loads(self._ceph_cmd(['osd', 'dump', '--format=json']))['osds'] + for osd in osds: + if osd['osd'] in ids: + for flag in activated_flags: + self.assertIn(flag, osd['state']) + for flag in deactivated_flags: + self.assertNotIn(flag, osd['state']) diff --git a/src/pybind/mgr/dashboard/controllers/osd.py b/src/pybind/mgr/dashboard/controllers/osd.py index e29a056a167f..6c1cdd1ade39 100644 --- a/src/pybind/mgr/dashboard/controllers/osd.py +++ b/src/pybind/mgr/dashboard/controllers/osd.py @@ -367,6 +367,17 @@ class OsdFlagsController(RESTController): set(enabled_flags) - {'pauserd', 'pausewr'} | {'pause'}) return sorted(enabled_flags) + @staticmethod + def _update_flags(action, flags, ids=None): + if ids: + if flags: + ids = list(map(str, ids)) + CephService.send_command('mon', 'osd ' + action, who=ids, + flags=','.join(flags)) + else: + for flag in flags: + CephService.send_command('mon', 'osd ' + action, '', key=flag) + def list(self): return self._osd_flags() @@ -382,10 +393,56 @@ class OsdFlagsController(RESTController): data = set(flags) added = data - enabled_flags removed = enabled_flags - data - for flag in added: - CephService.send_command('mon', 'osd set', '', key=flag) - for flag in removed: - CephService.send_command('mon', 'osd unset', '', key=flag) + + self._update_flags('set', added) + self._update_flags('unset', removed) + logger.info('Changed OSD flags: added=%s removed=%s', added, removed) return sorted(enabled_flags - removed | added) + + @Endpoint('PUT', 'individual') + @UpdatePermission + def set_individual(self, flags, ids): + """ + Updates flags (`noout`, `noin`, `nodown`, `noup`) for an individual + subset of OSDs. + """ + assert isinstance(flags, dict) + assert isinstance(ids, list) + assert all(isinstance(id, int) for id in ids) + + # These are to only flags that can be applied to an OSD individually. + all_flags = {'noin', 'noout', 'nodown', 'noup'} + added = set() + removed = set() + for flag, activated in flags.items(): + if flag in all_flags: + if activated is not None: + if activated: + added.add(flag) + else: + removed.add(flag) + + self._update_flags('set-group', added, ids) + self._update_flags('unset-group', removed, ids) + + logger.error('Changed individual OSD flags: added=%s removed=%s for ids=%s', + added, removed, ids) + + return {'added': sorted(added), + 'removed': sorted(removed), + 'ids': ids} + + @Endpoint('GET', 'individual') + @ReadPermission + def get_individual(self): + osd_map = mgr.get('osd_map')['osds'] + resp = [] + + for osd in osd_map: + resp.append({ + 'osd': osd['osd'], + 'flags': osd['state'] + }) + return resp diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index 5eb59c9e7f54..141af9d8bf2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -34,6 +34,7 @@ import { OsdCreationPreviewModalComponent } from './osd/osd-creation-preview-mod import { OsdDetailsComponent } from './osd/osd-details/osd-details.component'; import { OsdDevicesSelectionGroupsComponent } from './osd/osd-devices-selection-groups/osd-devices-selection-groups.component'; import { OsdDevicesSelectionModalComponent } from './osd/osd-devices-selection-modal/osd-devices-selection-modal.component'; +import { OsdFlagsIndivModalComponent } from './osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component'; import { OsdFlagsModalComponent } from './osd/osd-flags-modal/osd-flags-modal.component'; import { OsdFormComponent } from './osd/osd-form/osd-form.component'; import { OsdListComponent } from './osd/osd-list/osd-list.component'; @@ -58,6 +59,7 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; OsdDetailsComponent, OsdScrubModalComponent, OsdFlagsModalComponent, + OsdFlagsIndivModalComponent, OsdRecvSpeedModalComponent, OsdReweightModalComponent, OsdPgScrubModalComponent, @@ -124,7 +126,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; HostFormComponent, ServiceDetailsComponent, ServiceDaemonListComponent, - TelemetryComponent + TelemetryComponent, + OsdFlagsIndivModalComponent ] }) export class ClusterModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html new file mode 100644 index 000000000000..349f15a906db --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html @@ -0,0 +1,52 @@ + + Individual OSD Flags + + +
+ + + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts new file mode 100644 index 000000000000..3b3bd07f6fe3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts @@ -0,0 +1,353 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BsModalRef, ModalModule } from 'ngx-bootstrap/modal'; +import { ToastrModule } from 'ngx-toastr'; +import { of as observableOf } from 'rxjs'; + +import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; +import { OsdService } from '../../../../shared/api/osd.service'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { Flag } from '../../../../shared/models/flag'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { SharedModule } from '../../../../shared/shared.module'; +import { OsdFlagsIndivModalComponent } from './osd-flags-indiv-modal.component'; + +describe('OsdFlagsIndivModalComponent', () => { + let component: OsdFlagsIndivModalComponent; + let fixture: ComponentFixture; + let httpTesting: HttpTestingController; + let osdService: OsdService; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + ReactiveFormsModule, + SharedModule, + ToastrModule.forRoot(), + ModalModule.forRoot(), + RouterTestingModule + ], + declarations: [OsdFlagsIndivModalComponent], + providers: [BsModalRef, i18nProviders] + }); + + beforeEach(() => { + httpTesting = TestBed.get(HttpTestingController); + fixture = TestBed.createComponent(OsdFlagsIndivModalComponent); + component = fixture.componentInstance; + osdService = TestBed.get(OsdService); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('getActivatedIndivFlags', () => { + function checkFlagsCount( + counts: { [key: string]: number }, + expected: { [key: string]: number } + ) { + Object.entries(expected).forEach(([expectedKey, expectedValue]) => { + expect(counts[expectedKey]).toBe(expectedValue); + }); + } + + it('should count correctly if no flag has been set', () => { + component.selected = generateSelected(); + const countedFlags = component.getActivatedIndivFlags(); + checkFlagsCount(countedFlags, { noup: 0, nodown: 0, noin: 0, noout: 0 }); + }); + + it('should count correctly if some of the flags have been set', () => { + component.selected = generateSelected([['noin'], ['noin', 'noout'], ['nodown']]); + const countedFlags = component.getActivatedIndivFlags(); + checkFlagsCount(countedFlags, { noup: 0, nodown: 1, noin: 2, noout: 1 }); + }); + }); + + describe('changeValue', () => { + it('should change value correctly and set indeterminate to false', () => { + const testFlag = component.flags[0]; + const value = testFlag.value; + component.changeValue(testFlag); + expect(testFlag.value).toBe(!value); + expect(testFlag.indeterminate).toBeFalsy(); + }); + }); + + describe('resetSelection', () => { + it('should set a new flags object by deep cloning the initial selection', () => { + component.resetSelection(); + expect(component.flags === component.initialSelection).toBeFalsy(); + }); + }); + + describe('OSD single-select', () => { + beforeEach(() => { + component.selected = [{ osd: 0 }]; + }); + + describe('ngOnInit', () => { + it('should clone flags as initial selection', () => { + expect(component.flags === component.initialSelection).toBeFalsy(); + }); + + it('should initialize form correctly if no individual and global flags are set', () => { + component.selected[0]['state'] = ['exists', 'up']; + spyOn(osdService, 'getFlags').and.callFake(() => observableOf([])); + fixture.detectChanges(); + checkFlags(component.flags); + }); + + it('should initialize form correctly if individual but no global flags are set', () => { + component.selected[0]['state'] = ['exists', 'noout', 'up']; + spyOn(osdService, 'getFlags').and.callFake(() => observableOf([])); + fixture.detectChanges(); + const expected = { + noout: { value: true, clusterWide: false, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + + it('should initialize form correctly if multiple individual but no global flags are set', () => { + component.selected[0]['state'] = ['exists', 'noin', 'noout', 'up']; + spyOn(osdService, 'getFlags').and.callFake(() => observableOf([])); + fixture.detectChanges(); + const expected = { + noout: { value: true, clusterWide: false, indeterminate: false }, + noin: { value: true, clusterWide: false, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + + it('should initialize form correctly if no individual but global flags are set', () => { + component.selected[0]['state'] = ['exists', 'up']; + spyOn(osdService, 'getFlags').and.callFake(() => observableOf(['noout'])); + fixture.detectChanges(); + const expected = { + noout: { value: false, clusterWide: true, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + }); + + describe('submitAction', () => { + let notificationType: NotificationType; + let notificationService: NotificationService; + let bsModalRef: BsModalRef; + let flags: object; + + beforeEach(() => { + notificationService = TestBed.get(NotificationService); + spyOn(notificationService, 'show').and.callFake((type) => { + notificationType = type; + }); + bsModalRef = TestBed.get(BsModalRef); + spyOn(bsModalRef, 'hide').and.callThrough(); + flags = { + nodown: false, + noin: false, + noout: false, + noup: false + }; + }); + + it('should submit an activated flag', () => { + const code = component.flags[0].code; + component.flags[0].value = true; + component.submitAction(); + flags[code] = true; + + const req = httpTesting.expectOne('api/osd/flags/individual'); + req.flush({ flags, ids: [0] }); + expect(req.request.body).toEqual({ flags, ids: [0] }); + expect(notificationType).toBe(NotificationType.success); + expect(component.activeModal.hide).toHaveBeenCalledTimes(1); + }); + + it('should submit multiple flags', () => { + const codes = [component.flags[0].code, component.flags[1].code]; + component.flags[0].value = true; + component.flags[1].value = true; + component.submitAction(); + flags[codes[0]] = true; + flags[codes[1]] = true; + + const req = httpTesting.expectOne('api/osd/flags/individual'); + req.flush({ flags, ids: [0] }); + expect(req.request.body).toEqual({ flags, ids: [0] }); + expect(notificationType).toBe(NotificationType.success); + expect(component.activeModal.hide).toHaveBeenCalledTimes(1); + }); + + it('should hide modal if request fails', () => { + component.flags = []; + component.submitAction(); + const req = httpTesting.expectOne('api/osd/flags/individual'); + req.flush([], { status: 500, statusText: 'failure' }); + expect(notificationService.show).toHaveBeenCalledTimes(0); + expect(component.activeModal.hide).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('OSD multi-select', () => { + describe('ngOnInit', () => { + it('should initialize form correctly if same individual and no global flags are set', () => { + component.selected = generateSelected([['noin'], ['noin'], ['noin']]); + spyOn(osdService, 'getFlags').and.callFake(() => observableOf([])); + fixture.detectChanges(); + const expected = { + noin: { value: true, clusterWide: false, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + + it('should initialize form correctly if different individual and no global flags are set', () => { + component.selected = generateSelected([['noin'], ['noout'], ['noin']]); + spyOn(osdService, 'getFlags').and.callFake(() => observableOf([])); + fixture.detectChanges(); + const expected = { + noin: { value: false, clusterWide: false, indeterminate: true }, + noout: { value: false, clusterWide: false, indeterminate: true } + }; + checkFlags(component.flags, expected); + }); + + it('should initialize form correctly if different and same individual and no global flags are set', () => { + component.selected = generateSelected([ + ['noin', 'nodown'], + ['noout', 'nodown'], + ['noin', 'nodown'] + ]); + spyOn(osdService, 'getFlags').and.callFake(() => observableOf([])); + fixture.detectChanges(); + const expected = { + noin: { value: false, clusterWide: false, indeterminate: true }, + noout: { value: false, clusterWide: false, indeterminate: true }, + nodown: { value: true, clusterWide: false, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + + it('should initialize form correctly if a flag is set for all OSDs individually and globally', () => { + component.selected = generateSelected([ + ['noin', 'nodown'], + ['noout', 'nodown'], + ['noin', 'nodown'] + ]); + spyOn(osdService, 'getFlags').and.callFake(() => observableOf(['noout'])); + fixture.detectChanges(); + const expected = { + noin: { value: false, clusterWide: false, indeterminate: true }, + noout: { value: false, clusterWide: true, indeterminate: true }, + nodown: { value: true, clusterWide: false, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + + it('should initialize form correctly if different individual and global flags are set', () => { + component.selected = generateSelected([ + ['noin', 'nodown', 'noout'], + ['noout', 'nodown'], + ['noin', 'nodown', 'noout'] + ]); + spyOn(osdService, 'getFlags').and.callFake(() => observableOf(['noout'])); + fixture.detectChanges(); + const expected = { + noin: { value: false, clusterWide: false, indeterminate: true }, + noout: { value: true, clusterWide: true, indeterminate: false }, + nodown: { value: true, clusterWide: false, indeterminate: false } + }; + checkFlags(component.flags, expected); + }); + }); + + describe('submitAction', () => { + let notificationType: NotificationType; + let notificationService: NotificationService; + let bsModalRef: BsModalRef; + let flags: object; + + beforeEach(() => { + notificationService = TestBed.get(NotificationService); + spyOn(notificationService, 'show').and.callFake((type) => { + notificationType = type; + }); + bsModalRef = TestBed.get(BsModalRef); + spyOn(bsModalRef, 'hide').and.callThrough(); + flags = { + nodown: false, + noin: false, + noout: false, + noup: false + }; + }); + + it('should submit an activated flag for multiple OSDs', () => { + component.selected = generateSelected(); + const code = component.flags[0].code; + const submittedIds = [0, 1, 2]; + component.flags[0].value = true; + component.submitAction(); + flags[code] = true; + + const req = httpTesting.expectOne('api/osd/flags/individual'); + req.flush({ flags, ids: submittedIds }); + expect(req.request.body).toEqual({ flags, ids: submittedIds }); + expect(notificationType).toBe(NotificationType.success); + expect(component.activeModal.hide).toHaveBeenCalledTimes(1); + }); + + it('should submit multiple flags for multiple OSDs', () => { + component.selected = generateSelected(); + const codes = [component.flags[0].code, component.flags[1].code]; + const submittedIds = [0, 1, 2]; + component.flags[0].value = true; + component.flags[1].value = true; + component.submitAction(); + flags[codes[0]] = true; + flags[codes[1]] = true; + + const req = httpTesting.expectOne('api/osd/flags/individual'); + req.flush({ flags, ids: submittedIds }); + expect(req.request.body).toEqual({ flags, ids: submittedIds }); + expect(notificationType).toBe(NotificationType.success); + expect(component.activeModal.hide).toHaveBeenCalledTimes(1); + }); + }); + }); + + function checkFlags(flags: Flag[], expected: object = {}) { + flags.forEach((flag) => { + let value = false; + let clusterWide = false; + let indeterminate = false; + if (Object.keys(expected).includes(flag.code)) { + value = expected[flag.code]['value']; + clusterWide = expected[flag.code]['clusterWide']; + indeterminate = expected[flag.code]['indeterminate']; + } + expect(flag.value).toBe(value); + expect(flag.clusterWide).toBe(clusterWide); + expect(flag.indeterminate).toBe(indeterminate); + }); + } + + function generateSelected(flags: string[][] = []) { + const defaultFlags = ['exists', 'up']; + const osds = []; + const count = flags.length || 3; + for (let i = 0; i < count; i++) { + const osd = { + osd: i, + state: defaultFlags.concat(flags[i]) || defaultFlags + }; + osds.push(osd); + } + return osds; + } +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts new file mode 100644 index 000000000000..697d7e41aa55 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts @@ -0,0 +1,140 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; +import * as _ from 'lodash'; +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { OsdService } from '../../../../shared/api/osd.service'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { Flag } from '../../../../shared/models/flag'; +import { Permissions } from '../../../../shared/models/permissions'; +import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../../shared/services/notification.service'; + +@Component({ + selector: 'cd-osd-flags-indiv-modal', + templateUrl: './osd-flags-indiv-modal.component.html', + styleUrls: ['./osd-flags-indiv-modal.component.scss'] +}) +export class OsdFlagsIndivModalComponent implements OnInit { + permissions: Permissions; + selected: object[]; + initialSelection: Flag[] = []; + osdFlagsForm = new FormGroup({}); + flags: Flag[] = [ + { + code: 'noup', + name: this.i18n('No Up'), + description: this.i18n('OSDs are not allowed to start'), + value: false, + clusterWide: false, + indeterminate: false + }, + { + code: 'nodown', + name: this.i18n('No Down'), + description: this.i18n( + 'OSD failure reports are being ignored, such that the monitors will not mark OSDs down' + ), + value: false, + clusterWide: false, + indeterminate: false + }, + { + code: 'noin', + name: this.i18n('No In'), + description: this.i18n( + 'OSDs that were previously marked out will not be marked back in when they start' + ), + value: false, + clusterWide: false, + indeterminate: false + }, + { + code: 'noout', + name: this.i18n('No Out'), + description: this.i18n( + 'OSDs will not automatically be marked out after the configured interval' + ), + value: false, + clusterWide: false, + indeterminate: false + } + ]; + clusterWideTooltip: string = this.i18n('The flag has been enabled for the entire cluster.'); + + constructor( + public activeModal: BsModalRef, + private authStorageService: AuthStorageService, + private osdService: OsdService, + private notificationService: NotificationService, + private i18n: I18n + ) { + this.permissions = this.authStorageService.getPermissions(); + } + + ngOnInit() { + const osdCount = this.selected.length; + this.osdService.getFlags().subscribe((clusterWideFlags: string[]) => { + const activatedIndivFlags = this.getActivatedIndivFlags(); + this.flags.forEach((flag) => { + const flagCount = activatedIndivFlags[flag.code]; + if (clusterWideFlags.includes(flag.code)) { + flag.clusterWide = true; + } + + if (flagCount === osdCount) { + flag.value = true; + } else if (flagCount > 0) { + flag.indeterminate = true; + } + }); + this.initialSelection = _.cloneDeep(this.flags); + }); + } + + getActivatedIndivFlags(): { [flag: string]: number } { + const flagsCount = {}; + this.flags.forEach((flag) => { + flagsCount[flag.code] = 0; + }); + + [].concat(...this.selected.map((osd) => osd['state'])).map((activatedFlag) => { + if (Object.keys(flagsCount).includes(activatedFlag)) { + flagsCount[activatedFlag] = flagsCount[activatedFlag] + 1; + } + }); + return flagsCount; + } + + changeValue(flag: Flag) { + flag.value = !flag.value; + flag.indeterminate = false; + } + + resetSelection() { + this.flags = _.cloneDeep(this.initialSelection); + } + + submitAction() { + const activeFlags = {}; + this.flags.forEach((flag) => { + if (flag.indeterminate) { + activeFlags[flag.code] = null; + } else { + activeFlags[flag.code] = flag.value; + } + }); + const selectedIds = this.selected.map((selection) => selection['osd']); + this.osdService.updateIndividualFlags(activeFlags, selectedIds).subscribe( + () => { + this.notificationService.show(NotificationType.success, this.i18n('Updated OSD Flags')); + this.activeModal.hide(); + }, + () => { + this.activeModal.hide(); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html index bc1bc78dc23a..afb2200cfdd6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html @@ -38,6 +38,14 @@ [used]="row.stats.stat_bytes_used"> + + + {{ flag }} + {{ flag }} + { describe('getOsdList', () => { let osds: any[]; + let flagsSpy: jasmine.Spy; const createOsd = (n: number) => >{ @@ -163,6 +164,7 @@ describe('OsdListComponent', () => { beforeEach(() => { spyOn(osdService, 'getList').and.callFake(() => of(osds)); + flagsSpy = spyOn(osdService, 'getFlags').and.callFake(() => of([])); osds = [createOsd(1), createOsd(2), createOsd(3)]; component.getOsdList(); }); @@ -212,6 +214,42 @@ describe('OsdListComponent', () => { expectAttributeOnEveryOsd('cdIsBinary'); expect(component.osds[0].cdIsBinary).toBe(true); }); + + it('should return valid individual flags only', () => { + const osd1 = createOsd(1); + const osd2 = createOsd(2); + osd1.state = ['noup', 'exists', 'up']; + osd2.state = ['noup', 'exists', 'up', 'noin']; + osds = [osd1, osd2]; + component.getOsdList(); + + expect(component.osds[0].cdIndivFlags).toStrictEqual(['noup']); + expect(component.osds[1].cdIndivFlags).toStrictEqual(['noup', 'noin']); + }); + + it('should not fail on empty individual flags list', () => { + expect(component.osds[0].cdIndivFlags).toStrictEqual([]); + }); + + it('should not return disabled cluster-wide flags', () => { + flagsSpy.and.callFake(() => of(['noout', 'nodown', 'sortbitwise'])); + component.getOsdList(); + expect(component.osds[0].cdClusterFlags).toStrictEqual(['noout', 'nodown']); + + flagsSpy.and.callFake(() => of(['noout', 'purged_snapdirs', 'nodown'])); + component.getOsdList(); + expect(component.osds[0].cdClusterFlags).toStrictEqual(['noout', 'nodown']); + + flagsSpy.and.callFake(() => of(['recovery_deletes', 'noout', 'pglog_hardlimit', 'nodown'])); + component.getOsdList(); + expect(component.osds[0].cdClusterFlags).toStrictEqual(['noout', 'nodown']); + }); + + it('should not fail on empty cluster-wide flags list', () => { + flagsSpy.and.callFake(() => of([])); + component.getOsdList(); + expect(component.osds[0].cdClusterFlags).toStrictEqual([]); + }); }); describe('show osd actions as defined', () => { @@ -268,6 +306,7 @@ describe('OsdListComponent', () => { actions: [ 'Create', 'Edit', + 'Flags', 'Scrub', 'Deep Scrub', 'Reweight', @@ -285,6 +324,7 @@ describe('OsdListComponent', () => { actions: [ 'Create', 'Edit', + 'Flags', 'Scrub', 'Deep Scrub', 'Reweight', @@ -310,6 +350,7 @@ describe('OsdListComponent', () => { 'update,delete': { actions: [ 'Edit', + 'Flags', 'Scrub', 'Deep Scrub', 'Reweight', @@ -324,7 +365,16 @@ describe('OsdListComponent', () => { primary: { multiple: 'Scrub', executing: 'Edit', single: 'Edit', no: 'Edit' } }, update: { - actions: ['Edit', 'Scrub', 'Deep Scrub', 'Reweight', 'Mark Out', 'Mark In', 'Mark Down'], + actions: [ + 'Edit', + 'Flags', + 'Scrub', + 'Deep Scrub', + 'Reweight', + 'Mark Out', + 'Mark In', + 'Mark Down' + ], primary: { multiple: 'Scrub', executing: 'Edit', single: 'Edit', no: 'Edit' } }, delete: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts index 1deb20034341..0b7d4f9cad85 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts @@ -29,6 +29,7 @@ import { DepCheckerService } from '../../../../shared/services/dep-checker.servi import { NotificationService } from '../../../../shared/services/notification.service'; import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service'; import { URLBuilderService } from '../../../../shared/services/url-builder.service'; +import { OsdFlagsIndivModalComponent } from '../osd-flags-indiv-modal/osd-flags-indiv-modal.component'; import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component'; import { OsdPgScrubModalComponent } from '../osd-pg-scrub-modal/osd-pg-scrub-modal.component'; import { OsdRecvSpeedModalComponent } from '../osd-recv-speed-modal/osd-recv-speed-modal.component'; @@ -58,6 +59,8 @@ export class OsdListComponent extends ListWithDetails implements OnInit { safeToDestroyBodyTpl: TemplateRef; @ViewChild('deleteOsdExtraTpl', { static: false }) deleteOsdExtraTpl: TemplateRef; + @ViewChild('flagsTpl', { static: true }) + flagsTpl: TemplateRef; permissions: Permissions; tableActions: CdTableAction[]; @@ -68,6 +71,13 @@ export class OsdListComponent extends ListWithDetails implements OnInit { selection = new CdTableSelection(); osds: any[] = []; + disabledFlags: string[] = [ + 'sortbitwise', + 'purged_snapdirs', + 'recovery_deletes', + 'pglog_hardlimit' + ]; + indivFlagNames: string[] = ['noup', 'nodown', 'noin', 'noout']; protected static collectStates(osd: any) { const states = [osd['in'] ? 'in' : 'out']; @@ -118,6 +128,13 @@ export class OsdListComponent extends ListWithDetails implements OnInit { icon: Icons.edit, click: () => this.editAction() }, + { + name: this.actionLabels.FLAGS, + permission: 'update', + icon: Icons.flag, + click: () => this.configureFlagsIndivAction(), + disable: () => !this.hasOsdSelected + }, { name: this.actionLabels.SCRUB, permission: 'update', @@ -293,6 +310,11 @@ export class OsdListComponent extends ListWithDetails implements OnInit { flexGrow: 1, pipe: this.dimlessBinaryPipe }, + { + prop: 'state', + name: this.i18n(`Flags`), + cellTemplate: this.flagsTpl + }, { prop: 'stats.usage', name: this.i18n('Usage'), cellTemplate: this.osdUsageTpl }, { prop: 'stats_history.out_bytes', @@ -362,13 +384,16 @@ export class OsdListComponent extends ListWithDetails implements OnInit { } getOsdList() { - this.osdService.getList().subscribe((data: any[]) => { - this.osds = data.map((osd) => { + const observables = [this.osdService.getList(), this.osdService.getFlags()]; + observableForkJoin(observables).subscribe((resp: [any[], string[]]) => { + this.osds = resp[0].map((osd) => { osd.collectedStates = OsdListComponent.collectStates(osd); osd.stats_history.out_bytes = osd.stats_history.op_out_bytes.map((i: string) => i[1]); osd.stats_history.in_bytes = osd.stats_history.op_in_bytes.map((i: string) => i[1]); osd.stats.usage = osd.stats.stat_bytes_used / osd.stats.stat_bytes; osd.cdIsBinary = true; + osd.cdIndivFlags = osd.state.filter((f: string) => this.indivFlagNames.includes(f)); + osd.cdClusterFlags = resp[1].filter((f: string) => !this.disabledFlags.includes(f)); return osd; }); }); @@ -424,6 +449,13 @@ export class OsdListComponent extends ListWithDetails implements OnInit { this.bsModalRef = this.modalService.show(OsdFlagsModalComponent, {}); } + configureFlagsIndivAction() { + const initialState = { + selected: this.getSelectedOsds() + }; + this.bsModalRef = this.modalService.show(OsdFlagsIndivModalComponent, { initialState }); + } + showConfirmationModal(markAction: string, onSubmit: (id: number) => Observable) { this.bsModalRef = this.modalService.show(ConfirmationModalComponent, { initialState: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts index 6a55f64e2fe6..1b9542b9ac08 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts @@ -99,6 +99,15 @@ describe('OsdService', () => { expect(req.request.body).toEqual({ flags: ['foo'] }); }); + it('should call updateIndividualFlags to update individual flags', () => { + const flags = { noin: true, noout: true }; + const ids = [0, 1]; + service.updateIndividualFlags(flags, ids).subscribe(); + const req = httpTesting.expectOne('api/osd/flags/individual'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ flags: flags, ids: ids }); + }); + it('should mark the OSD out', () => { service.markOut(1).subscribe(); const req = httpTesting.expectOne('api/osd/1/mark_out'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts index 0bd2f370a9b1..0aee6b94c85b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts @@ -105,6 +105,10 @@ export class OsdService { return this.http.put(`${this.path}/flags`, { flags: flags }); } + updateIndividualFlags(flags: { [flag: string]: boolean }, ids: number[]) { + return this.http.put(`${this.path}/flags/individual`, { flags: flags, ids: ids }); + } + markOut(id: number) { return this.http.post(`${this.path}/${id}/mark_out`, null); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index 802c0d96f34e..9bd4e474088c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -108,6 +108,7 @@ export class ActionLabelsI18n { UNPROTECT: string; UNSET: string; UPDATE: string; + FLAGS: string; constructor(private i18n: I18n) { /* Create a new item */ @@ -151,6 +152,7 @@ export class ActionLabelsI18n { this.TRASH = this.i18n('Move to Trash'); this.UNPROTECT = this.i18n('Unprotect'); this.CHANGE = this.i18n('Change'); + this.FLAGS = this.i18n('Flags'); /* Prometheus wording */ this.RECREATE = this.i18n('Recreate'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts new file mode 100644 index 000000000000..075decbf7769 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts @@ -0,0 +1,8 @@ +export class Flag { + code: 'noout' | 'noin' | 'nodown' | 'noup'; + name: string; + description: string; + value: boolean; + clusterWide: boolean; + indeterminate: boolean; +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml new file mode 100644 index 000000000000..e69de29bb2d1