From: Tatjana Dehler Date: Mon, 27 Jul 2020 09:33:19 +0000 (+0200) Subject: mgr/dashboard: assign flags to single OSDs X-Git-Tag: v16.1.0~775^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=3639332f34344f97ce5bd88d2fb17a659ca281a2;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 --- diff --git a/qa/suites/rados/dashboard/tasks/dashboard.yaml b/qa/suites/rados/dashboard/tasks/dashboard.yaml index c958d4abd169..9d39ff47c198 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 3747fcb164a4..933a13a05f79 100644 --- a/qa/tasks/mgr/dashboard/test_osd.py +++ b/qa/tasks/mgr/dashboard/test_osd.py @@ -234,36 +234,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 91d1126fa406..ceee0a245171 100644 --- a/src/pybind/mgr/dashboard/controllers/osd.py +++ b/src/pybind/mgr/dashboard/controllers/osd.py @@ -35,6 +35,17 @@ EXPORT_FLAGS_SCHEMA = { "list_of_flags": ([str], "") } +EXPORT_INDIV_FLAGS_SCHEMA = { + "added": ([str], "List of added flags"), + "removed": ([str], "List of removed flags"), + "ids": ([int], "List of updated OSDs") +} + +EXPORT_INDIV_FLAGS_GET_SCHEMA = { + "osd": (int, "OSD ID"), + "flags": ([str], "List of active flags") +} + def osd_task(name, metadata, wait_for=2.0): return Task("osd/{}".format(name), metadata, wait_for) @@ -381,11 +392,29 @@ 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) + @EndpointDoc("Display OSD Flags", responses={200: EXPORT_FLAGS_SCHEMA}) def list(self): return self._osd_flags() + @EndpointDoc('Sets OSD flags for the entire cluster.', + parameters={ + 'flags': ([str], 'List of flags to set. The flags `recovery_deletes`, ' + '`sortbitwise` and `pglog_hardlimit` cannot be unset. ' + 'Additionally `purged_snapshots` cannot even be set.') + }, + responses={200: EXPORT_FLAGS_SCHEMA}) def bulk_set(self, flags): """ The `recovery_deletes`, `sortbitwise` and `pglog_hardlimit` flags cannot be unset. @@ -398,10 +427,71 @@ 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 + @EndpointDoc('Sets OSD flags for a subset of individual OSDs.', + parameters={ + 'flags': ({'noout': (bool, 'Sets/unsets `noout`', True, None), + 'noin': (bool, 'Sets/unsets `noin`', True, None), + 'noup': (bool, 'Sets/unsets `noup`', True, None), + 'nodown': (bool, 'Sets/unsets `nodown`', True, None)}, + 'Directory of flags to set or unset. The flags ' + '`noin`, `noout`, `noup` and `nodown` are going to ' + 'be considered only.'), + 'ids': ([int], 'List of OSD ids the flags should be applied ' + 'to.') + }, + responses={200: EXPORT_INDIV_FLAGS_SCHEMA}) + 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 + @EndpointDoc('Displays individual OSD flags', + responses={200: EXPORT_INDIV_FLAGS_GET_SCHEMA}) + 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 eb73ccc49aba..67ffc5e050cf 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 @@ -33,6 +33,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'; @@ -106,7 +107,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component'; ServiceDaemonListComponent, TelemetryComponent, PrometheusTabsComponent, - ServiceFormComponent + ServiceFormComponent, + 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..c392b8346c52 --- /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..c2be010f30c9 --- /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,351 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { NgbActiveModal, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { of as observableOf } from 'rxjs'; + +import { configureTestBed } 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(), + NgbTooltipModule + ], + declarations: [OsdFlagsIndivModalComponent], + providers: [NgbActiveModal] + }); + + beforeEach(() => { + httpTesting = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(OsdFlagsIndivModalComponent); + component = fixture.componentInstance; + osdService = TestBed.inject(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: NgbActiveModal; + let flags: object; + + beforeEach(() => { + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'show').and.callFake((type) => { + notificationType = type; + }); + bsModalRef = TestBed.inject(NgbActiveModal); + spyOn(bsModalRef, 'close').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.close).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.close).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.close).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: NgbActiveModal; + let flags: object; + + beforeEach(() => { + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'show').and.callFake((type) => { + notificationType = type; + }); + bsModalRef = TestBed.inject(NgbActiveModal); + spyOn(bsModalRef, 'close').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.close).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.close).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..055ac72007e4 --- /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,132 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import _ from 'lodash'; + +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: $localize`No Up`, + description: $localize`OSDs are not allowed to start`, + value: false, + clusterWide: false, + indeterminate: false + }, + { + code: 'nodown', + name: $localize`No Down`, + description: $localize`OSD failure reports are being ignored, such that the monitors will not mark OSDs down`, + value: false, + clusterWide: false, + indeterminate: false + }, + { + code: 'noin', + name: $localize`No In`, + description: $localize`OSDs that were previously marked out will not be marked back in when they start`, + value: false, + clusterWide: false, + indeterminate: false + }, + { + code: 'noout', + name: $localize`No Out`, + description: $localize`OSDs will not automatically be marked out after the configured interval`, + value: false, + clusterWide: false, + indeterminate: false + } + ]; + clusterWideTooltip: string = $localize`The flag has been enabled for the entire cluster.`; + + constructor( + public activeModal: NgbActiveModal, + private authStorageService: AuthStorageService, + private osdService: OsdService, + private notificationService: NotificationService + ) { + 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, $localize`Updated OSD Flags`); + this.activeModal.close(); + }, + () => { + this.activeModal.close(); + } + ); + } +} 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 c8111e02612e..fe236902e978 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 @@ -74,6 +74,14 @@ {{ actionDescription }} if you proceed. + + {{ flag }} + {{ flag }} + + { describe('getOsdList', () => { let osds: any[]; + let flagsSpy: jasmine.Spy; const createOsd = (n: number) => >{ @@ -169,6 +170,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(); }); @@ -218,6 +220,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', () => { @@ -274,6 +312,7 @@ describe('OsdListComponent', () => { actions: [ 'Create', 'Edit', + 'Flags', 'Scrub', 'Deep Scrub', 'Reweight', @@ -291,6 +330,7 @@ describe('OsdListComponent', () => { actions: [ 'Create', 'Edit', + 'Flags', 'Scrub', 'Deep Scrub', 'Reweight', @@ -316,6 +356,7 @@ describe('OsdListComponent', () => { 'update,delete': { actions: [ 'Edit', + 'Flags', 'Scrub', 'Deep Scrub', 'Reweight', @@ -330,7 +371,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 f42e9f15842b..a0e61cb5db96 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 @@ -30,6 +30,7 @@ import { ModalService } from '../../../../shared/services/modal.service'; 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'; @@ -57,6 +58,8 @@ export class OsdListComponent extends ListWithDetails implements OnInit { safeToDestroyBodyTpl: TemplateRef; @ViewChild('deleteOsdExtraTpl') deleteOsdExtraTpl: TemplateRef; + @ViewChild('flagsTpl', { static: true }) + flagsTpl: TemplateRef; permissions: Permissions; tableActions: CdTableAction[]; @@ -67,6 +70,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']; orchStatus: OrchestratorStatus; actionOrchFeatures = { @@ -115,6 +125,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', @@ -290,6 +307,11 @@ export class OsdListComponent extends ListWithDetails implements OnInit { flexGrow: 1, pipe: this.dimlessBinaryPipe }, + { + prop: 'state', + name: $localize`Flags`, + cellTemplate: this.flagsTpl + }, { prop: 'stats.usage', name: $localize`Usage`, cellTemplate: this.osdUsageTpl }, { prop: 'stats_history.out_bytes', @@ -371,13 +393,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; }); }); @@ -427,6 +452,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, { titleText: $localize`Mark OSD ${markAction}`, 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 831a022e1d73..9179a5e6d337 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'); 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 2e698a220689..9bff8083936e 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 @@ -102,6 +102,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.put(`${this.path}/${id}/mark`, { action: 'out' }); } 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 5f8d310d9c52..3ce91b55ee27 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 @@ -106,6 +106,7 @@ export class ActionLabelsI18n { UNPROTECT: string; UNSET: string; UPDATE: string; + FLAGS: string; constructor() { /* Create a new item */ @@ -149,6 +150,7 @@ export class ActionLabelsI18n { this.TRASH = $localize`Move to Trash`; this.UNPROTECT = $localize`Unprotect`; this.CHANGE = $localize`Change`; + this.FLAGS = $localize`Flags`; /* Prometheus wording */ this.RECREATE = $localize`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 index ffe179876253..bdbe2bfe465a 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -5149,12 +5149,143 @@ paths: schema: properties: flags: - type: string + description: List of flags to set. The flags `recovery_deletes`, + `sortbitwise` and `pglog_hardlimit` cannot be unset. Additionally + `purged_snapshots` cannot even be set. + items: + type: string + type: array + required: + - flags + type: object + responses: + '200': + content: + application/json: + schema: + properties: + list_of_flags: + description: '' + items: + type: string + type: array + required: + - list_of_flags + type: object + description: Resource updated. + '202': + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Sets OSD flags for the entire cluster. + tags: + - OSD + /api/osd/flags/individual: + get: + parameters: [] + responses: + '200': + content: + application/json: + schema: + properties: + flags: + description: List of active flags + items: + type: string + type: array + osd: + description: OSD ID + type: integer + required: + - osd + - flags + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Displays individual OSD flags + tags: + - OSD + put: + description: "\n Updates flags (`noout`, `noin`, `nodown`, `noup`) for\ + \ an individual\n subset of OSDs.\n " + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + flags: + description: Directory of flags to set or unset. The flags `noin`, + `noout`, `noup` and `nodown` are going to be considered only. + properties: + nodown: + description: Sets/unsets `nodown` + type: boolean + noin: + description: Sets/unsets `noin` + type: boolean + noout: + description: Sets/unsets `noout` + type: boolean + noup: + description: Sets/unsets `noup` + type: boolean + type: object + ids: + description: List of OSD ids the flags should be applied to. + items: + type: integer + type: array required: - flags + - ids type: object responses: '200': + content: + application/json: + schema: + properties: + added: + description: List of added flags + items: + type: string + type: array + ids: + description: List of updated OSDs + items: + type: integer + type: array + removed: + description: List of removed flags + items: + type: string + type: array + required: + - added + - removed + - ids + type: object description: Resource updated. '202': description: Operation is still executing. Please check the task queue. @@ -5169,6 +5300,7 @@ paths: trace. security: - jwt: [] + summary: Sets OSD flags for a subset of individual OSDs. tags: - OSD /api/osd/safe_to_delete: