From d817a24e345516229bc637e2c675d12e6bfcc456 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Alfonso=20Mart=C3=ADnez?= Date: Thu, 4 Nov 2021 14:56:37 +0100 Subject: [PATCH] mgr/dashboard: NFS 'create export' form: fixes MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * Do not allow a pseudo that is already in use by another export. * Create mode form: prefill dropdown selectors if options > 0. * Edit mode form: do not reset the field values that depend on other values that are being edited (unlike Create mode). * Fix broken link: cluster service. * Fix error message style for non-existent cephfs path. * nfs-service.ts: lsDir: thow error if volume is not provided. * File renaming: nfsganesha.py => nfs.py; test_ganesha.py => test_nfs.py Fixes: https://tracker.ceph.com/issues/53083 Signed-off-by: Alfonso Martínez --- .../controllers/{nfsganesha.py => nfs.py} | 18 +- .../ceph/nfs/nfs-form/nfs-form.component.html | 11 +- .../nfs/nfs-form/nfs-form.component.spec.ts | 6 +- .../ceph/nfs/nfs-form/nfs-form.component.ts | 69 ++++-- .../src/app/shared/api/nfs.service.spec.ts | 10 +- .../src/app/shared/api/nfs.service.ts | 5 +- .../mgr/dashboard/plugins/feature_toggles.py | 2 +- src/pybind/mgr/dashboard/tests/test_auth.py | 4 +- .../mgr/dashboard/tests/test_ganesha.py | 136 ----------- src/pybind/mgr/dashboard/tests/test_nfs.py | 229 ++++++++++++++++++ src/pybind/mgr/nfs/export.py | 8 + 11 files changed, 318 insertions(+), 180 deletions(-) rename src/pybind/mgr/dashboard/controllers/{nfsganesha.py => nfs.py} (94%) delete mode 100644 src/pybind/mgr/dashboard/tests/test_ganesha.py create mode 100644 src/pybind/mgr/dashboard/tests/test_nfs.py diff --git a/src/pybind/mgr/dashboard/controllers/nfsganesha.py b/src/pybind/mgr/dashboard/controllers/nfs.py similarity index 94% rename from src/pybind/mgr/dashboard/controllers/nfsganesha.py rename to src/pybind/mgr/dashboard/controllers/nfs.py index c48b0667b8afb..5c9f05c3ed700 100644 --- a/src/pybind/mgr/dashboard/controllers/nfsganesha.py +++ b/src/pybind/mgr/dashboard/controllers/nfs.py @@ -7,7 +7,6 @@ from functools import partial from typing import Any, Dict, List, Optional import cephfs -import cherrypy from mgr_module import NFS_GANESHA_SUPPORTED_FSALS from .. import mgr @@ -149,7 +148,10 @@ class NFSGaneshaExports(RESTController): @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore def create(self, path, cluster_id, pseudo, access_type, squash, security_label, protocols, transports, fsal, clients) -> Dict[str, Any]: - + export_mgr = mgr.remote('nfs', 'fetch_nfs_export_obj') + if export_mgr.get_export_by_pseudo(cluster_id, pseudo): + raise DashboardException(msg=f'Pseudo {pseudo} is already in use.', + component='nfs') if hasattr(fsal, 'user_id'): fsal.pop('user_id') # mgr/nfs does not let you customize user_id raw_ex = { @@ -164,11 +166,10 @@ class NFSGaneshaExports(RESTController): 'fsal': fsal, 'clients': clients } - export_mgr = mgr.remote('nfs', 'fetch_nfs_export_obj') ret, _, err = export_mgr.apply_export(cluster_id, json.dumps(raw_ex)) if ret == 0: return self._get_schema_export( - export_mgr._get_export_dict(cluster_id, pseudo)) # pylint: disable=W0212 + export_mgr.get_export_by_pseudo(cluster_id, pseudo)) raise NFSException(f"Export creation failed {err}") @EndpointDoc("Get an NFS-Ganesha export", @@ -215,7 +216,7 @@ class NFSGaneshaExports(RESTController): ret, _, err = export_mgr.apply_export(cluster_id, json.dumps(raw_ex)) if ret == 0: return self._get_schema_export( - export_mgr._get_export_dict(cluster_id, pseudo)) # pylint: disable=W0212 + export_mgr.get_export_by_pseudo(cluster_id, pseudo)) raise NFSException(f"Failed to update export: {err}") @NfsTask('delete', {'cluster_id': '{cluster_id}', @@ -231,7 +232,10 @@ class NFSGaneshaExports(RESTController): export = mgr.remote('nfs', 'export_get', cluster_id, export_id) if not export: - raise cherrypy.HTTPError(404) # pragma: no cover - the handling is too obvious + raise DashboardException( + http_status_code=404, + msg=f'Export with id {export_id} not found.', + component='nfs') mgr.remote('nfs', 'export_rm', cluster_id, export['pseudo']) @@ -265,7 +269,7 @@ class NFSGaneshaUi(BaseController): finally: if error_msg: raise DashboardException(code=400, - component='nfsganesha', + component='nfs', msg=error_msg) try: diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html index 3e390db7335e9..97a59ae326065 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html @@ -39,7 +39,7 @@ This field is required. - To create a new NFS cluster, add a new NFS Service. @@ -79,7 +79,7 @@ - +
@@ -252,6 +252,9 @@ This field is required. + The pseudo is already in use by another export. Pseudo needs to start with a '/' and can't contain any of the following: >, <, |, &, ( or ). diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts index 4bf34e2c7a0d1..7cf3d61387a43 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts @@ -74,14 +74,14 @@ describe('NfsFormComponent', () => { expect(component.nfsForm.value).toEqual({ access_type: 'RW', clients: [], - cluster_id: '', - fsal: { fs_name: 'a', name: '' }, + cluster_id: 'mynfs', + fsal: { fs_name: 'a', name: 'CEPH' }, path: '/', protocolNfsv4: true, pseudo: '', sec_label_xattr: 'security.selinux', security_label: false, - squash: '', + squash: 'no_root_squash', transportTCP: true, transportUDP: true }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts index 46eeeec52cb22..a56c1105e08e4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts @@ -24,6 +24,7 @@ import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { CdValidators } from '~/app/shared/forms/cd-validators'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { Permission } from '~/app/shared/models/permissions'; +import { CdHttpErrorResponse } from '~/app/shared/services/api-interceptor.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { NfsFormClientComponent } from '../nfs-form-client/nfs-form-client.component'; @@ -163,7 +164,7 @@ export class NfsFormComponent extends CdForm implements OnInit { access_type: new FormControl('RW', { validators: [Validators.required] }), - squash: new FormControl('', { + squash: new FormControl(this.nfsSquash[0], { validators: [Validators.required] }), transportUDP: new FormControl(true, { @@ -222,6 +223,9 @@ export class NfsFormComponent extends CdForm implements OnInit { for (const cluster of clusters) { this.allClusters.push({ cluster_id: cluster }); } + if (!this.isEdit && this.allClusters.length > 0) { + this.nfsForm.get('cluster_id').setValue(this.allClusters[0].cluster_id); + } } resolveFsals(res: string[]) { @@ -234,17 +238,18 @@ export class NfsFormComponent extends CdForm implements OnInit { this.allFsals.push(fsalItem); } }); - - if (this.allFsals.length === 1 && _.isUndefined(this.nfsForm.getValue('fsal'))) { + if (!this.isEdit && this.allFsals.length > 0) { this.nfsForm.patchValue({ - fsal: this.allFsals[0] + fsal: { + name: this.allFsals[0].value + } }); } } resolveFilesystems(filesystems: any[]) { this.allFsNames = filesystems; - if (filesystems.length === 1) { + if (!this.isEdit && filesystems.length > 0) { this.nfsForm.patchValue({ fsal: { fs_name: filesystems[0].name @@ -254,6 +259,7 @@ export class NfsFormComponent extends CdForm implements OnInit { } fsalChangeHandler() { + this.setPathValidation(); const fsalValue = this.nfsForm.getValue('name'); const checkAvailability = fsalValue === 'RGW' @@ -276,13 +282,13 @@ export class NfsFormComponent extends CdForm implements OnInit { checkAvailability.subscribe({ next: () => { this.setFsalAvailability(fsalValue, true); - this.nfsForm.patchValue({ - path: fsalValue === 'RGW' ? '' : '/', - pseudo: this.generatePseudo(), - access_type: this.updateAccessType() - }); - - this.setPathValidation(); + if (!this.isEdit) { + this.nfsForm.patchValue({ + path: fsalValue === 'RGW' ? '' : '/', + pseudo: this.generatePseudo(), + access_type: this.updateAccessType() + }); + } this.cdRef.detectChanges(); }, @@ -360,9 +366,11 @@ export class NfsFormComponent extends CdForm implements OnInit { } pathChangeHandler() { - this.nfsForm.patchValue({ - pseudo: this.generatePseudo() - }); + if (!this.isEdit) { + this.nfsForm.patchValue({ + pseudo: this.generatePseudo() + }); + } } private getBucketTypeahead(path: string): Observable { @@ -430,11 +438,23 @@ export class NfsFormComponent extends CdForm implements OnInit { } action.subscribe({ - error: () => this.nfsForm.setErrors({ cdSubmitButton: true }), + error: (errorResponse: CdHttpErrorResponse) => this.setFormErrors(errorResponse), complete: () => this.router.navigate(['/nfs']) }); } + private setFormErrors(errorResponse: CdHttpErrorResponse) { + if ( + errorResponse.error.detail && + errorResponse.error.detail + .toString() + .includes(`Pseudo ${this.nfsForm.getValue('pseudo')} is already in use`) + ) { + this.nfsForm.get('pseudo').setErrors({ pseudoAlreadyExists: true }); + } + this.nfsForm.setErrors({ cdSubmitButton: true }); + } + private buildRequest() { const requestModel: any = _.cloneDeep(this.nfsForm.value); @@ -500,15 +520,14 @@ export class NfsFormComponent extends CdForm implements OnInit { return of({ required: true }); } const fsName = this.nfsForm.getValue('fsal').fs_name; - return this.nfsService - .lsDir(fsName, control.value) - .pipe( - map((directory: Directory) => - directory.paths.includes(control.value) === requiredExistenceResult - ? null - : { pathNameNotAllowed: true } - ) - ); + return this.nfsService.lsDir(fsName, control.value).pipe( + map((directory: Directory) => + directory.paths.includes(control.value) === requiredExistenceResult + ? null + : { pathNameNotAllowed: true } + ), + catchError(() => of({ pathNameNotAllowed: true })) + ); }; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts index c977f7ec5b514..139fa490bfd82 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts @@ -1,5 +1,5 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { configureTestBed } from '~/testing/unit-test-helper'; import { NfsService } from './nfs.service'; @@ -63,4 +63,12 @@ describe('NfsService', () => { const req = httpTesting.expectOne('ui-api/nfs-ganesha/lsdir/a?root_dir=foo_dir'); expect(req.request.method).toBe('GET'); }); + + it('should not call lsDir if volume is not provided', fakeAsync(() => { + service.lsDir('', 'foo_dir').subscribe({ + error: (error: string) => expect(error).toEqual('Please specify a filesystem volume.') + }); + tick(); + httpTesting.expectNone('ui-api/nfs-ganesha/lsdir/?root_dir=foo_dir'); + })); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts index 88af7a68216f0..636335673978f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; import { NfsFSAbstractionLayer } from '~/app/ceph/nfs/models/nfs.fsal'; import { ApiClient } from '~/app/shared/api/api-client'; @@ -87,6 +87,9 @@ export class NfsService extends ApiClient { } lsDir(fs_name: string, root_dir: string): Observable { + if (!fs_name) { + return throwError($localize`Please specify a filesystem volume.`); + } return this.http.get(`${this.uiApiPath}/lsdir/${fs_name}?root_dir=${root_dir}`); } diff --git a/src/pybind/mgr/dashboard/plugins/feature_toggles.py b/src/pybind/mgr/dashboard/plugins/feature_toggles.py index c9e7a472b7291..c71d325476774 100644 --- a/src/pybind/mgr/dashboard/plugins/feature_toggles.py +++ b/src/pybind/mgr/dashboard/plugins/feature_toggles.py @@ -8,7 +8,7 @@ from mgr_module import CLICommand, Option from ..controllers.cephfs import CephFS from ..controllers.iscsi import Iscsi, IscsiTarget -from ..controllers.nfsganesha import NFSGanesha, NFSGaneshaExports +from ..controllers.nfs import NFSGanesha, NFSGaneshaExports from ..controllers.rbd import Rbd, RbdSnapshot, RbdTrash from ..controllers.rbd_mirroring import RbdMirroringPoolMode, \ RbdMirroringPoolPeer, RbdMirroringSummary diff --git a/src/pybind/mgr/dashboard/tests/test_auth.py b/src/pybind/mgr/dashboard/tests/test_auth.py index 698db40e1e5ef..d9755de98e45a 100644 --- a/src/pybind/mgr/dashboard/tests/test_auth.py +++ b/src/pybind/mgr/dashboard/tests/test_auth.py @@ -41,7 +41,7 @@ class AuthTest(ControllerTestCase): @patch('dashboard.controllers.auth.JwtManager.gen_token', Mock(return_value='my-token')) @patch('dashboard.controllers.auth.AuthManager.authenticate', Mock(return_value={ - 'permissions': {'read-only': ['read']}, + 'permissions': {'rgw': ['read']}, 'pwdExpirationDate': 1000000, 'pwdUpdateRequired': False })) @@ -51,7 +51,7 @@ class AuthTest(ControllerTestCase): self.assertJsonBody({ 'token': 'my-token', 'username': 'my-user', - 'permissions': {'read-only': ['read']}, + 'permissions': {'rgw': ['read']}, 'pwdExpirationDate': 1000000, 'sso': False, 'pwdUpdateRequired': False diff --git a/src/pybind/mgr/dashboard/tests/test_ganesha.py b/src/pybind/mgr/dashboard/tests/test_ganesha.py deleted file mode 100644 index f1fe3ecd853e5..0000000000000 --- a/src/pybind/mgr/dashboard/tests/test_ganesha.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=too-many-lines - -from unittest.mock import patch -from urllib.parse import urlencode - -from ..controllers.nfsganesha import NFSGaneshaExports, NFSGaneshaUi -from . import ControllerTestCase # pylint: disable=no-name-in-module - - -class NFSGaneshaExportsTest(ControllerTestCase): - - def test_get_schema_export(self): - export = { - "export_id": 2, - "path": "bk1", - "cluster_id": "myc", - "pseudo": "/bk-ps", - "access_type": "RO", - "squash": "root_id_squash", - "security_label": False, - "protocols": [ - 4 - ], - "transports": [ - "TCP", - "UDP" - ], - "fsal": { - "name": "RGW", - "user_id": "dashboard", - "access_key_id": "UUU5YVVOQ2P5QTOPYNAN", - "secret_access_key": "7z87tMUUsHr67ZWx12pCbWkp9UyOldxhDuPY8tVN" - }, - "clients": [] - } - expected_schema_export = export - del expected_schema_export['fsal']['access_key_id'] - del expected_schema_export['fsal']['secret_access_key'] - self.assertDictEqual( - expected_schema_export, - NFSGaneshaExports._get_schema_export(export)) # pylint: disable=protected-access - - -class NFSGaneshaUiControllerTest(ControllerTestCase): - @classmethod - def setup_server(cls): - # pylint: disable=protected-access - NFSGaneshaUi._cp_config['tools.authenticate.on'] = False - cls.setup_controllers([NFSGaneshaUi]) - - @classmethod - def _create_ls_dir_url(cls, fs_name, query_params): - api_url = '/ui-api/nfs-ganesha/lsdir/{}'.format(fs_name) - if query_params is not None: - return '{}?{}'.format(api_url, urlencode(query_params)) - return api_url - - @patch('dashboard.controllers.nfsganesha.CephFS') - def test_lsdir(self, cephfs_class): - cephfs_class.return_value.ls_dir.return_value = [ - {'path': '/foo'}, - {'path': '/foo/bar'} - ] - mocked_ls_dir = cephfs_class.return_value.ls_dir - - reqs = [ - { - 'params': None, - 'cephfs_ls_dir_args': ['/', 1], - 'path0': '/', - 'status': 200 - }, - { - 'params': {'root_dir': '/', 'depth': '1'}, - 'cephfs_ls_dir_args': ['/', 1], - 'path0': '/', - 'status': 200 - }, - { - 'params': {'root_dir': '', 'depth': '1'}, - 'cephfs_ls_dir_args': ['/', 1], - 'path0': '/', - 'status': 200 - }, - { - 'params': {'root_dir': '/foo', 'depth': '3'}, - 'cephfs_ls_dir_args': ['/foo', 3], - 'path0': '/foo', - 'status': 200 - }, - { - 'params': {'root_dir': 'foo', 'depth': '6'}, - 'cephfs_ls_dir_args': ['/foo', 5], - 'path0': '/foo', - 'status': 200 - }, - { - 'params': {'root_dir': '/', 'depth': '-1'}, - 'status': 400 - }, - { - 'params': {'root_dir': '/', 'depth': 'abc'}, - 'status': 400 - } - ] - - for req in reqs: - self._get(self._create_ls_dir_url('a', req['params'])) - self.assertStatus(req['status']) - - # Returned paths should contain root_dir as first element - if req['status'] == 200: - paths = self.json_body()['paths'] - self.assertEqual(paths[0], req['path0']) - cephfs_class.assert_called_once_with('a') - - # Check the arguments passed to `CephFS.ls_dir`. - if req.get('cephfs_ls_dir_args'): - mocked_ls_dir.assert_called_once_with(*req['cephfs_ls_dir_args']) - else: - mocked_ls_dir.assert_not_called() - mocked_ls_dir.reset_mock() - cephfs_class.reset_mock() - - @patch('dashboard.controllers.nfsganesha.cephfs') - @patch('dashboard.controllers.nfsganesha.CephFS') - def test_lsdir_non_existed_dir(self, cephfs_class, cephfs): - cephfs.ObjectNotFound = Exception - cephfs.PermissionError = Exception - cephfs_class.return_value.ls_dir.side_effect = cephfs.ObjectNotFound() - self._get(self._create_ls_dir_url('a', {'root_dir': '/foo', 'depth': '3'})) - cephfs_class.assert_called_once_with('a') - cephfs_class.return_value.ls_dir.assert_called_once_with('/foo', 3) - self.assertStatus(200) - self.assertJsonBody({'paths': []}) diff --git a/src/pybind/mgr/dashboard/tests/test_nfs.py b/src/pybind/mgr/dashboard/tests/test_nfs.py new file mode 100644 index 0000000000000..087ca18914b95 --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_nfs.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +# pylint: disable=too-many-lines +from copy import deepcopy +from unittest.mock import Mock, patch +from urllib.parse import urlencode + +from .. import mgr +from ..controllers._version import APIVersion +from ..controllers.nfs import NFSGaneshaExports, NFSGaneshaUi +from ..tests import ControllerTestCase +from ..tools import NotificationQueue, TaskManager + + +class NFSGaneshaExportsTest(ControllerTestCase): + _nfs_module_export = { + "export_id": 1, + "path": "bk1", + "cluster_id": "myc", + "pseudo": "/bk-ps", + "access_type": "RO", + "squash": "root_id_squash", + "security_label": False, + "protocols": [ + 4 + ], + "transports": [ + "TCP", + "UDP" + ], + "fsal": { + "name": "RGW", + "user_id": "dashboard", + "access_key_id": "UUU5YVVOQ2P5QTOPYNAN", + "secret_access_key": "7z87tMUUsHr67ZWx12pCbWkp9UyOldxhDuPY8tVN" + }, + "clients": [] + } + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._expected_export = deepcopy(cls._nfs_module_export) + del cls._expected_export['fsal']['access_key_id'] + del cls._expected_export['fsal']['secret_access_key'] + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + NotificationQueue.stop() + + @classmethod + def setup_server(cls): + NotificationQueue.start_queue() + TaskManager.init() + cls.setup_controllers([NFSGaneshaExports]) + + def test_list_exports(self): + mgr.remote = Mock(return_value=[self._nfs_module_export]) + + self._get('/api/nfs-ganesha/export') + self.assertStatus(200) + self.assertJsonBody([self._expected_export]) + + def test_get_export(self): + mgr.remote = Mock(return_value=self._nfs_module_export) + + self._get('/api/nfs-ganesha/export/myc/1') + self.assertStatus(200) + self.assertJsonBody(self._expected_export) + + def test_create_export(self): + export_mgr = Mock() + created_nfs_export = deepcopy(self._nfs_module_export) + created_nfs_export['pseudo'] = 'new-pseudo' + created_nfs_export['export_id'] = 2 + export_mgr.get_export_by_pseudo.side_effect = [None, created_nfs_export] + export_mgr.apply_export.return_value = (0, '', '') + mgr.remote.return_value = export_mgr + + export_create_body = deepcopy(self._expected_export) + del export_create_body['export_id'] + export_create_body['pseudo'] = created_nfs_export['pseudo'] + + self._post('/api/nfs-ganesha/export', + export_create_body, + version=APIVersion(2, 0)) + self.assertStatus(201) + expected_body = export_create_body + expected_body['export_id'] = created_nfs_export['export_id'] + self.assertJsonBody(export_create_body) + + def test_create_export_with_existing_pseudo_fails(self): + export_mgr = Mock() + export_mgr.get_export_by_pseudo.return_value = self._nfs_module_export + mgr.remote.return_value = export_mgr + + export_create_body = deepcopy(self._expected_export) + del export_create_body['export_id'] + + self._post('/api/nfs-ganesha/export', + export_create_body, + version=APIVersion(2, 0)) + self.assertStatus(400) + response = self.json_body() + self.assertIn(f'Pseudo {export_create_body["pseudo"]} is already in use', + response['detail']) + + def test_set_export(self): + export_mgr = Mock() + updated_nfs_export = deepcopy(self._nfs_module_export) + updated_nfs_export['pseudo'] = 'updated-pseudo' + export_mgr.get_export_by_pseudo.return_value = updated_nfs_export + export_mgr.apply_export.return_value = (0, '', '') + mgr.remote.return_value = export_mgr + + updated_export_body = deepcopy(self._expected_export) + updated_export_body['pseudo'] = updated_nfs_export['pseudo'] + + self._put('/api/nfs-ganesha/export/myc/2', + updated_export_body, + version=APIVersion(2, 0)) + self.assertStatus(200) + self.assertJsonBody(updated_export_body) + + def test_delete_export(self): + mgr.remote = Mock(side_effect=[self._nfs_module_export, None]) + + self._delete('/api/nfs-ganesha/export/myc/2', + version=APIVersion(2, 0)) + self.assertStatus(204) + + def test_delete_export_not_found(self): + mgr.remote = Mock(return_value=None) + + self._delete('/api/nfs-ganesha/export/myc/3', + version=APIVersion(2, 0)) + self.assertStatus(404) + + +class NFSGaneshaUiControllerTest(ControllerTestCase): + @classmethod + def setup_server(cls): + cls.setup_controllers([NFSGaneshaUi]) + + @classmethod + def _create_ls_dir_url(cls, fs_name, query_params): + api_url = '/ui-api/nfs-ganesha/lsdir/{}'.format(fs_name) + if query_params is not None: + return '{}?{}'.format(api_url, urlencode(query_params)) + return api_url + + @patch('dashboard.controllers.nfs.CephFS') + def test_lsdir(self, cephfs_class): + cephfs_class.return_value.ls_dir.return_value = [ + {'path': '/foo'}, + {'path': '/foo/bar'} + ] + mocked_ls_dir = cephfs_class.return_value.ls_dir + + reqs = [ + { + 'params': None, + 'cephfs_ls_dir_args': ['/', 1], + 'path0': '/', + 'status': 200 + }, + { + 'params': {'root_dir': '/', 'depth': '1'}, + 'cephfs_ls_dir_args': ['/', 1], + 'path0': '/', + 'status': 200 + }, + { + 'params': {'root_dir': '', 'depth': '1'}, + 'cephfs_ls_dir_args': ['/', 1], + 'path0': '/', + 'status': 200 + }, + { + 'params': {'root_dir': '/foo', 'depth': '3'}, + 'cephfs_ls_dir_args': ['/foo', 3], + 'path0': '/foo', + 'status': 200 + }, + { + 'params': {'root_dir': 'foo', 'depth': '6'}, + 'cephfs_ls_dir_args': ['/foo', 5], + 'path0': '/foo', + 'status': 200 + }, + { + 'params': {'root_dir': '/', 'depth': '-1'}, + 'status': 400 + }, + { + 'params': {'root_dir': '/', 'depth': 'abc'}, + 'status': 400 + } + ] + + for req in reqs: + self._get(self._create_ls_dir_url('a', req['params'])) + self.assertStatus(req['status']) + + # Returned paths should contain root_dir as first element + if req['status'] == 200: + paths = self.json_body()['paths'] + self.assertEqual(paths[0], req['path0']) + cephfs_class.assert_called_once_with('a') + + # Check the arguments passed to `CephFS.ls_dir`. + if req.get('cephfs_ls_dir_args'): + mocked_ls_dir.assert_called_once_with(*req['cephfs_ls_dir_args']) + else: + mocked_ls_dir.assert_not_called() + mocked_ls_dir.reset_mock() + cephfs_class.reset_mock() + + @patch('dashboard.controllers.nfs.cephfs') + @patch('dashboard.controllers.nfs.CephFS') + def test_lsdir_non_existed_dir(self, cephfs_class, cephfs): + cephfs.ObjectNotFound = Exception + cephfs.PermissionError = Exception + cephfs_class.return_value.ls_dir.side_effect = cephfs.ObjectNotFound() + self._get(self._create_ls_dir_url('a', {'root_dir': '/foo', 'depth': '3'})) + cephfs_class.assert_called_once_with('a') + cephfs_class.return_value.ls_dir.assert_called_once_with('/foo', 3) + self.assertStatus(200) + self.assertJsonBody({'paths': []}) diff --git a/src/pybind/mgr/nfs/export.py b/src/pybind/mgr/nfs/export.py index b23a95e04632d..eeb57f8b17eb2 100644 --- a/src/pybind/mgr/nfs/export.py +++ b/src/pybind/mgr/nfs/export.py @@ -420,6 +420,14 @@ class ExportMgr: export = self._fetch_export_id(cluster_id, export_id) return export.to_dict() if export else None + def get_export_by_pseudo( + self, + cluster_id: str, + pseudo_path: str + ) -> Optional[Dict[str, Any]]: + export = self._fetch_export(cluster_id, pseudo_path) + return export.to_dict() if export else None + def apply_export(self, cluster_id: str, export_config: str) -> Tuple[int, str, str]: try: if not export_config: -- 2.39.5