From: Stephan Müller Date: Wed, 6 Nov 2019 16:47:47 +0000 (+0100) Subject: mgr/dashboard: CephFS quota management X-Git-Tag: v15.1.0~488^2~4 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=16bae1a3e30f3da87dda674ca599b703ec9d6ebe;p=ceph-ci.git mgr/dashboard: CephFS quota management Now both CephFS quotas can be changed with a validation against the next tree maximum in the file tree, that prevents setting the quotas in a way that would not be usable. Fixes: https://tracker.ceph.com/issues/38287 Signed-off-by: Stephan Müller --- diff --git a/qa/tasks/mgr/dashboard/test_cephfs.py b/qa/tasks/mgr/dashboard/test_cephfs.py index 47a39d18bf6..bfd27402519 100644 --- a/qa/tasks/mgr/dashboard/test_cephfs.py +++ b/qa/tasks/mgr/dashboard/test_cephfs.py @@ -2,6 +2,7 @@ from __future__ import absolute_import import six +from contextlib import contextmanager from .helper import DashboardTestCase, JObj, JList, JLeaf @@ -11,6 +12,8 @@ class CephfsTest(DashboardTestCase): AUTH_ROLES = ['cephfs-manager'] + QUOTA_PATH = '/quotas' + def assertToHave(self, data, key): self.assertIn(key, data) self.assertIsNotNone(data[key]) @@ -39,6 +42,26 @@ class CephfsTest(DashboardTestCase): self.assertEqual(len(data), expectedLength) return data + def setQuotas(self, bytes=None, files=None): + quotas = { + 'max_bytes': bytes, + 'max_files': files + } + self._post("/api/cephfs/{}/set_quotas".format(self.get_fs_id()), data=quotas, + params={'path': self.QUOTA_PATH}) + self.assertStatus(200) + + def assertQuotas(self, bytes, files): + data = self.ls_dir('/', 1)[0] + self.assertEqual(data['quotas']['max_bytes'], bytes) + self.assertEqual(data['quotas']['max_files'], files) + + @contextmanager + def new_quota_dir(self): + self.mk_dirs(self.QUOTA_PATH) + self.setQuotas(1024**3, 1024) + yield 1 + self.rm_dir(self.QUOTA_PATH) @DashboardTestCase.RunAs('test', 'test', ['block-manager']) def test_access_permissions(self): @@ -199,3 +222,28 @@ class CephfsTest(DashboardTestCase): self.rm_dir('/movies/dune/extended_version') self.rm_dir('/movies/dune') self.rm_dir('/movies') + + def test_quotas_default(self): + self.mk_dirs(self.QUOTA_PATH) + self.assertQuotas(0, 0) + self.rm_dir(self.QUOTA_PATH) + + def test_quotas_set_both(self): + with self.new_quota_dir(): + self.assertQuotas(1024**3, 1024) + + def test_quotas_set_only_bytes(self): + with self.new_quota_dir(): + self.setQuotas(2048**3) + self.assertQuotas(2048**3, 1024) + + def test_quotas_set_only_files(self): + with self.new_quota_dir(): + self.setQuotas(None, 2048) + self.assertQuotas(1024**3, 2048) + + def test_quotas_unset_both(self): + with self.new_quota_dir(): + self.setQuotas(0, 0) + self.assertQuotas(0, 0) + diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 84d754199d0..7678e45a50c 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -414,7 +414,7 @@ class CephFS(RESTController): return cfs.get_quotas(path) @RESTController.Resource('POST') - def set_quotas(self, fs_id, path, max_bytes, max_files): + def set_quotas(self, fs_id, path, max_bytes=None, max_files=None): """ Set the quotas of the specified path. :param fs_id: The filesystem identifier. diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html index 80d7a6005a7..8a6e0f176fe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html @@ -18,10 +18,20 @@
Quotas + + Snapshots diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts index 7f3eba70e29..ed277400548 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts @@ -1,5 +1,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Validators } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; @@ -15,11 +16,18 @@ import { PermissionHelper } from '../../../../testing/unit-test-helper'; import { CephfsService } from '../../../shared/api/cephfs.service'; +import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component'; +import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component'; +import { NotificationType } from '../../../shared/enum/notification-type.enum'; +import { CdValidators } from '../../../shared/forms/cd-validators'; +import { CdTableAction } from '../../../shared/models/cd-table-action'; +import { CdTableSelection } from '../../../shared/models/cd-table-selection'; import { CephfsDir, CephfsQuotas, CephfsSnapshot } from '../../../shared/models/cephfs-directory-models'; +import { NotificationService } from '../../../shared/services/notification.service'; import { SharedModule } from '../../../shared/shared.module'; import { CephfsDirectoriesComponent } from './cephfs-directories.component'; @@ -28,6 +36,12 @@ describe('CephfsDirectoriesComponent', () => { let fixture: ComponentFixture; let cephfsService: CephfsService; let lsDirSpy; + let modalShowSpy; + let notificationShowSpy; + let minValidator; + let maxValidator; + let minBinaryValidator; + let maxBinaryValidator; let originalDate; let modal; @@ -44,6 +58,7 @@ describe('CephfsDirectoriesComponent', () => { parent: Tree; createdSnaps: CephfsSnapshot[] | any[]; deletedSnaps: CephfsSnapshot[] | any[]; + updatedQuotas: { [path: string]: CephfsQuotas }; }; // Object contains mock functions @@ -62,9 +77,9 @@ describe('CephfsDirectoriesComponent', () => { } return snapshots; }, - dir: (path: string, name: string, modifier: number): CephfsDir => { - const dirPath = `${path === '/' ? '' : path}/${name}`; - let snapshots = mockLib.snapshots(path, modifier); + dir: (parentPath: string, name: string, modifier: number): CephfsDir => { + const dirPath = `${parentPath === '/' ? '' : parentPath}/${name}`; + let snapshots = mockLib.snapshots(parentPath, modifier); const extraSnapshots = mockData.createdSnaps.filter((s) => s.path === dirPath); if (extraSnapshots.length > 0) { snapshots = snapshots.concat(extraSnapshots); @@ -78,8 +93,11 @@ describe('CephfsDirectoriesComponent', () => { return { name, path: dirPath, - parent: path, - quotas: mockLib.quotas(1024 * modifier, 10 * modifier), + parent: parentPath, + quotas: Object.assign( + mockLib.quotas(1024 * modifier, 10 * modifier), + mockData.updatedQuotas[dirPath] || {} + ), snapshots: snapshots }; }, @@ -121,6 +139,10 @@ describe('CephfsDirectoriesComponent', () => { }); return of(name); }, + updateQuota: (_id, path, updated: CephfsQuotas) => { + mockData.updatedQuotas[path] = Object.assign(mockData.updatedQuotas[path] || {}, updated); + return of('Response'); + }, modalShow: (comp, init) => { modal = modalServiceShow(comp, init); return modal.ref; @@ -175,6 +197,43 @@ describe('CephfsDirectoriesComponent', () => { component.snapshot.selection.selected = snapshots; component.deleteSnapshotModal(); modal.component.callSubmitAction(); + }, + updateQuotaThroughModal: (attribute: string, value: number) => { + component.quota.selection.selected = component.settings.filter( + (q) => q.quotaKey === attribute + ); + component.updateQuotaModal(); + modal.component.onSubmitForm({ [attribute]: value }); + }, + unsetQuotaThroughModal: (attribute: string) => { + component.quota.selection.selected = component.settings.filter( + (q) => q.quotaKey === attribute + ); + component.unsetQuotaModal(); + modal.component.onSubmit(); + }, + setFourQuotaDirs: (quotas: number[][]) => { + expect(quotas.length).toBe(4); // Make sure this function is used correctly + let path = ''; + quotas.forEach((quota, index) => { + index += 1; + mockLib.mkDir(path, index.toString(), quota[0], quota[1]); + path += '/' + index; + }); + mockData.parent = { + value: '3', + id: '/1/2/3', + parent: { + value: '2', + id: '/1/2', + parent: { + value: '1', + id: '/1', + parent: { value: '/', id: '/' } + } + } + } as Tree; + mockLib.selectNode('/1/2/3/4'); } }; @@ -186,16 +245,94 @@ describe('CephfsDirectoriesComponent', () => { requestedPaths: (expected: string[]) => expect(get.requestedPaths()).toEqual(expected), snapshotsByName: (snaps: string[]) => expect(component.selectedDir.snapshots.map((s) => s.name)).toEqual(snaps), - quotaSettings: ( - fileValue: number | string, - fileOrigin: string, - sizeValue: string, - sizeOrigin: string - ) => - expect(component.settings).toEqual([ - { name: 'Max files', value: fileValue, origin: fileOrigin }, - { name: 'Max size', value: sizeValue, origin: sizeOrigin } - ]) + dirQuotas: (bytes: number, files: number) => { + expect(component.selectedDir.quotas).toEqual({ max_bytes: bytes, max_files: files }); + }, + noQuota: (key: 'bytes' | 'files') => { + assert.quotaRow(key, '', 0, ''); + }, + quotaIsNotInherited: (key: 'bytes' | 'files', shownValue, nextMaximum) => { + const dir = component.selectedDir; + const path = dir.path; + assert.quotaRow(key, shownValue, nextMaximum, path); + }, + quotaIsInherited: (key: 'bytes' | 'files', shownValue, path) => { + const isBytes = key === 'bytes'; + const nextMaximum = get.nodeIds()[path].quotas[isBytes ? 'max_bytes' : 'max_files']; + assert.quotaRow(key, shownValue, nextMaximum, path); + }, + quotaRow: ( + key: 'bytes' | 'files', + shownValue: number | string, + nextTreeMaximum: number, + originPath: string + ) => { + const isBytes = key === 'bytes'; + expect(component.settings[isBytes ? 1 : 0]).toEqual({ + row: { + name: `Max ${isBytes ? 'size' : key}`, + value: shownValue, + originPath + }, + quotaKey: `max_${key}`, + dirValue: expect.any(Number), + nextTreeMaximum: { + value: nextTreeMaximum, + path: expect.any(String) + } + }); + }, + quotaUnsetModalTexts: (titleText, message, notificationMsg) => { + expect(modalShowSpy).toHaveBeenCalledWith(ConfirmationModalComponent, { + initialState: expect.objectContaining({ + titleText, + description: message, + buttonText: 'Unset' + }) + }); + expect(notificationShowSpy).toHaveBeenCalledWith(NotificationType.success, notificationMsg); + }, + quotaUpdateModalTexts: (titleText, message, notificationMsg) => { + expect(modalShowSpy).toHaveBeenCalledWith(FormModalComponent, { + initialState: expect.objectContaining({ + titleText, + message, + submitButtonText: 'Save' + }) + }); + expect(notificationShowSpy).toHaveBeenCalledWith(NotificationType.success, notificationMsg); + }, + quotaUpdateModalField: ( + type: string, + label: string, + key: string, + value: number, + max: number, + errors?: { [key: string]: string } + ) => { + expect(modalShowSpy).toHaveBeenCalledWith(FormModalComponent, { + initialState: expect.objectContaining({ + fields: [ + { + type, + label, + errors, + name: key, + value, + validators: expect.anything(), + required: true + } + ] + }) + }); + if (type === 'binary') { + expect(minBinaryValidator).toHaveBeenCalledWith(0); + expect(maxBinaryValidator).toHaveBeenCalledWith(max); + } else { + expect(minValidator).toHaveBeenCalledWith(0); + expect(maxValidator).toHaveBeenCalledWith(max); + } + } }; configureTestBed({ @@ -217,7 +354,8 @@ describe('CephfsDirectoriesComponent', () => { nodes: undefined, parent: undefined, createdSnaps: [], - deletedSnaps: [] + deletedSnaps: [], + updatedQuotas: {} }; originalDate = Date; spyOn(global, 'Date').and.callFake(mockLib.date); @@ -226,8 +364,10 @@ describe('CephfsDirectoriesComponent', () => { lsDirSpy = spyOn(cephfsService, 'lsDir').and.callFake(mockLib.lsDir); spyOn(cephfsService, 'mkSnapshot').and.callFake(mockLib.mkSnapshot); spyOn(cephfsService, 'rmSnapshot').and.callFake(mockLib.rmSnapshot); + spyOn(cephfsService, 'updateQuota').and.callFake(mockLib.updateQuota); - spyOn(TestBed.get(BsModalService), 'show').and.callFake(mockLib.modalShow); + modalShowSpy = spyOn(TestBed.get(BsModalService), 'show').and.callFake(mockLib.modalShow); + notificationShowSpy = spyOn(TestBed.get(NotificationService), 'show').and.stub(); fixture = TestBed.createComponent(CephfsDirectoriesComponent); component = fixture.componentInstance; @@ -292,6 +432,50 @@ describe('CephfsDirectoriesComponent', () => { '/a/a/b' ]); }); + + describe('test quota update mock', () => { + const PATH = '/a'; + const ID = 2; + + const updateQuota = (quotas: CephfsQuotas) => mockLib.updateQuota(ID, PATH, quotas); + + const expectMockUpdate = (max_bytes?: number, max_files?: number) => + expect(mockData.updatedQuotas[PATH]).toEqual({ + max_bytes, + max_files + }); + + const expectLsUpdate = (max_bytes?: number, max_files?: number) => { + let dir: CephfsDir; + mockLib.lsDir(ID, '/').subscribe((dirs) => (dir = dirs.find((d) => d.path === PATH))); + expect(dir.quotas).toEqual({ + max_bytes, + max_files + }); + }; + + it('tests to set quotas', () => { + expectLsUpdate(1024, 10); + + updateQuota({ max_bytes: 512 }); + expectMockUpdate(512); + expectLsUpdate(512, 10); + + updateQuota({ max_files: 100 }); + expectMockUpdate(512, 100); + expectLsUpdate(512, 100); + }); + + it('tests to unset quotas', () => { + updateQuota({ max_files: 0 }); + expectMockUpdate(undefined, 0); + expectLsUpdate(1024, 0); + + updateQuota({ max_bytes: 0 }); + expectMockUpdate(0, 0); + expectLsUpdate(0, 0); + }); + }); }); it('calls lsDir only if an id exits', () => { @@ -353,7 +537,8 @@ describe('CephfsDirectoriesComponent', () => { mockLib.selectNode('/a'); const dir = get.dirs().find((d) => d.path === '/a'); expect(component.selectedDir).toEqual(dir); - assert.quotaSettings(10, '/a', '1 KiB', '/a'); + assert.quotaIsNotInherited('files', 10, 0); + assert.quotaIsNotInherited('bytes', '1 KiB', 0); }); it('should extend the list by subdirectories when expanding and omit already called path', () => { @@ -401,47 +586,32 @@ describe('CephfsDirectoriesComponent', () => { }); describe('used quotas', () => { - const setUpDirs = (quotas: number[][]) => { - let path = ''; - quotas.forEach((quota, index) => { - index += 1; - mockLib.mkDir(path, index.toString(), quota[0], quota[1]); - path += '/' + index; - }); - mockData.parent = { - value: '3', - id: '/1/2/3', - parent: { - value: '2', - id: '/1/2', - parent: { - value: '1', - id: '/1', - parent: { value: '/', id: '/' } - } - } - } as Tree; - mockLib.selectNode('/1/2/3/4'); - }; - it('should use no quota if none is set', () => { - setUpDirs([[0, 0], [0, 0], [0, 0], [0, 0]]); - assert.quotaSettings('', '', '', ''); + mockLib.setFourQuotaDirs([[0, 0], [0, 0], [0, 0], [0, 0]]); + assert.noQuota('files'); + assert.noQuota('bytes'); + assert.dirQuotas(0, 0); }); it('should use quota from upper parents', () => { - setUpDirs([[100, 0], [0, 8], [0, 0], [0, 0]]); - assert.quotaSettings(100, '/1', '8 KiB', '/1/2'); + mockLib.setFourQuotaDirs([[100, 0], [0, 8], [0, 0], [0, 0]]); + assert.quotaIsInherited('files', 100, '/1'); + assert.quotaIsInherited('bytes', '8 KiB', '/1/2'); + assert.dirQuotas(0, 0); }); it('should use quota from the parent with the lowest value (deep inheritance)', () => { - setUpDirs([[200, 1], [100, 4], [400, 3], [300, 2]]); - assert.quotaSettings(100, '/1/2', '1 KiB', '/1'); + mockLib.setFourQuotaDirs([[200, 1], [100, 4], [400, 3], [300, 2]]); + assert.quotaIsInherited('files', 100, '/1/2'); + assert.quotaIsInherited('bytes', '1 KiB', '/1'); + assert.dirQuotas(2048, 300); }); it('should use current value', () => { - setUpDirs([[200, 2], [300, 4], [400, 3], [100, 1]]); - assert.quotaSettings(100, '/1/2/3/4', '1 KiB', '/1/2/3/4'); + mockLib.setFourQuotaDirs([[200, 2], [300, 4], [400, 3], [100, 1]]); + assert.quotaIsNotInherited('files', 100, 200); + assert.quotaIsNotInherited('bytes', '1 KiB', 2048); + assert.dirQuotas(1024, 100); }); }); }); @@ -469,11 +639,6 @@ describe('CephfsDirectoriesComponent', () => { mockLib.deleteSnapshotsThroughModal(component.selectedDir.snapshots); assert.snapshotsByName([]); }); - - afterEach(() => { - // Makes sure the directory is updated correctly - expect(component.selectedDir).toEqual(get.nodeIds()[component.selectedDir.path]); - }); }); it('should test all snapshot table actions combinations', () => { @@ -517,4 +682,239 @@ describe('CephfsDirectoriesComponent', () => { } }); }); + + describe('quotas', () => { + beforeEach(() => { + // Spies + minValidator = spyOn(Validators, 'min').and.callThrough(); + maxValidator = spyOn(Validators, 'max').and.callThrough(); + minBinaryValidator = spyOn(CdValidators, 'binaryMin').and.callThrough(); + maxBinaryValidator = spyOn(CdValidators, 'binaryMax').and.callThrough(); + // Select /a/c/b + mockLib.changeId(1); + mockLib.selectNode('/a'); + mockLib.selectNode('/a/c'); + mockLib.selectNode('/a/c/b'); + // Quotas after selection + assert.quotaIsInherited('files', 10, '/a'); + assert.quotaIsInherited('bytes', '1 KiB', '/a'); + assert.dirQuotas(2048, 20); + }); + + describe('update modal', () => { + describe('max_files', () => { + beforeEach(() => { + mockLib.updateQuotaThroughModal('max_files', 5); + }); + + it('should update max_files correctly', () => { + expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_files: 5 }); + assert.quotaIsNotInherited('files', 5, 10); + }); + + it('uses the correct form field', () => { + assert.quotaUpdateModalField('number', 'Max files', 'max_files', 20, 10, { + min: 'Value has to be at least 0 or more', + max: 'Value has to be at most 10 or less' + }); + }); + + it('shows the right texts', () => { + assert.quotaUpdateModalTexts( + "Update CephFS files quota for '/a/c/b'", + "The inherited files quota 10 from '/a' is the maximum value to be used.", + "Updated CephFS files quota for '/a/c/b'" + ); + }); + }); + + describe('max_bytes', () => { + beforeEach(() => { + mockLib.updateQuotaThroughModal('max_bytes', 512); + }); + + it('should update max_files correctly', () => { + expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_bytes: 512 }); + assert.quotaIsNotInherited('bytes', '512 B', 1024); + }); + + it('uses the correct form field', () => { + mockLib.updateQuotaThroughModal('max_bytes', 512); + assert.quotaUpdateModalField('binary', 'Max size', 'max_bytes', 2048, 1024); + }); + + it('shows the right texts', () => { + assert.quotaUpdateModalTexts( + "Update CephFS size quota for '/a/c/b'", + "The inherited size quota 1 KiB from '/a' is the maximum value to be used.", + "Updated CephFS size quota for '/a/c/b'" + ); + }); + }); + + describe('action behaviour', () => { + it('opens with next maximum as maximum if directory holds the current maximum', () => { + mockLib.updateQuotaThroughModal('max_bytes', 512); + mockLib.updateQuotaThroughModal('max_bytes', 888); + assert.quotaUpdateModalField('binary', 'Max size', 'max_bytes', 512, 1024); + }); + + it("uses 'Set' action instead of 'Update' if the quota is not set (0)", () => { + mockLib.updateQuotaThroughModal('max_bytes', 0); + mockLib.updateQuotaThroughModal('max_bytes', 200); + assert.quotaUpdateModalTexts( + "Set CephFS size quota for '/a/c/b'", + "The inherited size quota 1 KiB from '/a' is the maximum value to be used.", + "Set CephFS size quota for '/a/c/b'" + ); + }); + }); + }); + + describe('unset modal', () => { + describe('max_files', () => { + beforeEach(() => { + mockLib.updateQuotaThroughModal('max_files', 5); // Sets usable quota + mockLib.unsetQuotaThroughModal('max_files'); + }); + + it('should unset max_files correctly', () => { + expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_files: 0 }); + assert.dirQuotas(2048, 0); + }); + + it('shows the right texts', () => { + assert.quotaUnsetModalTexts( + "Unset CephFS files quota for '/a/c/b'", + "Unset files quota 5 from '/a/c/b' in order to inherit files quota 10 from '/a'.", + "Unset CephFS files quota for '/a/c/b'" + ); + }); + }); + + describe('max_bytes', () => { + beforeEach(() => { + mockLib.updateQuotaThroughModal('max_bytes', 512); // Sets usable quota + mockLib.unsetQuotaThroughModal('max_bytes'); + }); + + it('should unset max_files correctly', () => { + expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_bytes: 0 }); + assert.dirQuotas(0, 20); + }); + + it('shows the right texts', () => { + assert.quotaUnsetModalTexts( + "Unset CephFS size quota for '/a/c/b'", + "Unset size quota 512 B from '/a/c/b' in order to inherit size quota 1 KiB from '/a'.", + "Unset CephFS size quota for '/a/c/b'" + ); + }); + }); + + describe('action behaviour', () => { + it('uses different Text if no quota is inherited', () => { + mockLib.selectNode('/a'); + mockLib.unsetQuotaThroughModal('max_bytes'); + assert.quotaUnsetModalTexts( + "Unset CephFS size quota for '/a'", + "Unset size quota 1 KiB from '/a' in order to have no quota on the directory.", + "Unset CephFS size quota for '/a'" + ); + }); + + it('uses different Text if quota is already inherited', () => { + mockLib.unsetQuotaThroughModal('max_bytes'); + assert.quotaUnsetModalTexts( + "Unset CephFS size quota for '/a/c/b'", + "Unset size quota 2 KiB from '/a/c/b' which isn't used because of the inheritance " + + "of size quota 1 KiB from '/a'.", + "Unset CephFS size quota for '/a/c/b'" + ); + }); + }); + }); + }); + + describe('table actions', () => { + let actions: CdTableAction[]; + + const empty = (): CdTableSelection => { + const selection = new CdTableSelection(); + return selection; + }; + + const select = (value: number): CdTableSelection => { + const selection = new CdTableSelection(); + selection.selected = [{ dirValue: value }]; + return selection; + }; + + beforeEach(() => { + actions = component.quota.tableActions; + }); + + it("shows 'Set' for empty and not set quotas", () => { + const isSetVisible = actions[0].visible; + expect(isSetVisible(empty())).toBe(true); + expect(isSetVisible(select(0))).toBe(true); + expect(isSetVisible(select(1))).toBe(false); + }); + + it("shows 'Update' for set quotas only", () => { + const isUpdateVisible = actions[1].visible; + expect(isUpdateVisible(empty())).toBeFalsy(); + expect(isUpdateVisible(select(0))).toBe(false); + expect(isUpdateVisible(select(1))).toBe(true); + }); + + it("only enables 'Unset' for set quotas only", () => { + const isUnsetDisabled = actions[2].disable; + expect(isUnsetDisabled(empty())).toBe(true); + expect(isUnsetDisabled(select(0))).toBe(true); + expect(isUnsetDisabled(select(1))).toBe(false); + }); + + it('should test all quota table actions permission combinations', () => { + const permissionHelper: PermissionHelper = new PermissionHelper(component.permission); + const tableActions = permissionHelper.setPermissionsAndGetActions( + component.quota.tableActions + ); + + expect(tableActions).toEqual({ + 'create,update,delete': { + actions: ['Set', 'Update', 'Unset'], + primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' } + }, + 'create,update': { + actions: ['Set', 'Update', 'Unset'], + primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' } + }, + 'create,delete': { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + }, + create: { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + }, + 'update,delete': { + actions: ['Set', 'Update', 'Unset'], + primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' } + }, + update: { + actions: ['Set', 'Update', 'Unset'], + primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' } + }, + delete: { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + }, + 'no-permissions': { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + } + }); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts index 58f370de0b9..ca93b8e67f3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts @@ -1,26 +1,50 @@ import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { Validators } from '@angular/forms'; import { I18n } from '@ngx-translate/i18n-polyfill'; import * as _ from 'lodash'; import * as moment from 'moment'; import { NodeEvent, Tree, TreeComponent, TreeModel } from 'ng2-tree'; - import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; + import { CephfsService } from '../../../shared/api/cephfs.service'; +import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component'; import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component'; +import { ActionLabelsI18n } from '../../../shared/constants/app.constants'; import { Icons } from '../../../shared/enum/icons.enum'; import { NotificationType } from '../../../shared/enum/notification-type.enum'; +import { CdValidators } from '../../../shared/forms/cd-validators'; +import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config'; import { CdTableAction } from '../../../shared/models/cd-table-action'; import { CdTableColumn } from '../../../shared/models/cd-table-column'; import { CdTableSelection } from '../../../shared/models/cd-table-selection'; -import { CephfsDir, CephfsSnapshot } from '../../../shared/models/cephfs-directory-models'; +import { + CephfsDir, + CephfsQuotas, + CephfsSnapshot +} from '../../../shared/models/cephfs-directory-models'; import { Permission } from '../../../shared/models/permissions'; import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe'; import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; import { NotificationService } from '../../../shared/services/notification.service'; +class QuotaSetting { + row: { + // Shows quota that is used for current directory + name: string; + value: number | string; + originPath: string; + }; + quotaKey: string; + dirValue: number; + nextTreeMaximum: { + value: number; + path: string; + }; +} + @Component({ selector: 'cd-cephfs-directories', templateUrl: './cephfs-directories.component.html', @@ -43,12 +67,13 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { permission: Permission; selectedDir: CephfsDir; - settings: { - name: string; - value: number | string; - origin: string; - }[]; - settingsColumns: CdTableColumn[]; + settings: QuotaSetting[]; + quota: { + columns: CdTableColumn[]; + selection: CdTableSelection; + tableActions: CdTableAction[]; + updateSelection: Function; + }; snapshot: { columns: CdTableColumn[]; selection: CdTableSelection; @@ -63,32 +88,64 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { private cephfsService: CephfsService, private cdDatePipe: CdDatePipe, private i18n: I18n, + private actionLabels: ActionLabelsI18n, private notificationService: NotificationService, private dimlessBinaryPipe: DimlessBinaryPipe ) {} ngOnInit() { this.permission = this.authStorageService.getPermissions().cephfs; - this.settingsColumns = [ - { - prop: 'name', - name: this.i18n('Name'), - flexGrow: 1 - }, - { - prop: 'value', - name: this.i18n('Value'), - sortable: false, - flexGrow: 1 + this.quota = { + columns: [ + { + prop: 'row.name', + name: this.i18n('Name'), + flexGrow: 1 + }, + { + prop: 'row.value', + name: this.i18n('Value'), + sortable: false, + flexGrow: 1 + }, + { + prop: 'row.originPath', + name: this.i18n('Origin'), + sortable: false, + cellTemplate: this.originTmpl, + flexGrow: 1 + } + ], + selection: new CdTableSelection(), + updateSelection: (selection: CdTableSelection) => { + this.quota.selection = selection; }, - { - prop: 'origin', - name: this.i18n('Origin'), - sortable: false, - cellTemplate: this.originTmpl, - flexGrow: 1 - } - ]; + tableActions: [ + { + name: this.actionLabels.SET, + icon: Icons.edit, + permission: 'update', + visible: (selection) => + !selection.hasSelection || (selection.first() && selection.first().dirValue === 0), + click: () => this.updateQuotaModal() + }, + { + name: this.actionLabels.UPDATE, + icon: Icons.edit, + permission: 'update', + visible: (selection) => selection.first() && selection.first().dirValue > 0, + click: () => this.updateQuotaModal() + }, + { + name: this.actionLabels.UNSET, + icon: Icons.destroy, + permission: 'update', + disable: (selection) => + !selection.hasSelection || (selection.first() && selection.first().dirValue === 0), + click: () => this.unsetQuotaModal() + } + ] + }; this.snapshot = { columns: [ { @@ -115,14 +172,14 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { }, tableActions: [ { - name: this.i18n('Create'), + name: this.actionLabels.CREATE, icon: Icons.add, permission: 'create', canBePrimary: (selection) => !selection.hasSelection, click: () => this.createSnapshot() }, { - name: this.i18n('Delete'), + name: this.actionLabels.DELETE, icon: Icons.destroy, permission: 'delete', click: () => this.deleteSnapshotModal(), @@ -227,29 +284,53 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { } private setSettings(node: Tree) { - const files = this.getQuota(node, 'max_files'); - const size = this.getQuota(node, 'max_bytes'); + const readable = (value: number, fn?: (number) => number | string): number | string => + value ? (fn ? fn(value) : value) : ''; + this.settings = [ - { - name: 'Max files', - value: files.value, - origin: files.origin - }, - { - name: 'Max size', - value: size.value !== '' ? this.dimlessBinaryPipe.transform(size.value) : '', - origin: size.origin - } + this.getQuota(node, 'max_files', readable), + this.getQuota(node, 'max_bytes', (value) => + readable(value, (v) => this.dimlessBinaryPipe.transform(v)) + ) ]; } - private getQuota(tree: Tree, quotaSetting: string): { value: string; origin: string } { - tree = this.getOrigin(tree, quotaSetting); + private getQuota( + tree: Tree, + quotaKey: string, + valueConvertFn: (number) => number | string + ): QuotaSetting { + // Get current maximum + const currentPath = tree.id; + tree = this.getOrigin(tree, quotaKey); const dir = this.getDirectory(tree); - const value = dir.quotas[quotaSetting]; + const value = dir.quotas[quotaKey]; + // Get next tree maximum + // => The value that isn't changeable through a change of the current directories quota value + let nextMaxValue = value; + let nextMaxPath = dir.path; + if (tree.id === currentPath) { + if (tree.parent.value === '/') { + // The value will never inherit any other value, so it has no maximum. + nextMaxValue = 0; + } else { + const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey)); + nextMaxValue = nextMaxDir.quotas[quotaKey]; + nextMaxPath = nextMaxDir.path; + } + } return { - value: value ? value : '', - origin: value ? dir.path : '' + row: { + name: quotaKey === 'max_bytes' ? this.i18n('Max size') : this.i18n('Max files'), + value: valueConvertFn(value), + originPath: value ? dir.path : '' + }, + quotaKey, + dirValue: this.nodeIds[currentPath].quotas[quotaKey], + nextTreeMaximum: { + value: nextMaxValue, + path: nextMaxValue ? nextMaxPath : '' + } }; } @@ -274,6 +355,133 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { return this.nodeIds[path]; } + updateQuotaModal() { + const path = this.selectedDir.path; + const selection: QuotaSetting = this.quota.selection.first(); + const nextMax = selection.nextTreeMaximum; + const key = selection.quotaKey; + const value = selection.dirValue; + this.modalService.show(FormModalComponent, { + initialState: { + titleText: this.getModalQuotaTitle( + value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE, + path + ), + message: nextMax.value + ? this.i18n('The inherited {{quotaValue}} is the maximum value to be used.', { + quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path) + }) + : undefined, + fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)], + submitButtonText: 'Save', + onSubmit: (values) => this.updateQuota(values) + } + }); + } + + private getModalQuotaTitle(action: string, path: string): string { + return this.i18n("{{action}} CephFS {{quotaName}} quota for '{{path}}'", { + action, + quotaName: this.getQuotaName(), + path + }); + } + + private getQuotaName(): string { + return this.isBytesQuotaSelected() ? this.i18n('size') : this.i18n('files'); + } + + private isBytesQuotaSelected(): boolean { + return this.quota.selection.first().quotaKey === 'max_bytes'; + } + + private getQuotaValueFromPathMsg(value: number, path: string): string { + return this.i18n("{{quotaName}} quota {{value}} from '{{path}}'", { + value: this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value, + quotaName: this.getQuotaName(), + path + }); + } + + private getQuotaFormField( + label: string, + name: string, + value: number, + maxValue: number + ): CdFormModalFieldConfig { + const isBinary = name === 'max_bytes'; + const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)]; + if (maxValue) { + formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue)); + } + const field: CdFormModalFieldConfig = { + type: isBinary ? 'binary' : 'number', + label, + name, + value, + validators: formValidators, + required: true + }; + if (!isBinary) { + field.errors = { + min: this.i18n(`Value has to be at least {{value}} or more`, { value: 0 }), + max: this.i18n(`Value has to be at most {{value}} or less`, { value: maxValue }) + }; + } + return field; + } + + private updateQuota(values: CephfsQuotas, onSuccess?: Function) { + const path = this.selectedDir.path; + const key = this.quota.selection.first().quotaKey; + const action = + this.selectedDir.quotas[key] === 0 + ? this.actionLabels.SET + : values[key] === 0 + ? this.actionLabels.UNSET + : this.i18n('Updated'); + this.cephfsService.updateQuota(this.id, path, values).subscribe(() => { + if (onSuccess) { + onSuccess(); + } + this.notificationService.show( + NotificationType.success, + this.getModalQuotaTitle(action, path) + ); + this.forceDirRefresh(); + }); + } + + unsetQuotaModal() { + const path = this.selectedDir.path; + const selection: QuotaSetting = this.quota.selection.first(); + const key = selection.quotaKey; + const nextMax = selection.nextTreeMaximum; + const dirValue = selection.dirValue; + + this.modalRef = this.modalService.show(ConfirmationModalComponent, { + initialState: { + titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path), + buttonText: this.actionLabels.UNSET, + description: this.i18n(`{{action}} {{quotaValue}} {{conclusion}}.`, { + action: this.actionLabels.UNSET, + quotaValue: this.getQuotaValueFromPathMsg(dirValue, path), + conclusion: + nextMax.value > 0 + ? nextMax.value > dirValue + ? this.i18n('in order to inherit {{quotaValue}}', { + quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path) + }) + : this.i18n("which isn't used because of the inheritance of {{quotaValue}}", { + quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path) + }) + : this.i18n('in order to have no quota on the directory') + }), + onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.hide()) + } + }); + } + createSnapshot() { // Create a snapshot. Auto-generate a snapshot name by default. const path = this.selectedDir.path; @@ -283,7 +491,7 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { message: this.i18n('Please enter the name of the snapshot.'), fields: [ { - type: 'inputText', + type: 'text', name: 'name', value: `${moment().toISOString(true)}`, required: true @@ -314,17 +522,19 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { */ private forceDirRefresh() { const path = this.selectedNode.parent.id as string; - this.cephfsService.lsDir(this.id, path).subscribe((data) => + this.cephfsService.lsDir(this.id, path).subscribe((data) => { data.forEach((d) => { Object.assign(this.dirs.find((sub) => sub.path === d.path), d); - }) - ); + }); + // Now update quotas + this.setSettings(this.selectedNode); + }); } deleteSnapshotModal() { this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { initialState: { - itemDescription: 'CephFs Snapshot', + itemDescription: this.i18n('CephFs Snapshot'), itemNames: this.snapshot.selection.selected.map( (snapshot: CephfsSnapshot) => snapshot.name ), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts index f348584ca29..8c10ed127ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -78,4 +78,21 @@ describe('CephfsService', () => { const req = httpTesting.expectOne('api/cephfs/1/rm_snapshot?path=%252Fsome%252Fpath&name=snap'); expect(req.request.method).toBe('POST'); }); + + it('should call updateQuota', () => { + service.updateQuota(1, '/some/path', { max_bytes: 1024 }).subscribe(); + let req = httpTesting.expectOne('api/cephfs/1/set_quotas?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ max_bytes: 1024 }); + + service.updateQuota(1, '/some/path', { max_files: 10 }).subscribe(); + req = httpTesting.expectOne('api/cephfs/1/set_quotas?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ max_files: 10 }); + + service.updateQuota(1, '/some/path', { max_bytes: 1024, max_files: 10 }).subscribe(); + req = httpTesting.expectOne('api/cephfs/1/set_quotas?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ max_bytes: 1024, max_files: 10 }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index 1c1da64ae77..b851610103e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -5,7 +5,7 @@ import * as _ from 'lodash'; import { Observable } from 'rxjs'; import { cdEncode } from '../decorators/cd-encode'; -import { CephfsDir } from '../models/cephfs-directory-models'; +import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models'; import { ApiModule } from './api.module'; @cdEncode @@ -55,13 +55,22 @@ export class CephfsService { if (!_.isUndefined(name)) { params = params.append('name', name); } - return this.http.post(`${this.baseURL}/${id}/mk_snapshot`, null, { params: params }); + return this.http.post(`${this.baseURL}/${id}/mk_snapshot`, null, { params }); } rmSnapshot(id, path, name) { let params = new HttpParams(); params = params.append('path', path); params = params.append('name', name); - return this.http.post(`${this.baseURL}/${id}/rm_snapshot`, null, { params: params }); + return this.http.post(`${this.baseURL}/${id}/rm_snapshot`, null, { params }); + } + + updateQuota(id, path, quotas: CephfsQuotas) { + let params = new HttpParams(); + params = params.append('path', path); + return this.http.post(`${this.baseURL}/${id}/set_quotas`, quotas, { + observe: 'response', + params + }); } } 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 53f2c124108..cb99bd6338e 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 @@ -82,11 +82,13 @@ export class ActionLabelsI18n { REMOVE: string; EDIT: string; CANCEL: string; + CHANGE: string; COPY: string; CLONE: string; DEEP_SCRUB: string; DESTROY: string; EVICT: string; + EXPIRE: string; FLATTEN: string; MARK_DOWN: string; MARK_IN: string; @@ -94,17 +96,18 @@ export class ActionLabelsI18n { MARK_OUT: string; PROTECT: string; PURGE: string; + RECREATE: string; RENAME: string; RESTORE: string; REWEIGHT: string; ROLLBACK: string; SCRUB: string; + SET: string; SHOW: string; TRASH: string; UNPROTECT: string; - RECREATE: string; - EXPIRE: string; - CHANGE: string; + UNSET: string; + UPDATE: string; constructor(private i18n: I18n) { /* Create a new item */ @@ -115,12 +118,15 @@ export class ActionLabelsI18n { /* Add an existing item to a container */ this.ADD = this.i18n('Add'); + this.SET = this.i18n('Set'); /* Remove an item from a container WITHOUT deleting it */ this.REMOVE = this.i18n('Remove'); + this.UNSET = this.i18n('Unset'); /* Make changes to an existing item */ this.EDIT = this.i18n('Edit'); + this.UPDATE = this.i18n('Update'); this.CANCEL = this.i18n('Cancel'); /* Non-standard actions */ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index 3a95e7ddfe0..9cbe33d4474 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -3,6 +3,12 @@ i18n>Failed to load data.
+
+
+ +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index 36d32cf81fd..0cb500440b5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -68,6 +68,9 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O // Method used for setting column widths. @Input() columnMode? = 'flex'; + // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions" + @Input() + onlyActionHeader? = false; // Display the tool header, including reload button, pagination and search fields? @Input() toolHeader? = true; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts index f584d1f4bdf..92186aecc96 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts @@ -7,8 +7,8 @@ export class CephfsSnapshot { } export class CephfsQuotas { - max_bytes: number; - max_files: number; + max_bytes?: number; + max_files?: number; } export class CephfsDir { diff --git a/src/pybind/mgr/dashboard/services/cephfs.py b/src/pybind/mgr/dashboard/services/cephfs.py index 926ab32cac4..75ac8ba3dc7 100644 --- a/src/pybind/mgr/dashboard/services/cephfs.py +++ b/src/pybind/mgr/dashboard/services/cephfs.py @@ -224,7 +224,9 @@ class CephFS(object): :param max_files: The file limit. :type max_files: int | None """ - self.cfs.setxattr(path, 'ceph.quota.max_bytes', - str(max_bytes if max_bytes else 0).encode(), 0) - self.cfs.setxattr(path, 'ceph.quota.max_files', - str(max_files if max_files else 0).encode(), 0) + if max_bytes is not None: + self.cfs.setxattr(path, 'ceph.quota.max_bytes', + str(max_bytes).encode(), 0) + if max_files is not None: + self.cfs.setxattr(path, 'ceph.quota.max_files', + str(max_files).encode(), 0)