From 94fe271b06f1e87d37850ac20dd31fa2314e8dfe Mon Sep 17 00:00:00 2001 From: =?utf8?q?Alfonso=20Mart=C3=ADnez?= Date: Wed, 24 Feb 2021 08:20:53 +0100 Subject: [PATCH] mgr/dashboard: select any object gateway on local cluster. MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Dashboard backend settings: - Refactoring: now accepting more than 1 type of value. - RGW_API_ACCESS_KEY & RGW_API_SECRET_KEY accept string (backward compatibility: legacy behavior) as well as dictionary of strings for connecting multiple daemons. - Ease of use: deprecated: mgr/dashboard/RGW_API_USER_ID: not useful anymore (kept for backward compatibility). UI/UX: - Created context component (to be shown only on rgw-related routes) for selecting operating daemon. - Daemon selector only shown if there is more than 1 daemon running on a local cluster (to reduce cognitive load). Fixes: https://tracker.ceph.com/issues/47375 Signed-off-by: Alfonso Martínez --- doc/mgr/dashboard.rst | 14 +- qa/tasks/mgr/dashboard/test_rgw.py | 14 +- src/pybind/mgr/dashboard/controllers/rgw.py | 158 ++++++------ .../mgr/dashboard/controllers/settings.py | 6 +- .../nfs/nfs-form/nfs-form.component.spec.ts | 9 +- .../src/app/ceph/rgw/models/rgw-daemon.ts | 8 + .../rgw-daemon-list.component.ts | 14 +- .../frontend/src/app/ceph/rgw/rgw.module.ts | 4 +- .../app/core/context/context.component.html | 27 ++ .../app/core/context/context.component.scss | 5 + .../core/context/context.component.spec.ts | 106 ++++++++ .../src/app/core/context/context.component.ts | 74 ++++++ .../frontend/src/app/core/core.module.ts | 14 +- .../workbench-layout.component.html | 1 + .../app/shared/api/rgw-bucket.service.spec.ts | 27 +- .../src/app/shared/api/rgw-bucket.service.ts | 85 ++++--- .../src/app/shared/api/rgw-daemon.service.ts | 46 +++- .../app/shared/api/rgw-site.service.spec.ts | 10 +- .../src/app/shared/api/rgw-site.service.ts | 17 +- .../app/shared/api/rgw-user.service.spec.ts | 66 +++-- .../src/app/shared/api/rgw-user.service.ts | 103 +++++--- .../frontend/src/testing/unit-test-helper.ts | 41 ++- src/pybind/mgr/dashboard/openapi.yaml | 88 ++++++- .../mgr/dashboard/services/rgw_client.py | 238 ++++++++++-------- src/pybind/mgr/dashboard/settings.py | 150 ++++++----- src/pybind/mgr/dashboard/tests/test_rgw.py | 8 +- .../mgr/dashboard/tests/test_rgw_client.py | 47 ++-- .../mgr/dashboard/tests/test_settings.py | 11 +- 28 files changed, 957 insertions(+), 434 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts diff --git a/doc/mgr/dashboard.rst b/doc/mgr/dashboard.rst index 37f092c5ab1db..efa458a7cf4c7 100644 --- a/doc/mgr/dashboard.rst +++ b/doc/mgr/dashboard.rst @@ -389,11 +389,22 @@ To obtain the credentials of an existing user via `radosgw-admin`:: $ radosgw-admin user info --uid= -Finally, provide the credentials to the dashboard:: +In case of having several Object Gateways, you will need the required users' credentials +to connect to each Object Gateway. +Finally, provide these credentials to the dashboard:: + $ echo -n "{'': '', '': '', ...}" > + $ echo -n "{'': '', '': '', ...}" > $ ceph dashboard set-rgw-api-access-key -i $ ceph dashboard set-rgw-api-secret-key -i +.. note:: + + Legacy way of providing credentials (connect to single Object Gateway):: + + $ echo -n "" > + $ echo -n "" > + In a simple configuration with a single RGW endpoint, this is all you have to do to get the Object Gateway management functionality working. The dashboard will try to automatically determine the host and port @@ -411,7 +422,6 @@ exist and you may find yourself in the situation that you have to use them:: $ ceph dashboard set-rgw-api-scheme # http or https $ ceph dashboard set-rgw-api-admin-resource - $ ceph dashboard set-rgw-api-user-id If you are using a self-signed certificate in your Object Gateway setup, you should disable certificate verification in the dashboard to avoid refused diff --git a/qa/tasks/mgr/dashboard/test_rgw.py b/qa/tasks/mgr/dashboard/test_rgw.py index 36227f9d22674..17495bf6718fe 100644 --- a/qa/tasks/mgr/dashboard/test_rgw.py +++ b/qa/tasks/mgr/dashboard/test_rgw.py @@ -31,7 +31,6 @@ class RgwTestCase(DashboardTestCase): '--system', '--access-key', 'admin', '--secret', 'admin' ]) # Update the dashboard configuration. - cls._ceph_cmd(['dashboard', 'set-rgw-api-user-id', 'admin']) cls._ceph_cmd_with_secret(['dashboard', 'set-rgw-api-secret-key'], 'admin') cls._ceph_cmd_with_secret(['dashboard', 'set-rgw-api-access-key'], 'admin') # Create a test user? @@ -79,7 +78,6 @@ class RgwApiCredentialsTest(RgwTestCase): self._ceph_cmd(['mgr', 'module', 'disable', 'dashboard']) self._ceph_cmd(['mgr', 'module', 'enable', 'dashboard', '--force']) # Set the default credentials. - self._ceph_cmd(['dashboard', 'set-rgw-api-user-id', '']) self._ceph_cmd_with_secret(['dashboard', 'set-rgw-api-secret-key'], 'admin') self._ceph_cmd_with_secret(['dashboard', 'set-rgw-api-access-key'], 'admin') super(RgwApiCredentialsTest, self).setUp() @@ -101,16 +99,6 @@ class RgwApiCredentialsTest(RgwTestCase): self.assertIn('message', data) self.assertTrue(data['available']) - def test_invalid_user_id(self): - self._ceph_cmd(['dashboard', 'set-rgw-api-user-id', 'xyz']) - data = self._get('/api/rgw/status') - self.assertStatus(200) - self.assertIn('available', data) - self.assertIn('message', data) - self.assertFalse(data['available']) - self.assertIn('The user "xyz" is unknown to the Object Gateway.', - data['message']) - class RgwSiteTest(RgwTestCase): @@ -471,6 +459,8 @@ class RgwDaemonTest(RgwTestCase): self.assertIn('id', data) self.assertIn('version', data) self.assertIn('server_hostname', data) + self.assertIn('zonegroup_name', data) + self.assertIn('zone_name', data) def test_get(self): data = self._get('/api/rgw/daemon') diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 23d1f5db62f61..9e41e5a4d6927 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -17,7 +17,7 @@ from . import ApiController, BaseController, ControllerDoc, Endpoint, \ EndpointDoc, ReadPermission, RESTController, allow_empty_body try: - from typing import Any, List + from typing import Any, List, Optional except ImportError: # pragma: no cover pass # Just for type checking @@ -31,7 +31,9 @@ RGW_SCHEMA = { RGW_DAEMON_SCHEMA = { "id": (str, "Daemon ID"), "version": (str, "Ceph Version"), - "server_hostname": (str, "") + "server_hostname": (str, ""), + "zonegroup_name": (str, "Zone Group"), + "zone_name": (str, "Zone") } RGW_USER_SCHEMA = { @@ -63,16 +65,11 @@ class Rgw(BaseController): # establish a new connection (-> 'No RGW found' instead # of 'RGW REST API failed request ...'). # Note, this only applies to auto-detected RGW clients. - RgwClient.drop_instance(instance.userid) + RgwClient.drop_instance(instance) raise e if not is_online: msg = 'Failed to connect to the Object Gateway\'s Admin Ops API.' raise RequestException(msg) - # Ensure the API user ID is known by the RGW. - if not instance.user_exists(): - msg = 'The user "{}" is unknown to the Object Gateway.'.format( - instance.userid) - raise RequestException(msg) # Ensure the system flag is set for the API user ID. if not instance.is_system_user(): # pragma: no cover - no complexity there msg = 'The system flag is not set for user "{}".'.format( @@ -92,6 +89,7 @@ class RgwDaemon(RESTController): def list(self): # type: () -> List[dict] daemons = [] + instance = RgwClient.admin_instance() for hostname, server in CephService.get_service_map('rgw').items(): for service in server['services']: metadata = service['metadata'] @@ -100,7 +98,10 @@ class RgwDaemon(RESTController): daemon = { 'id': service['id'], 'version': metadata['ceph_version'], - 'server_hostname': hostname + 'server_hostname': hostname, + 'zonegroup_name': metadata['zonegroup_name'], + 'zone_name': metadata['zone_name'], + 'default': instance.daemon.name == service['id'] } daemons.append(daemon) @@ -135,9 +136,9 @@ class RgwDaemon(RESTController): class RgwRESTController(RESTController): - def proxy(self, method, path, params=None, json_response=True): + def proxy(self, daemon_name, method, path, params=None, json_response=True): try: - instance = RgwClient.admin_instance() + instance = RgwClient.admin_instance(daemon_name=daemon_name) result = instance.proxy(method, path, params, None) if json_response: result = json_str_to_object(result) @@ -149,16 +150,14 @@ class RgwRESTController(RESTController): @ApiController('/rgw/site', Scope.RGW) @ControllerDoc("RGW Site Management API", "RgwSite") class RgwSite(RgwRESTController): - def list(self, query=None): + def list(self, query=None, daemon_name=None): if query == 'placement-targets': - result = RgwClient.admin_instance().get_placement_targets() - elif query == 'realms': - result = RgwClient.admin_instance().get_realms() - else: - # @TODO: for multisite: by default, retrieve cluster topology/map. - raise DashboardException(http_status_code=501, component='rgw', msg='Not Implemented') + return RgwClient.admin_instance(daemon_name=daemon_name).get_placement_targets() + if query == 'realms': + return RgwClient.admin_instance(daemon_name=daemon_name).get_realms() - return result + # @TODO: for multisite: by default, retrieve cluster topology/map. + raise DashboardException(http_status_code=501, component='rgw', msg='Not Implemented') @ApiController('/rgw/bucket', Scope.RGW) @@ -179,26 +178,26 @@ class RgwBucket(RgwRESTController): if bucket['tenant'] else bucket['bucket'] return bucket - def _get_versioning(self, owner, bucket_name): - rgw_client = RgwClient.instance(owner) + def _get_versioning(self, owner, daemon_name, bucket_name): + rgw_client = RgwClient.instance(owner, daemon_name) return rgw_client.get_bucket_versioning(bucket_name) - def _set_versioning(self, owner, bucket_name, versioning_state, mfa_delete, + def _set_versioning(self, owner, daemon_name, bucket_name, versioning_state, mfa_delete, mfa_token_serial, mfa_token_pin): - bucket_versioning = self._get_versioning(owner, bucket_name) + bucket_versioning = self._get_versioning(owner, daemon_name, bucket_name) if versioning_state != bucket_versioning['Status']\ or (mfa_delete and mfa_delete != bucket_versioning['MfaDelete']): - rgw_client = RgwClient.instance(owner) + rgw_client = RgwClient.instance(owner, daemon_name) rgw_client.set_bucket_versioning(bucket_name, versioning_state, mfa_delete, mfa_token_serial, mfa_token_pin) - def _get_locking(self, owner, bucket_name): - rgw_client = RgwClient.instance(owner) + def _get_locking(self, owner, daemon_name, bucket_name): + rgw_client = RgwClient.instance(owner, daemon_name) return rgw_client.get_bucket_locking(bucket_name) - def _set_locking(self, owner, bucket_name, mode, + def _set_locking(self, owner, daemon_name, bucket_name, mode, retention_period_days, retention_period_years): - rgw_client = RgwClient.instance(owner) + rgw_client = RgwClient.instance(owner, daemon_name) return rgw_client.set_bucket_locking(bucket_name, mode, int(retention_period_days), int(retention_period_years)) @@ -230,29 +229,29 @@ class RgwBucket(RgwRESTController): bucket_name = '{}:{}'.format(tenant, bucket_name) return bucket_name - def list(self, stats=False): - # type: (bool) -> List[Any] + def list(self, stats=False, daemon_name=None): + # type: (bool, Optional[str]) -> List[Any] query_params = '?stats' if stats else '' - result = self.proxy('GET', 'bucket{}'.format(query_params)) + result = self.proxy(daemon_name, 'GET', 'bucket{}'.format(query_params)) if stats: result = [self._append_bid(bucket) for bucket in result] return result - def get(self, bucket): - # type: (str) -> dict - result = self.proxy('GET', 'bucket', {'bucket': bucket}) + def get(self, bucket, daemon_name=None): + # type: (str, Optional[str]) -> dict + result = self.proxy(daemon_name, 'GET', 'bucket', {'bucket': bucket}) bucket_name = RgwBucket.get_s3_bucket_name(result['bucket'], result['tenant']) # Append the versioning configuration. - versioning = self._get_versioning(result['owner'], bucket_name) + versioning = self._get_versioning(result['owner'], daemon_name, bucket_name) result['versioning'] = versioning['Status'] result['mfa_delete'] = versioning['MfaDelete'] # Append the locking configuration. - locking = self._get_locking(result['owner'], bucket_name) + locking = self._get_locking(result['owner'], daemon_name, bucket_name) result.update(locking) return self._append_bid(result) @@ -261,15 +260,15 @@ class RgwBucket(RgwRESTController): def create(self, bucket, uid, zonegroup=None, placement_target=None, lock_enabled='false', lock_mode=None, lock_retention_period_days=None, - lock_retention_period_years=None): + lock_retention_period_years=None, daemon_name=None): lock_enabled = str_to_bool(lock_enabled) try: - rgw_client = RgwClient.instance(uid) + rgw_client = RgwClient.instance(uid, daemon_name) result = rgw_client.create_bucket(bucket, zonegroup, placement_target, lock_enabled) if lock_enabled: - self._set_locking(uid, bucket, lock_mode, + self._set_locking(uid, daemon_name, bucket, lock_mode, lock_retention_period_days, lock_retention_period_years) return result @@ -280,14 +279,15 @@ class RgwBucket(RgwRESTController): def set(self, bucket, bucket_id, uid, versioning_state=None, mfa_delete=None, mfa_token_serial=None, mfa_token_pin=None, lock_mode=None, lock_retention_period_days=None, - lock_retention_period_years=None): + lock_retention_period_years=None, daemon_name=None): # When linking a non-tenant-user owned bucket to a tenanted user, we # need to prefix bucket name with '/'. e.g. photos -> /photos if '$' in uid and '/' not in bucket: bucket = '/{}'.format(bucket) # Link bucket to new user: - result = self.proxy('PUT', + result = self.proxy(daemon_name, + 'PUT', 'bucket', { 'bucket': bucket, 'bucket-id': bucket_id, @@ -299,20 +299,20 @@ class RgwBucket(RgwRESTController): bucket_name = RgwBucket.get_s3_bucket_name(bucket, uid_tenant) if versioning_state: - self._set_versioning(uid, bucket_name, versioning_state, + self._set_versioning(uid, daemon_name, bucket_name, versioning_state, mfa_delete, mfa_token_serial, mfa_token_pin) # Update locking if it is enabled. - locking = self._get_locking(uid, bucket_name) + locking = self._get_locking(uid, daemon_name, bucket_name) if locking['lock_enabled']: - self._set_locking(uid, bucket_name, lock_mode, + self._set_locking(uid, daemon_name, bucket_name, lock_mode, lock_retention_period_days, lock_retention_period_years) return self._append_bid(result) - def delete(self, bucket, purge_objects='true'): - return self.proxy('DELETE', 'bucket', { + def delete(self, bucket, purge_objects='true', daemon_name=None): + return self.proxy(daemon_name, 'DELETE', 'bucket', { 'bucket': bucket, 'purge-objects': purge_objects }, json_response=False) @@ -345,15 +345,15 @@ class RgwUser(RgwRESTController): @EndpointDoc("Display RGW Users", responses={200: RGW_USER_SCHEMA}) - def list(self): - # type: () -> List[str] + def list(self, daemon_name=None): + # type: (Optional[str]) -> List[str] users = [] # type: List[str] marker = None while True: params = {} # type: dict if marker: params['marker'] = marker - result = self.proxy('GET', 'user?list', params) + result = self.proxy(daemon_name, 'GET', 'user?list', params) users.extend(result['keys']) if not result['truncated']: break @@ -364,9 +364,9 @@ class RgwUser(RgwRESTController): marker = result['marker'] return users - def get(self, uid): - # type: (str) -> dict - result = self.proxy('GET', 'user', {'uid': uid}) + def get(self, uid, daemon_name=None): + # type: (str, Optional[str]) -> dict + result = self.proxy(daemon_name, 'GET', 'user', {'uid': uid}) if not self._keys_allowed(): del result['keys'] del result['swift_keys'] @@ -374,11 +374,11 @@ class RgwUser(RgwRESTController): @Endpoint() @ReadPermission - def get_emails(self): - # type: () -> List[str] + def get_emails(self, daemon_name=None): + # type: (Optional[str]) -> List[str] emails = [] - for uid in json.loads(self.list()): # type: ignore - user = json.loads(self.get(uid)) # type: ignore + for uid in json.loads(self.list(daemon_name)): # type: ignore + user = json.loads(self.get(uid, daemon_name)) # type: ignore if user["email"]: emails.append(user["email"]) return emails @@ -386,7 +386,7 @@ class RgwUser(RgwRESTController): @allow_empty_body def create(self, uid, display_name, email=None, max_buckets=None, suspended=None, generate_key=None, access_key=None, - secret_key=None): + secret_key=None, daemon_name=None): params = {'uid': uid} if display_name is not None: params['display-name'] = display_name @@ -402,12 +402,12 @@ class RgwUser(RgwRESTController): params['access-key'] = access_key if secret_key is not None: params['secret-key'] = secret_key - result = self.proxy('PUT', 'user', params) + result = self.proxy(daemon_name, 'PUT', 'user', params) return self._append_uid(result) @allow_empty_body def set(self, uid, display_name=None, email=None, max_buckets=None, - suspended=None): + suspended=None, daemon_name=None): params = {'uid': uid} if display_name is not None: params['display-name'] = display_name @@ -417,35 +417,35 @@ class RgwUser(RgwRESTController): params['max-buckets'] = max_buckets if suspended is not None: params['suspended'] = suspended - result = self.proxy('POST', 'user', params) + result = self.proxy(daemon_name, 'POST', 'user', params) return self._append_uid(result) - def delete(self, uid): + def delete(self, uid, daemon_name=None): try: - instance = RgwClient.admin_instance() + instance = RgwClient.admin_instance(daemon_name=daemon_name) # Ensure the user is not configured to access the RGW Object Gateway. if instance.userid == uid: raise DashboardException(msg='Unable to delete "{}" - this user ' 'account is required for managing the ' 'Object Gateway'.format(uid)) # Finally redirect request to the RGW proxy. - return self.proxy('DELETE', 'user', {'uid': uid}, json_response=False) + return self.proxy(daemon_name, 'DELETE', 'user', {'uid': uid}, json_response=False) except (DashboardException, RequestException) as e: # pragma: no cover raise DashboardException(e, component='rgw') # pylint: disable=redefined-builtin @RESTController.Resource(method='POST', path='/capability', status=201) @allow_empty_body - def create_cap(self, uid, type, perm): - return self.proxy('PUT', 'user?caps', { + def create_cap(self, uid, type, perm, daemon_name=None): + return self.proxy(daemon_name, 'PUT', 'user?caps', { 'uid': uid, 'user-caps': '{}={}'.format(type, perm) }) # pylint: disable=redefined-builtin @RESTController.Resource(method='DELETE', path='/capability', status=204) - def delete_cap(self, uid, type, perm): - return self.proxy('DELETE', 'user?caps', { + def delete_cap(self, uid, type, perm, daemon_name=None): + return self.proxy(daemon_name, 'DELETE', 'user?caps', { 'uid': uid, 'user-caps': '{}={}'.format(type, perm) }) @@ -453,7 +453,7 @@ class RgwUser(RgwRESTController): @RESTController.Resource(method='POST', path='/key', status=201) @allow_empty_body def create_key(self, uid, key_type='s3', subuser=None, generate_key='true', - access_key=None, secret_key=None): + access_key=None, secret_key=None, daemon_name=None): params = {'uid': uid, 'key-type': key_type, 'generate-key': generate_key} if subuser is not None: params['subuser'] = subuser @@ -461,25 +461,25 @@ class RgwUser(RgwRESTController): params['access-key'] = access_key if secret_key is not None: params['secret-key'] = secret_key - return self.proxy('PUT', 'user?key', params) + return self.proxy(daemon_name, 'PUT', 'user?key', params) @RESTController.Resource(method='DELETE', path='/key', status=204) - def delete_key(self, uid, key_type='s3', subuser=None, access_key=None): + def delete_key(self, uid, key_type='s3', subuser=None, access_key=None, daemon_name=None): params = {'uid': uid, 'key-type': key_type} if subuser is not None: params['subuser'] = subuser if access_key is not None: params['access-key'] = access_key - return self.proxy('DELETE', 'user?key', params, json_response=False) + return self.proxy(daemon_name, 'DELETE', 'user?key', params, json_response=False) @RESTController.Resource(method='GET', path='/quota') - def get_quota(self, uid): - return self.proxy('GET', 'user?quota', {'uid': uid}) + def get_quota(self, uid, daemon_name=None): + return self.proxy(daemon_name, 'GET', 'user?quota', {'uid': uid}) @RESTController.Resource(method='PUT', path='/quota') @allow_empty_body - def set_quota(self, uid, quota_type, enabled, max_size_kb, max_objects): - return self.proxy('PUT', 'user?quota', { + def set_quota(self, uid, quota_type, enabled, max_size_kb, max_objects, daemon_name=None): + return self.proxy(daemon_name, 'PUT', 'user?quota', { 'uid': uid, 'quota-type': quota_type, 'enabled': enabled, @@ -491,8 +491,8 @@ class RgwUser(RgwRESTController): @allow_empty_body def create_subuser(self, uid, subuser, access, key_type='s3', generate_secret='true', access_key=None, - secret_key=None): - return self.proxy('PUT', 'user', { + secret_key=None, daemon_name=None): + return self.proxy(daemon_name, 'PUT', 'user', { 'uid': uid, 'subuser': subuser, 'key-type': key_type, @@ -503,12 +503,12 @@ class RgwUser(RgwRESTController): }) @RESTController.Resource(method='DELETE', path='/subuser/{subuser}', status=204) - def delete_subuser(self, uid, subuser, purge_keys='true'): + def delete_subuser(self, uid, subuser, purge_keys='true', daemon_name=None): """ :param purge_keys: Set to False to do not purge the keys. Note, this only works for s3 subusers. """ - return self.proxy('DELETE', 'user', { + return self.proxy(daemon_name, 'DELETE', 'user', { 'uid': uid, 'subuser': subuser, 'purge-keys': purge_keys diff --git a/src/pybind/mgr/dashboard/controllers/settings.py b/src/pybind/mgr/dashboard/controllers/settings.py index cb16083ff11ea..7d9ca9fb316a6 100644 --- a/src/pybind/mgr/dashboard/controllers/settings.py +++ b/src/pybind/mgr/dashboard/controllers/settings.py @@ -72,11 +72,11 @@ class Settings(RESTController): def _get(self, name): with self._attribute_handler(name) as sname: - default, data_type = getattr(Options, sname) + setting = getattr(Options, sname) return { 'name': sname, - 'default': default, - 'type': data_type.__name__, + 'default': setting.default_value, + 'type': setting.types_as_str(), 'value': getattr(SettingsModule, sname) } 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 9911d18d8ed69..660b9777a8c6d 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 @@ -10,7 +10,7 @@ import { ToastrModule } from 'ngx-toastr'; import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component'; import { SharedModule } from '~/app/shared/shared.module'; import { ActivatedRouteStub } from '~/testing/activated-route-stub'; -import { configureTestBed } from '~/testing/unit-test-helper'; +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; import { NFSClusterType } from '../nfs-cluster-type.enum'; import { NfsFormClientComponent } from '../nfs-form-client/nfs-form-client.component'; import { NfsFormComponent } from './nfs-form.component'; @@ -57,19 +57,20 @@ describe('NfsFormComponent', () => { httpTesting.expectOne('ui-api/nfs-ganesha/fsals').flush(['CEPH', 'RGW']); httpTesting.expectOne('ui-api/nfs-ganesha/cephx/clients').flush(['admin', 'fs', 'rgw']); httpTesting.expectOne('ui-api/nfs-ganesha/cephfs/filesystems').flush([{ id: 1, name: 'a' }]); - httpTesting.expectOne('api/rgw/user').flush(['test', 'dev']); + RgwHelper.getCurrentDaemon(); + httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`).flush(['test', 'dev']); const user_dev = { suspended: 0, user_id: 'dev', keys: ['a'] }; - httpTesting.expectOne('api/rgw/user/dev').flush(user_dev); + httpTesting.expectOne(`api/rgw/user/dev?${RgwHelper.DAEMON_QUERY_PARAM}`).flush(user_dev); const user_test = { suspended: 1, user_id: 'test', keys: ['a'] }; - httpTesting.expectOne('api/rgw/user/test').flush(user_test); + httpTesting.expectOne(`api/rgw/user/test?${RgwHelper.DAEMON_QUERY_PARAM}`).flush(user_test); httpTesting.verify(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts new file mode 100644 index 0000000000000..25544a5ed2700 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts @@ -0,0 +1,8 @@ +export class RgwDaemon { + id: string; + version: string; + server_hostname: string; + zonegroup_name: string; + zone_name: string; + default: boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts index 146d5dbf881b2..cb8ce571335d4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts @@ -1,5 +1,7 @@ import { Component, OnInit } from '@angular/core'; +import { take } from 'rxjs/operators'; + import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; import { RgwSiteService } from '~/app/shared/api/rgw-site.service'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; @@ -42,6 +44,16 @@ export class RgwDaemonListComponent extends ListWithDetails implements OnInit { prop: 'server_hostname', flexGrow: 2 }, + { + name: $localize`Zone Group`, + prop: 'zonegroup_name', + flexGrow: 2 + }, + { + name: $localize`Zone`, + prop: 'zone_name', + flexGrow: 2 + }, { name: $localize`Version`, prop: 'version', @@ -55,7 +67,7 @@ export class RgwDaemonListComponent extends ListWithDetails implements OnInit { } getDaemonList(context: CdTableFetchDataContext) { - this.rgwDaemonService.list().subscribe( + this.rgwDaemonService.daemons$.pipe(take(1)).subscribe( (resp: object[]) => { this.daemons = resp; }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index 33c6e01560d26..4abcd69796f32 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -63,9 +63,7 @@ export class RgwModule {} const routes: Routes = [ { - path: '', - redirectTo: 'daemon', - pathMatch: 'full' + path: '' // Required for a clean reload on daemon selection. }, { path: 'daemon', component: RgwDaemonListComponent, data: { breadcrumbs: 'Daemons' } }, { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html new file mode 100644 index 0000000000000..63af29e135928 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html @@ -0,0 +1,27 @@ + + +
+ Selected Object Gateway: +
+ +
+ + + +
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss new file mode 100644 index 0000000000000..0cd44f1504412 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss @@ -0,0 +1,5 @@ +@use './src/styles/vendor/variables' as vv; + +.cd-context-bar { + border-bottom: 1px solid vv.$gray-300; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts new file mode 100644 index 0000000000000..adffb6f107541 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts @@ -0,0 +1,106 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of } from 'rxjs'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + FeatureTogglesMap, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ContextComponent } from './context.component'; + +describe('ContextComponent', () => { + let component: ContextComponent; + let fixture: ComponentFixture; + let router: Router; + let routerNavigateByUrlSpy: jasmine.Spy; + let routerNavigateSpy: jasmine.Spy; + let getPermissionsSpy: jasmine.Spy; + let getFeatureTogglesSpy: jasmine.Spy; + let ftMap: FeatureTogglesMap; + let httpTesting: HttpTestingController; + + const getDaemonList = () => { + const daemonList: RgwDaemon[] = []; + for (let daemonIndex = 1; daemonIndex <= 3; daemonIndex++) { + const rgwDaemon = new RgwDaemon(); + rgwDaemon.id = `daemon${daemonIndex}`; + rgwDaemon.default = daemonIndex === 2; + rgwDaemon.zonegroup_name = `zonegroup${daemonIndex}`; + daemonList.push(rgwDaemon); + } + return daemonList; + }; + + configureTestBed({ + declarations: [ContextComponent], + imports: [HttpClientTestingModule, RouterTestingModule] + }); + + beforeEach(() => { + httpTesting = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + routerNavigateByUrlSpy = spyOn(router, 'navigateByUrl'); + routerNavigateByUrlSpy.and.returnValue(Promise.resolve(undefined)); + routerNavigateSpy = spyOn(router, 'navigate'); + getPermissionsSpy = spyOn(TestBed.inject(AuthStorageService), 'getPermissions'); + getPermissionsSpy.and.returnValue( + new Permissions({ rgw: ['read', 'update', 'create', 'delete'] }) + ); + getFeatureTogglesSpy = spyOn(TestBed.inject(FeatureTogglesService), 'get'); + ftMap = new FeatureTogglesMap(); + ftMap.rgw = true; + getFeatureTogglesSpy.and.returnValue(of(ftMap)); + fixture = TestBed.createComponent(ContextComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + const req = httpTesting.expectOne('api/rgw/daemon'); + req.flush(getDaemonList()); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not show any info if not in RGW route', () => { + component.isRgwRoute = false; + expect(fixture.debugElement.nativeElement.textContent).toEqual(''); + }); + + it('should select the default daemon', fakeAsync(() => { + component.isRgwRoute = true; + tick(); + fixture.detectChanges(); + const selectedDaemon = fixture.debugElement.nativeElement.querySelector( + '.ctx-bar-selected-rgw-daemon' + ); + expect(selectedDaemon.textContent).toEqual(' daemon2 ( zonegroup2 ) '); + + const availableDaemons = fixture.debugElement.nativeElement.querySelectorAll( + '.ctx-bar-available-rgw-daemon' + ); + expect(availableDaemons.length).toEqual(getDaemonList().length); + expect(availableDaemons[0].textContent).toEqual(' daemon1 ( zonegroup1 ) '); + })); + + it('should select the chosen daemon', fakeAsync(() => { + component.isRgwRoute = true; + component.onDaemonSelection(getDaemonList()[2]); + tick(); + fixture.detectChanges(); + + expect(routerNavigateByUrlSpy).toHaveBeenCalledTimes(1); + expect(routerNavigateSpy).toHaveBeenCalledTimes(1); + + const selectedDaemon = fixture.debugElement.nativeElement.querySelector( + '.ctx-bar-selected-rgw-daemon' + ); + expect(selectedDaemon.textContent).toEqual(' daemon3 ( zonegroup3 ) '); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts new file mode 100644 index 0000000000000..5d1d1e34f761d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts @@ -0,0 +1,74 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Event, NavigationEnd, Router } from '@angular/router'; + +import { NEVER, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + FeatureTogglesMap$, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; +import { TimerService } from '~/app/shared/services/timer.service'; + +@Component({ + selector: 'cd-context', + templateUrl: './context.component.html', + styleUrls: ['./context.component.scss'] +}) +export class ContextComponent implements OnInit, OnDestroy { + readonly REFRESH_INTERVAL = 5000; + private subs = new Subscription(); + private rgwUrlPrefix = '/rgw'; + permissions: Permissions; + featureToggleMap$: FeatureTogglesMap$; + isRgwRoute = document.location.href.includes(this.rgwUrlPrefix); + + constructor( + private authStorageService: AuthStorageService, + private featureToggles: FeatureTogglesService, + private router: Router, + private timerService: TimerService, + public rgwDaemonService: RgwDaemonService + ) {} + + ngOnInit() { + this.permissions = this.authStorageService.getPermissions(); + this.featureToggleMap$ = this.featureToggles.get(); + // Check if route belongs to RGW: + this.subs.add( + this.router.events + .pipe(filter((event: Event) => event instanceof NavigationEnd)) + .subscribe(() => (this.isRgwRoute = this.router.url.includes(this.rgwUrlPrefix))) + ); + // Select default daemon: + this.rgwDaemonService.list().subscribe((daemons: RgwDaemon[]) => { + this.rgwDaemonService.selectDefaultDaemon(daemons); + }); + // Set daemon list polling only when in RGW route: + this.subs.add( + this.timerService + .get(() => (this.isRgwRoute ? this.rgwDaemonService.list() : NEVER), this.REFRESH_INTERVAL) + .subscribe() + ); + } + + ngOnDestroy() { + this.subs.unsubscribe(); + } + + onDaemonSelection(daemon: RgwDaemon) { + this.rgwDaemonService.selectDaemon(daemon); + this.reloadData(); + } + + private reloadData() { + const currentUrl = this.router.url; + this.router.navigateByUrl(this.rgwUrlPrefix, { skipLocationChange: true }).finally(() => { + this.router.navigate([currentUrl]); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts index 0a5acf317a3a4..005c8277877bc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts @@ -2,9 +2,11 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { BlockUIModule } from 'ng-block-ui'; -import { SharedModule } from '../shared/shared.module'; +import { ContextComponent } from '~/app/core/context/context.component'; +import { SharedModule } from '~/app/shared/shared.module'; import { ErrorComponent } from './error/error.component'; import { BlankLayoutComponent } from './layouts/blank-layout/blank-layout.component'; import { LoginLayoutComponent } from './layouts/login-layout/login-layout.component'; @@ -12,9 +14,17 @@ import { WorkbenchLayoutComponent } from './layouts/workbench-layout/workbench-l import { NavigationModule } from './navigation/navigation.module'; @NgModule({ - imports: [BlockUIModule.forRoot(), CommonModule, NavigationModule, RouterModule, SharedModule], + imports: [ + BlockUIModule.forRoot(), + CommonModule, + NavigationModule, + NgbDropdownModule, + RouterModule, + SharedModule + ], exports: [NavigationModule], declarations: [ + ContextComponent, WorkbenchLayoutComponent, BlankLayoutComponent, LoginLayoutComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html index ae81cdee71d42..3979ad7a4a95e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html @@ -2,6 +2,7 @@
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts index aa33df3888d3d..e0787d06a92ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts @@ -1,7 +1,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { configureTestBed } from '~/testing/unit-test-helper'; +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; import { RgwBucketService } from './rgw-bucket.service'; describe('RgwBucketService', () => { @@ -28,13 +28,15 @@ describe('RgwBucketService', () => { it('should call list', () => { service.list().subscribe(); - const req = httpTesting.expectOne('api/rgw/bucket?stats=true'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/bucket?${RgwHelper.DAEMON_QUERY_PARAM}&stats=true`); expect(req.request.method).toBe('GET'); }); it('should call get', () => { service.get('foo').subscribe(); - const req = httpTesting.expectOne('api/rgw/bucket/foo'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); }); @@ -42,8 +44,9 @@ describe('RgwBucketService', () => { service .create('foo', 'bar', 'default', 'default-placement', false, 'COMPLIANCE', '10', '0') .subscribe(); + RgwHelper.getCurrentDaemon(); const req = httpTesting.expectOne( - 'api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=10&lock_retention_period_years=0' + `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=10&lock_retention_period_years=0&${RgwHelper.DAEMON_QUERY_PARAM}` ); expect(req.request.method).toBe('POST'); }); @@ -52,21 +55,28 @@ describe('RgwBucketService', () => { service .update('foo', 'bar', 'baz', 'Enabled', 'Enabled', '1', '223344', 'GOVERNANCE', '0', '1') .subscribe(); + RgwHelper.getCurrentDaemon(); const req = httpTesting.expectOne( - 'api/rgw/bucket/foo?bucket_id=bar&uid=baz&versioning_state=Enabled&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=0&lock_retention_period_years=1' + `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=0&lock_retention_period_years=1` ); expect(req.request.method).toBe('PUT'); }); it('should call delete, with purgeObjects = true', () => { service.delete('foo').subscribe(); - const req = httpTesting.expectOne('api/rgw/bucket/foo?purge_objects=true'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&purge_objects=true` + ); expect(req.request.method).toBe('DELETE'); }); it('should call delete, with purgeObjects = false', () => { service.delete('foo', false).subscribe(); - const req = httpTesting.expectOne('api/rgw/bucket/foo?purge_objects=false'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&purge_objects=false` + ); expect(req.request.method).toBe('DELETE'); }); @@ -75,7 +85,8 @@ describe('RgwBucketService', () => { service.exists('foo').subscribe((resp) => { result = resp; }); - const req = httpTesting.expectOne('api/rgw/bucket'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/bucket?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); req.flush(['foo', 'bar']); expect(result).toBe(true); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts index baeaa3dde4837..1d622518bba90 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts @@ -5,7 +5,8 @@ import _ from 'lodash'; import { of as observableOf } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { cdEncode } from '../decorators/cd-encode'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; @cdEncode @Injectable({ @@ -14,28 +15,33 @@ import { cdEncode } from '../decorators/cd-encode'; export class RgwBucketService { private url = 'api/rgw/bucket'; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {} /** * Get the list of buckets. - * @return {Observable} + * @return Observable */ list() { - let params = new HttpParams(); - params = params.append('stats', 'true'); - return this.http.get(this.url, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('stats', 'true'); + return this.http.get(this.url, { params: params }); + }); } /** * Get the list of bucket names. - * @return {Observable} + * @return Observable */ enumerate() { - return this.http.get(this.url); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(this.url, { params: params }); + }); } get(bucket: string) { - return this.http.get(`${this.url}/${bucket}`); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${bucket}`, { params: params }); + }); } create( @@ -48,19 +54,22 @@ export class RgwBucketService { lock_retention_period_days: string, lock_retention_period_years: string ) { - return this.http.post(this.url, null, { - params: new HttpParams({ - fromObject: { - bucket, - uid, - zonegroup, - placement_target: placementTarget, - lock_enabled: String(lockEnabled), - lock_mode, - lock_retention_period_days, - lock_retention_period_years - } - }) + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.post(this.url, null, { + params: new HttpParams({ + fromObject: { + bucket, + uid, + zonegroup, + placement_target: placementTarget, + lock_enabled: String(lockEnabled), + lock_mode, + lock_retention_period_days, + lock_retention_period_years, + daemon_name: params.get('daemon_name') + } + }) + }); }); } @@ -76,29 +85,31 @@ export class RgwBucketService { lockRetentionPeriodDays: string, lockRetentionPeriodYears: string ) { - let params = new HttpParams(); - params = params.append('bucket_id', bucketId); - params = params.append('uid', uid); - params = params.append('versioning_state', versioningState); - params = params.append('mfa_delete', mfaDelete); - params = params.append('mfa_token_serial', mfaTokenSerial); - params = params.append('mfa_token_pin', mfaTokenPin); - params = params.append('lock_mode', lockMode); - params = params.append('lock_retention_period_days', lockRetentionPeriodDays); - params = params.append('lock_retention_period_years', lockRetentionPeriodYears); - return this.http.put(`${this.url}/${bucket}`, null, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('bucket_id', bucketId); + params = params.append('uid', uid); + params = params.append('versioning_state', versioningState); + params = params.append('mfa_delete', mfaDelete); + params = params.append('mfa_token_serial', mfaTokenSerial); + params = params.append('mfa_token_pin', mfaTokenPin); + params = params.append('lock_mode', lockMode); + params = params.append('lock_retention_period_days', lockRetentionPeriodDays); + params = params.append('lock_retention_period_years', lockRetentionPeriodYears); + return this.http.put(`${this.url}/${bucket}`, null, { params: params }); + }); } delete(bucket: string, purgeObjects = true) { - let params = new HttpParams(); - params = params.append('purge_objects', purgeObjects ? 'true' : 'false'); - return this.http.delete(`${this.url}/${bucket}`, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('purge_objects', purgeObjects ? 'true' : 'false'); + return this.http.delete(`${this.url}/${bucket}`, { params: params }); + }); } /** * Check if the specified bucket exists. * @param {string} bucket The bucket name to check. - * @return {Observable} + * @return Observable */ exists(bucket: string) { return this.enumerate().pipe( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts index ae2a5d697595e..67244e7de0e96 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts @@ -1,7 +1,11 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { cdEncode } from '../decorators/cd-encode'; +import { Observable, ReplaySubject } from 'rxjs'; +import { mergeMap, take, tap } from 'rxjs/operators'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; @cdEncode @Injectable({ @@ -9,14 +13,48 @@ import { cdEncode } from '../decorators/cd-encode'; }) export class RgwDaemonService { private url = 'api/rgw/daemon'; + private daemons = new ReplaySubject(1); + daemons$ = this.daemons.asObservable(); + private selectedDaemon = new ReplaySubject(1); + selectedDaemon$ = this.selectedDaemon.asObservable(); constructor(private http: HttpClient) {} - list() { - return this.http.get(this.url); + list(): Observable { + return this.http.get(this.url).pipe( + tap((daemons: RgwDaemon[]) => { + this.daemons.next(daemons); + }) + ); } get(id: string) { return this.http.get(`${this.url}/${id}`); } + + selectDaemon(daemon: RgwDaemon) { + this.selectedDaemon.next(daemon); + } + + selectDefaultDaemon(daemons: RgwDaemon[]): RgwDaemon { + for (const daemon of daemons) { + if (daemon.default) { + this.selectDaemon(daemon); + return daemon; + } + } + + throw new Error('No default RGW daemon found.'); + } + + request(next: (params: HttpParams) => Observable) { + return this.selectedDaemon.pipe( + take(1), + mergeMap((daemon: RgwDaemon) => { + let params = new HttpParams(); + params = params.append('daemon_name', daemon.id); + return next(params); + }) + ); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts index 5dcdbf4cf7988..d11501b35114f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts @@ -1,7 +1,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { configureTestBed } from '~/testing/unit-test-helper'; +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; import { RgwSiteService } from './rgw-site.service'; describe('RgwSiteService', () => { @@ -28,13 +28,17 @@ describe('RgwSiteService', () => { it('should contain site endpoint in GET request', () => { service.get().subscribe(); - const req = httpTesting.expectOne(service['url']); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`${service['url']}?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); }); it('should add query param in GET request', () => { const query = 'placement-targets'; service.get(query).subscribe(); - httpTesting.expectOne(`${service['url']}?query=placement-targets`); + RgwHelper.getCurrentDaemon(); + httpTesting.expectOne( + `${service['url']}?${RgwHelper.DAEMON_QUERY_PARAM}&query=placement-targets` + ); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts index 8c761668831ff..545179dcf1abe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts @@ -1,7 +1,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { cdEncode } from '../decorators/cd-encode'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; @cdEncode @Injectable({ @@ -10,14 +11,14 @@ import { cdEncode } from '../decorators/cd-encode'; export class RgwSiteService { private url = 'api/rgw/site'; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {} get(query?: string) { - let params = new HttpParams(); - if (query) { - params = params.append('query', query); - } - - return this.http.get(this.url, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + if (query) { + params = params.append('query', query); + } + return this.http.get(this.url, { params: params }); + }); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts index c2954eac585a2..e85fe9d11381f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { of as observableOf, throwError } from 'rxjs'; -import { configureTestBed } from '~/testing/unit-test-helper'; +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; import { RgwUserService } from './rgw-user.service'; describe('RgwUserService', () => { @@ -33,7 +33,8 @@ describe('RgwUserService', () => { service.list().subscribe((resp) => { result = resp; }); - const req = httpTesting.expectOne('api/rgw/user'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); req.flush([]); expect(result).toEqual([]); @@ -44,16 +45,16 @@ describe('RgwUserService', () => { service.list().subscribe((resp) => { result = resp; }); - - let req = httpTesting.expectOne('api/rgw/user'); + RgwHelper.getCurrentDaemon(); + let req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); req.flush(['foo', 'bar']); - req = httpTesting.expectOne('api/rgw/user/foo'); + req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); req.flush({ name: 'foo' }); - req = httpTesting.expectOne('api/rgw/user/bar'); + req = httpTesting.expectOne(`api/rgw/user/bar?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); req.flush({ name: 'bar' }); @@ -62,79 +63,106 @@ describe('RgwUserService', () => { it('should call enumerate', () => { service.enumerate().subscribe(); - const req = httpTesting.expectOne('api/rgw/user'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); }); it('should call get', () => { service.get('foo').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); }); it('should call getQuota', () => { service.getQuota('foo').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/quota'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user/foo/quota?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('GET'); }); it('should call update', () => { service.update('foo', { xxx: 'yyy' }).subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo?xxx=yyy'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy`); expect(req.request.method).toBe('PUT'); }); it('should call updateQuota', () => { service.updateQuota('foo', { xxx: 'yyy' }).subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/quota?xxx=yyy'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/quota?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy` + ); expect(req.request.method).toBe('PUT'); }); it('should call create', () => { service.create({ foo: 'bar' }).subscribe(); - const req = httpTesting.expectOne('api/rgw/user?foo=bar'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}&foo=bar`); expect(req.request.method).toBe('POST'); }); it('should call delete', () => { service.delete('foo').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); expect(req.request.method).toBe('DELETE'); }); it('should call createSubuser', () => { service.createSubuser('foo', { xxx: 'yyy' }).subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/subuser?xxx=yyy'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/subuser?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy` + ); expect(req.request.method).toBe('POST'); }); it('should call deleteSubuser', () => { service.deleteSubuser('foo', 'bar').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/subuser/bar'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/subuser/bar?${RgwHelper.DAEMON_QUERY_PARAM}` + ); expect(req.request.method).toBe('DELETE'); }); it('should call addCapability', () => { service.addCapability('foo', 'bar', 'baz').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/capability?type=bar&perm=baz'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/capability?${RgwHelper.DAEMON_QUERY_PARAM}&type=bar&perm=baz` + ); expect(req.request.method).toBe('POST'); }); it('should call deleteCapability', () => { service.deleteCapability('foo', 'bar', 'baz').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/capability?type=bar&perm=baz'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/capability?${RgwHelper.DAEMON_QUERY_PARAM}&type=bar&perm=baz` + ); expect(req.request.method).toBe('DELETE'); }); it('should call addS3Key', () => { service.addS3Key('foo', { xxx: 'yyy' }).subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/key?key_type=s3&xxx=yyy'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/key?${RgwHelper.DAEMON_QUERY_PARAM}&key_type=s3&xxx=yyy` + ); expect(req.request.method).toBe('POST'); }); it('should call deleteS3Key', () => { service.deleteS3Key('foo', 'bar').subscribe(); - const req = httpTesting.expectOne('api/rgw/user/foo/key?key_type=s3&access_key=bar'); + RgwHelper.getCurrentDaemon(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/key?${RgwHelper.DAEMON_QUERY_PARAM}&key_type=s3&access_key=bar` + ); expect(req.request.method).toBe('DELETE'); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts index f322a04fffd22..66167bcabbd07 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts @@ -5,7 +5,8 @@ import _ from 'lodash'; import { forkJoin as observableForkJoin, Observable, of as observableOf } from 'rxjs'; import { catchError, mapTo, mergeMap } from 'rxjs/operators'; -import { cdEncode } from '../decorators/cd-encode'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; @cdEncode @Injectable({ @@ -14,7 +15,7 @@ import { cdEncode } from '../decorators/cd-encode'; export class RgwUserService { private url = 'api/rgw/user'; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {} /** * Get the list of users. @@ -40,89 +41,109 @@ export class RgwUserService { * @return {Observable} */ enumerate() { - return this.http.get(this.url); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(this.url, { params: params }); + }); } enumerateEmail() { - return this.http.get(`${this.url}/get_emails`); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/get_emails`, { params: params }); + }); } get(uid: string) { - return this.http.get(`${this.url}/${uid}`); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${uid}`, { params: params }); + }); } getQuota(uid: string) { - return this.http.get(`${this.url}/${uid}/quota`); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${uid}/quota`, { params: params }); + }); } create(args: Record) { - let params = new HttpParams(); - _.keys(args).forEach((key) => { - params = params.append(key, args[key]); + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.post(this.url, null, { params: params }); }); - return this.http.post(this.url, null, { params: params }); } update(uid: string, args: Record) { - let params = new HttpParams(); - _.keys(args).forEach((key) => { - params = params.append(key, args[key]); + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.put(`${this.url}/${uid}`, null, { params: params }); }); - return this.http.put(`${this.url}/${uid}`, null, { params: params }); } updateQuota(uid: string, args: Record) { - let params = new HttpParams(); - _.keys(args).forEach((key) => { - params = params.append(key, args[key]); + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.put(`${this.url}/${uid}/quota`, null, { params: params }); }); - return this.http.put(`${this.url}/${uid}/quota`, null, { params: params }); } delete(uid: string) { - return this.http.delete(`${this.url}/${uid}`); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.delete(`${this.url}/${uid}`, { params: params }); + }); } createSubuser(uid: string, args: Record) { - let params = new HttpParams(); - _.keys(args).forEach((key) => { - params = params.append(key, args[key]); + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.post(`${this.url}/${uid}/subuser`, null, { params: params }); }); - return this.http.post(`${this.url}/${uid}/subuser`, null, { params: params }); } deleteSubuser(uid: string, subuser: string) { - return this.http.delete(`${this.url}/${uid}/subuser/${subuser}`); + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.delete(`${this.url}/${uid}/subuser/${subuser}`, { params: params }); + }); } addCapability(uid: string, type: string, perm: string) { - let params = new HttpParams(); - params = params.append('type', type); - params = params.append('perm', perm); - return this.http.post(`${this.url}/${uid}/capability`, null, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('type', type); + params = params.append('perm', perm); + return this.http.post(`${this.url}/${uid}/capability`, null, { params: params }); + }); } deleteCapability(uid: string, type: string, perm: string) { - let params = new HttpParams(); - params = params.append('type', type); - params = params.append('perm', perm); - return this.http.delete(`${this.url}/${uid}/capability`, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('type', type); + params = params.append('perm', perm); + return this.http.delete(`${this.url}/${uid}/capability`, { params: params }); + }); } addS3Key(uid: string, args: Record) { - let params = new HttpParams(); - params = params.append('key_type', 's3'); - _.keys(args).forEach((key) => { - params = params.append(key, args[key]); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('key_type', 's3'); + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.post(`${this.url}/${uid}/key`, null, { params: params }); }); - return this.http.post(`${this.url}/${uid}/key`, null, { params: params }); } deleteS3Key(uid: string, accessKey: string) { - let params = new HttpParams(); - params = params.append('key_type', 's3'); - params = params.append('access_key', accessKey); - return this.http.delete(`${this.url}/${uid}/key`, { params: params }); + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('key_type', 's3'); + params = params.append('access_key', accessKey); + return this.http.delete(`${this.url}/${uid}/key`, { params: params }); + }); } /** diff --git a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts index 3e3d27a7ce641..c45f480a700e6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts +++ b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts @@ -9,24 +9,26 @@ import _ from 'lodash'; import { configureTestSuite } from 'ng-bullet'; import { of } from 'rxjs'; -import { InventoryDevice } from '../app/ceph/cluster/inventory/inventory-devices/inventory-device.model'; -import { Pool } from '../app/ceph/pool/pool'; -import { OrchestratorService } from '../app/shared/api/orchestrator.service'; -import { TableActionsComponent } from '../app/shared/datatable/table-actions/table-actions.component'; -import { Icons } from '../app/shared/enum/icons.enum'; -import { CdFormGroup } from '../app/shared/forms/cd-form-group'; -import { CdTableAction } from '../app/shared/models/cd-table-action'; -import { CdTableSelection } from '../app/shared/models/cd-table-selection'; -import { CrushNode } from '../app/shared/models/crush-node'; -import { CrushRule, CrushRuleConfig } from '../app/shared/models/crush-rule'; -import { OrchestratorFeature } from '../app/shared/models/orchestrator.enum'; -import { Permission } from '../app/shared/models/permissions'; +import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model'; +import { Pool } from '~/app/ceph/pool/pool'; +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { CrushNode } from '~/app/shared/models/crush-node'; +import { CrushRule, CrushRuleConfig } from '~/app/shared/models/crush-rule'; +import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum'; +import { Permission } from '~/app/shared/models/permissions'; import { AlertmanagerAlert, AlertmanagerNotification, AlertmanagerNotificationAlert, PrometheusRule -} from '../app/shared/models/prometheus-alerts'; +} from '~/app/shared/models/prometheus-alerts'; export function configureTestBed(configuration: any, entryComponents?: any) { configureTestSuite(() => { @@ -385,6 +387,19 @@ export class IscsiHelper { } } +export class RgwHelper { + static readonly DAEMON_NAME = 'daemon1'; + static readonly DAEMON_QUERY_PARAM = `daemon_name=${RgwHelper.DAEMON_NAME}`; + + static getCurrentDaemon() { + const rgwDaemon = new RgwDaemon(); + rgwDaemon.id = this.DAEMON_NAME; + rgwDaemon.default = true; + const service = TestBed.inject(RgwDaemonService); + service.selectDaemon(rgwDaemon); + } +} + export class Mocks { static getCrushNode( name: string, diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index cd2a570a8be2e..eba7eac1356c0 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -7404,6 +7404,11 @@ paths: name: stats schema: type: boolean + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -7432,6 +7437,8 @@ paths: properties: bucket: type: string + daemon_name: + type: string lock_enabled: default: 'false' type: string @@ -7488,6 +7495,11 @@ paths: name: purge_objects schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '202': content: @@ -7519,6 +7531,11 @@ paths: required: true schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -7552,6 +7569,8 @@ paths: properties: bucket_id: type: string + daemon_name: + type: string lock_mode: type: string lock_retention_period_days: @@ -7615,11 +7634,19 @@ paths: version: description: Ceph Version type: string + zone_name: + description: Zone + type: string + zonegroup_name: + description: Zone Group + type: string type: object required: - id - version - server_hostname + - zonegroup_name + - zone_name type: array description: OK '400': @@ -7671,6 +7698,11 @@ paths: name: query schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -7726,7 +7758,12 @@ paths: - Rgw /api/rgw/user: get: - parameters: [] + parameters: + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -7765,6 +7802,8 @@ paths: properties: access_key: type: string + daemon_name: + type: string display_name: type: string email: @@ -7809,7 +7848,12 @@ paths: - RgwUser /api/rgw/user/get_emails: get: - parameters: [] + parameters: + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -7837,6 +7881,11 @@ paths: required: true schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '202': content: @@ -7868,6 +7917,11 @@ paths: required: true schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -7899,6 +7953,8 @@ paths: application/json: schema: properties: + daemon_name: + type: string display_name: type: string email: @@ -7950,6 +8006,11 @@ paths: required: true schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '202': content: @@ -7986,6 +8047,8 @@ paths: application/json: schema: properties: + daemon_name: + type: string perm: type: string type: @@ -8041,6 +8104,11 @@ paths: name: access_key schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '202': content: @@ -8079,6 +8147,8 @@ paths: properties: access_key: type: string + daemon_name: + type: string generate_key: default: 'true' type: string @@ -8122,6 +8192,11 @@ paths: required: true schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '200': content: @@ -8153,6 +8228,8 @@ paths: application/json: schema: properties: + daemon_name: + type: string enabled: type: string max_objects: @@ -8208,6 +8285,8 @@ paths: type: string access_key: type: string + daemon_name: + type: string generate_secret: default: 'true' type: string @@ -8267,6 +8346,11 @@ paths: name: purge_keys schema: type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string responses: '202': content: diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 54b26a7a3a1a3..4c3e2821c9c5c 100644 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -11,7 +11,7 @@ from .. import mgr from ..awsauth import S3Auth from ..exceptions import DashboardException from ..rest_client import RequestException, RestClient -from ..settings import Options, Settings +from ..settings import Settings from ..tools import build_url, dict_contains_path, dict_get, json_str_to_object try: @@ -30,7 +30,16 @@ class NoCredentialsException(RequestException): 'the dashboard.') -def _get_daemon_info() -> Dict[str, Any]: +class RgwDaemon: + """Simple representation of a daemon.""" + host: str + name: str + port: int + ssl: bool + zonegroup_name: str + + +def _get_daemons() -> Dict[str, RgwDaemon]: """ Retrieve RGW daemon info from MGR. Note, the service id of the RGW daemons may differ depending on the setup. @@ -76,30 +85,31 @@ def _get_daemon_info() -> Dict[str, Any]: service_map = mgr.get('service_map') if not dict_contains_path(service_map, ['services', 'rgw', 'daemons']): raise LookupError('No RGW found') - daemon = None - daemons = service_map['services']['rgw']['daemons'] - for key in daemons.keys(): - if dict_contains_path(daemons[key], ['metadata', 'frontend_config#0']): - daemon = daemons[key] - break - if daemon is None: + daemons = {} + daemon_map = service_map['services']['rgw']['daemons'] + for key in daemon_map.keys(): + if dict_contains_path(daemon_map[key], ['metadata', 'frontend_config#0']): + daemon = _determine_rgw_addr(daemon_map[key]) + daemon.name = key + daemon.zonegroup_name = daemon_map[key]['metadata']['zonegroup_name'] + daemons[daemon.name] = daemon + logger.info('Found RGW daemon with configuration: host=%s, port=%d, ssl=%s', + daemon.host, daemon.port, str(daemon.ssl)) + if not daemons: raise LookupError('No RGW daemon found') - return daemon + return daemons -def _determine_rgw_addr() -> Tuple[str, int, bool]: +def _determine_rgw_addr(daemon_info: Dict[str, Any]) -> RgwDaemon: """ Parse RGW daemon info to determine the configured host (IP address) and port. """ - daemon = _get_daemon_info() - addr = _parse_addr(daemon['addr']) - port, ssl = _parse_frontend_config(daemon['metadata']['frontend_config#0']) + daemon = RgwDaemon() + daemon.host = _parse_addr(daemon_info['addr']) + daemon.port, daemon.ssl = _parse_frontend_config(daemon_info['metadata']['frontend_config#0']) - logger.info('Auto-detected RGW configuration: addr=%s, port=%d, ssl=%s', - addr, port, str(ssl)) - - return addr, port, ssl + return daemon def _parse_addr(value) -> str: @@ -208,46 +218,28 @@ def _parse_frontend_config(config) -> Tuple[int, bool]: class RgwClient(RestClient): - _SYSTEM_USERID = None - _ADMIN_PATH = None _host = None _port = None _ssl = None - _user_instances = {} # type: Dict[str, RgwClient] + _user_instances = {} # type: Dict[str, Dict[str, RgwClient]] + _config_instances = {} # type: Dict[str, RgwClient] _rgw_settings_snapshot = None + _daemons: Dict[str, RgwDaemon] = {} + daemon: RgwDaemon + got_keys_from_config: bool + userid: str @staticmethod - def _load_settings(): - # The API access key and secret key are mandatory for a minimal configuration. - if not (Settings.RGW_API_ACCESS_KEY and Settings.RGW_API_SECRET_KEY): - logger.warning('No credentials found, please consult the ' - 'documentation about how to enable RGW for the ' - 'dashboard.') - raise NoCredentialsException() - - if Options.has_default_value('RGW_API_HOST') and \ - Options.has_default_value('RGW_API_PORT') and \ - Options.has_default_value('RGW_API_SCHEME'): - host, port, ssl = _determine_rgw_addr() - else: - host = Settings.RGW_API_HOST - port = Settings.RGW_API_PORT - ssl = Settings.RGW_API_SCHEME == 'https' - - RgwClient._host = host - RgwClient._port = port - RgwClient._ssl = ssl - RgwClient._ADMIN_PATH = Settings.RGW_API_ADMIN_RESOURCE - - # Create an instance using the configured settings. - instance = RgwClient(Settings.RGW_API_USER_ID, - Settings.RGW_API_ACCESS_KEY, - Settings.RGW_API_SECRET_KEY) - - RgwClient._SYSTEM_USERID = instance.userid + def _get_daemon_connection_info(daemon_name: str) -> dict: + try: + access_key = Settings.RGW_API_ACCESS_KEY[daemon_name] + secret_key = Settings.RGW_API_SECRET_KEY[daemon_name] + except TypeError: + # Legacy string values. + access_key = Settings.RGW_API_ACCESS_KEY + secret_key = Settings.RGW_API_SECRET_KEY - # Append the instance to the internal map. - RgwClient._user_instances[RgwClient._SYSTEM_USERID] = instance + return {'access_key': access_key, 'secret_key': secret_key} def _get_daemon_zone_info(self): # type: () -> dict return json_str_to_object(self.proxy('GET', 'config?type=zone', None, None)) @@ -263,93 +255,126 @@ class RgwClient(RestClient): Settings.RGW_API_SECRET_KEY, Settings.RGW_API_ADMIN_RESOURCE, Settings.RGW_API_SCHEME, - Settings.RGW_API_USER_ID, Settings.RGW_API_SSL_VERIFY) @staticmethod - def instance(userid): - # type: (Optional[str]) -> RgwClient + def instance(userid: Optional[str] = None, + daemon_name: Optional[str] = None) -> 'RgwClient': + # pylint: disable=too-many-branches + # The API access key and secret key are mandatory for a minimal configuration. + if not (Settings.RGW_API_ACCESS_KEY and Settings.RGW_API_SECRET_KEY): + logger.warning('No credentials found, please consult the ' + 'documentation about how to enable RGW for the ' + 'dashboard.') + raise NoCredentialsException() + + if not RgwClient._daemons: + RgwClient._daemons = _get_daemons() + + if not daemon_name: + # Select default daemon if configured in settings: + if Settings.RGW_API_HOST and Settings.RGW_API_PORT: + for daemon in RgwClient._daemons.values(): + if daemon.host == Settings.RGW_API_HOST \ + and daemon.port == Settings.RGW_API_PORT: + daemon_name = daemon.name + break + if not daemon_name: + raise LookupError('No RGW daemon found with host: {}, port: {}'.format( + Settings.RGW_API_HOST, + Settings.RGW_API_PORT)) + # Select 1st daemon: + else: + daemon_name = next(iter(RgwClient._daemons.keys())) + # Discard all cached instances if any rgw setting has changed if RgwClient._rgw_settings_snapshot != RgwClient._rgw_settings(): RgwClient._rgw_settings_snapshot = RgwClient._rgw_settings() RgwClient.drop_instance() - if not RgwClient._user_instances: - RgwClient._load_settings() + if daemon_name not in RgwClient._config_instances: + connection_info = RgwClient._get_daemon_connection_info(daemon_name) + RgwClient._config_instances[daemon_name] = RgwClient(connection_info['access_key'], + connection_info['secret_key'], + daemon_name) - if not userid: - userid = RgwClient._SYSTEM_USERID + if not userid or userid == RgwClient._config_instances[daemon_name].userid: + return RgwClient._config_instances[daemon_name] - if userid not in RgwClient._user_instances: + if daemon_name not in RgwClient._user_instances \ + or userid not in RgwClient._user_instances[daemon_name]: # Get the access and secret keys for the specified user. - keys = RgwClient.admin_instance().get_user_keys(userid) + keys = RgwClient._config_instances[daemon_name].get_user_keys(userid) if not keys: raise RequestException( "User '{}' does not have any keys configured.".format( userid)) + instance = RgwClient(keys['access_key'], + keys['secret_key'], + daemon_name, + userid) + RgwClient._user_instances.update({daemon_name: {userid: instance}}) - # Create an instance and append it to the internal map. - RgwClient._user_instances[userid] = RgwClient(userid, # type: ignore - keys['access_key'], - keys['secret_key']) - - return RgwClient._user_instances[userid] # type: ignore + return RgwClient._user_instances[daemon_name][userid] @staticmethod - def admin_instance(): - return RgwClient.instance(RgwClient._SYSTEM_USERID) + def admin_instance(daemon_name: Optional[str] = None) -> 'RgwClient': + return RgwClient.instance(daemon_name=daemon_name) @staticmethod - def drop_instance(userid: Optional[str] = None): + def drop_instance(instance: Optional['RgwClient'] = None): """ - Drop a cached instance by name or all. + Drop a cached instance or all. """ - if userid: - RgwClient._user_instances.pop(userid, None) + if instance: + if instance.got_keys_from_config: + del RgwClient._config_instances[instance.daemon.name] + else: + del RgwClient._user_instances[instance.daemon.name][instance.userid] else: + RgwClient._config_instances.clear() RgwClient._user_instances.clear() def _reset_login(self): - if self.userid != RgwClient._SYSTEM_USERID: - logger.info("Fetching new keys for user: %s", self.userid) - keys = RgwClient.admin_instance().get_user_keys(self.userid) - # pylint: disable=attribute-defined-outside-init - self.auth = S3Auth(keys['access_key'], keys['secret_key'], - service_url=self.service_url) - else: + if self.got_keys_from_config: raise RequestException('Authentication failed for the "{}" user: wrong credentials' .format(self.userid), status_code=401) + logger.info("Fetching new keys for user: %s", self.userid) + keys = RgwClient.admin_instance(daemon_name=self.daemon.name).get_user_keys(self.userid) + self.auth = S3Auth(keys['access_key'], keys['secret_key'], + service_url=self.service_url) - def __init__(self, # pylint: disable-msg=R0913 - userid, + def __init__(self, access_key, secret_key, - host=None, - port=None, - admin_path=None, - ssl=False): - - if not host and not RgwClient._host: - RgwClient._load_settings() - host = host if host else RgwClient._host - port = port if port else RgwClient._port - admin_path = admin_path if admin_path else RgwClient._ADMIN_PATH - ssl = ssl if ssl else RgwClient._ssl + daemon_name, + user_id=None): + daemon = RgwClient._daemons[daemon_name] ssl_verify = Settings.RGW_API_SSL_VERIFY + self.admin_path = Settings.RGW_API_ADMIN_RESOURCE + self.service_url = build_url(host=daemon.host, port=daemon.port) + + self.auth = S3Auth(access_key, secret_key, service_url=self.service_url) + super(RgwClient, self).__init__(daemon.host, + daemon.port, + 'RGW', + daemon.ssl, + self.auth, + ssl_verify=ssl_verify) + self.got_keys_from_config = not user_id + try: + self.userid = self._get_user_id(self.admin_path) if self.got_keys_from_config \ + else user_id + except RequestException as error: + # Avoid dashboard GUI redirections caused by status code (403, ...): + http_status_code = 400 if 400 <= error.status_code < 500 else error.status_code + raise DashboardException(msg='Error connecting to Object Gateway.', + http_status_code=http_status_code, + component='rgw') + self.daemon = daemon - self.service_url = build_url(host=host, port=port) - self.admin_path = admin_path - - s3auth = S3Auth(access_key, secret_key, service_url=self.service_url) - super(RgwClient, self).__init__(host, port, 'RGW', ssl, s3auth, ssl_verify=ssl_verify) - - # If user ID is not set, then try to get it via the RGW Admin Ops API. - self.userid = userid if userid else self._get_user_id(self.admin_path) # type: str - - self._zonegroup_name: str = _get_daemon_info()['metadata']['zonegroup_name'] - - logger.info("Created new connection: user=%s, host=%s, port=%s, ssl=%d, sslverify=%d", - self.userid, host, port, ssl, ssl_verify) + logger.info("Created new connection: daemon=%s, host=%s, port=%s, ssl=%d, sslverify=%d", + daemon.name, daemon.host, daemon.port, daemon.ssl, ssl_verify) @RestClient.api_get('/', resp_structure='[0] > (ID & DisplayName)') def is_service_online(self, request=None): @@ -495,7 +520,8 @@ class RgwClient(RestClient): } ) - return {'zonegroup': self._zonegroup_name, 'placement_targets': placement_targets} + return {'zonegroup': self.daemon.zonegroup_name, + 'placement_targets': placement_targets} def get_realms(self): # type: () -> List realms_info = self._get_realms_info() diff --git a/src/pybind/mgr/dashboard/settings.py b/src/pybind/mgr/dashboard/settings.py index d42e6ed5b4559..6c1b90374fb72 100644 --- a/src/pybind/mgr/dashboard/settings.py +++ b/src/pybind/mgr/dashboard/settings.py @@ -3,12 +3,47 @@ from __future__ import absolute_import import errno import inspect +from ast import literal_eval +from typing import Any from mgr_module import CLICheckNonemptyFileInput from . import mgr +class Setting: + """ + Setting representation that allows to set a default value and a list of allowed data types. + :param default_value: The name of the bucket. + :param types: a list consisting of the primary/preferred type and, optionally, + secondary/legacy types for backward compatibility. + """ + + def __init__(self, default_value: Any, types: list): + if not isinstance(types, list): + raise ValueError('Setting types must be a list.') + default_value_type = type(default_value) + if default_value_type not in types: + raise ValueError('Default value type not allowed.') + self.default_value = default_value + self.types = types + + def types_as_str(self): + return ','.join([x.__name__ for x in self.types]) + + def cast(self, value): + for type_index, setting_type in enumerate(self.types): + try: + if setting_type.__name__ == 'bool' and str(value).lower() == 'false': + return False + elif setting_type.__name__ == 'dict': + return literal_eval(value) + return setting_type(value) + except (SyntaxError, TypeError, ValueError) as error: + if type_index == len(self.types) - 1: + raise error + + class Options(object): """ If you need to store some configuration value please add the config option @@ -19,93 +54,84 @@ class Options(object): GRAFANA_API_HOST = ('localhost', str) GRAFANA_API_PORT = (3000, int) """ - ENABLE_BROWSABLE_API = (True, bool) - REST_REQUESTS_TIMEOUT = (45, int) + ENABLE_BROWSABLE_API = Setting(True, [bool]) + REST_REQUESTS_TIMEOUT = Setting(45, [int]) # AUTHENTICATION ATTEMPTS - ACCOUNT_LOCKOUT_ATTEMPTS = (10, int) + ACCOUNT_LOCKOUT_ATTEMPTS = Setting(10, [int]) # API auditing - AUDIT_API_ENABLED = (False, bool) - AUDIT_API_LOG_PAYLOAD = (True, bool) + AUDIT_API_ENABLED = Setting(False, [bool]) + AUDIT_API_LOG_PAYLOAD = Setting(True, [bool]) # RGW settings - RGW_API_HOST = ('', str) - RGW_API_PORT = (80, int) - RGW_API_ACCESS_KEY = ('', str) - RGW_API_SECRET_KEY = ('', str) - RGW_API_ADMIN_RESOURCE = ('admin', str) - RGW_API_SCHEME = ('http', str) - RGW_API_USER_ID = ('', str) - RGW_API_SSL_VERIFY = (True, bool) + RGW_API_HOST = Setting('', [dict, str]) + RGW_API_PORT = Setting(80, [dict, int]) + RGW_API_ACCESS_KEY = Setting('', [dict, str]) + RGW_API_SECRET_KEY = Setting('', [dict, str]) + RGW_API_ADMIN_RESOURCE = Setting('admin', [str]) + RGW_API_SCHEME = Setting('http', [str]) + RGW_API_USER_ID = Setting('', [dict, str]) + RGW_API_SSL_VERIFY = Setting(True, [bool]) # Grafana settings - GRAFANA_API_URL = ('', str) - GRAFANA_FRONTEND_API_URL = ('', str) - GRAFANA_API_USERNAME = ('admin', str) - GRAFANA_API_PASSWORD = ('admin', str) - GRAFANA_API_SSL_VERIFY = (True, bool) - GRAFANA_UPDATE_DASHBOARDS = (False, bool) + GRAFANA_API_URL = Setting('', [str]) + GRAFANA_FRONTEND_API_URL = Setting('', [str]) + GRAFANA_API_USERNAME = Setting('admin', [str]) + GRAFANA_API_PASSWORD = Setting('admin', [str]) + GRAFANA_API_SSL_VERIFY = Setting(True, [bool]) + GRAFANA_UPDATE_DASHBOARDS = Setting(False, [bool]) # NFS Ganesha settings - GANESHA_CLUSTERS_RADOS_POOL_NAMESPACE = ('', str) + GANESHA_CLUSTERS_RADOS_POOL_NAMESPACE = Setting('', [str]) # Prometheus settings - PROMETHEUS_API_HOST = ('', str) - PROMETHEUS_API_SSL_VERIFY = (True, bool) - ALERTMANAGER_API_HOST = ('', str) - ALERTMANAGER_API_SSL_VERIFY = (True, bool) + PROMETHEUS_API_HOST = Setting('', [str]) + PROMETHEUS_API_SSL_VERIFY = Setting(True, [bool]) + ALERTMANAGER_API_HOST = Setting('', [str]) + ALERTMANAGER_API_SSL_VERIFY = Setting(True, [bool]) # iSCSI management settings - ISCSI_API_SSL_VERIFICATION = (True, bool) + ISCSI_API_SSL_VERIFICATION = Setting(True, [bool]) # user management settings # Time span of user passwords to expire in days. # The default value is '0' which means that user passwords are # never going to expire. - USER_PWD_EXPIRATION_SPAN = (0, int) + USER_PWD_EXPIRATION_SPAN = Setting(0, [int]) # warning levels to notify the user that the password is going # to expire soon - USER_PWD_EXPIRATION_WARNING_1 = (10, int) - USER_PWD_EXPIRATION_WARNING_2 = (5, int) + USER_PWD_EXPIRATION_WARNING_1 = Setting(10, [int]) + USER_PWD_EXPIRATION_WARNING_2 = Setting(5, [int]) # Password policy - PWD_POLICY_ENABLED = (True, bool) + PWD_POLICY_ENABLED = Setting(True, [bool]) # Individual checks - PWD_POLICY_CHECK_LENGTH_ENABLED = (True, bool) - PWD_POLICY_CHECK_OLDPWD_ENABLED = (True, bool) - PWD_POLICY_CHECK_USERNAME_ENABLED = (False, bool) - PWD_POLICY_CHECK_EXCLUSION_LIST_ENABLED = (False, bool) - PWD_POLICY_CHECK_COMPLEXITY_ENABLED = (False, bool) - PWD_POLICY_CHECK_SEQUENTIAL_CHARS_ENABLED = (False, bool) - PWD_POLICY_CHECK_REPETITIVE_CHARS_ENABLED = (False, bool) + PWD_POLICY_CHECK_LENGTH_ENABLED = Setting(True, [bool]) + PWD_POLICY_CHECK_OLDPWD_ENABLED = Setting(True, [bool]) + PWD_POLICY_CHECK_USERNAME_ENABLED = Setting(False, [bool]) + PWD_POLICY_CHECK_EXCLUSION_LIST_ENABLED = Setting(False, [bool]) + PWD_POLICY_CHECK_COMPLEXITY_ENABLED = Setting(False, [bool]) + PWD_POLICY_CHECK_SEQUENTIAL_CHARS_ENABLED = Setting(False, [bool]) + PWD_POLICY_CHECK_REPETITIVE_CHARS_ENABLED = Setting(False, [bool]) # Settings - PWD_POLICY_MIN_LENGTH = (8, int) - PWD_POLICY_MIN_COMPLEXITY = (10, int) - PWD_POLICY_EXCLUSION_LIST = (','.join(['osd', 'host', - 'dashboard', 'pool', - 'block', 'nfs', - 'ceph', 'monitors', - 'gateway', 'logs', - 'crush', 'maps']), - str) + PWD_POLICY_MIN_LENGTH = Setting(8, [int]) + PWD_POLICY_MIN_COMPLEXITY = Setting(10, [int]) + PWD_POLICY_EXCLUSION_LIST = Setting(','.join(['osd', 'host', 'dashboard', 'pool', + 'block', 'nfs', 'ceph', 'monitors', + 'gateway', 'logs', 'crush', 'maps']), + [str]) @staticmethod def has_default_value(name): return getattr(Settings, name, None) is None or \ - getattr(Settings, name) == getattr(Options, name)[0] + getattr(Settings, name) == getattr(Options, name).default_value class SettingsMeta(type): def __getattr__(cls, attr): - default, stype = getattr(Options, attr) - if stype == bool and str(mgr.get_module_option( - attr, - default)).lower() == 'false': - value = False - else: - value = stype(mgr.get_module_option(attr, default)) - return value + setting = getattr(Options, attr) + return setting.cast(mgr.get_module_option(attr, setting.default_value)) def __setattr__(cls, attr, value): if not attr.startswith('_') and hasattr(Options, attr): @@ -128,14 +154,14 @@ def _options_command_map(): return not inspect.isroutine(member) cmd_map = {} - for option, value in inspect.getmembers(Options, filter_attr): + for option, setting in inspect.getmembers(Options, filter_attr): if option.startswith('_'): continue key_get = 'dashboard get-{}'.format(option.lower().replace('_', '-')) key_set = 'dashboard set-{}'.format(option.lower().replace('_', '-')) key_reset = 'dashboard reset-{}'.format(option.lower().replace('_', '-')) cmd_map[key_get] = {'name': option, 'type': None} - cmd_map[key_set] = {'name': option, 'type': value[1]} + cmd_map[key_set] = {'name': option, 'type': setting.types_as_str()} cmd_map[key_reset] = {'name': option, 'type': None} return cmd_map @@ -191,11 +217,11 @@ def options_schema_list(): return not inspect.isroutine(member) result = [] - for option, value in inspect.getmembers(Options, filter_attr): + for option, setting in inspect.getmembers(Options, filter_attr): if option.startswith('_'): continue - result.append({'name': option, 'default': value[0], - 'type': value[1].__name__}) + result.append({'name': option, 'default': setting.default_value, + 'type': setting.types_as_str()}) return result @@ -219,10 +245,8 @@ def handle_option_command(cmd, inbuf): return value, stdout, stderr else: value = cmd['value'] - value = opt['type'](value) - if opt['type'] == bool and cmd['value'].lower() == 'false': - value = False - setattr(Settings, opt['name'], value) + setting = getattr(Options, opt['name']) + setattr(Settings, opt['name'], setting.cast(value)) return 0, 'Option {} updated'.format(opt['name']), '' diff --git a/src/pybind/mgr/dashboard/tests/test_rgw.py b/src/pybind/mgr/dashboard/tests/test_rgw.py index 1a99db59287cd..5201f8d11156c 100644 --- a/src/pybind/mgr/dashboard/tests/test_rgw.py +++ b/src/pybind/mgr/dashboard/tests/test_rgw.py @@ -34,10 +34,10 @@ class RgwUserControllerTestCase(ControllerTestCase): 'keys': ['test1', 'test2', 'test3'], 'truncated': False }] - self._get('/test/api/rgw/user') + self._get('/test/api/rgw/user?daemon_name=dummy-daemon') self.assertStatus(200) mock_proxy.assert_has_calls([ - mock.call('GET', 'user?list', {}) + mock.call('dummy-daemon', 'GET', 'user?list', {}) ]) self.assertJsonBody(['test1', 'test2', 'test3']) @@ -56,8 +56,8 @@ class RgwUserControllerTestCase(ControllerTestCase): self._get('/test/api/rgw/user') self.assertStatus(200) mock_proxy.assert_has_calls([ - mock.call('GET', 'user?list', {}), - mock.call('GET', 'user?list', {'marker': 'foo:bar'}) + mock.call(None, 'GET', 'user?list', {}), + mock.call(None, 'GET', 'user?list', {'marker': 'foo:bar'}) ]) self.assertJsonBody(['test1', 'test2', 'test3', 'admin']) diff --git a/src/pybind/mgr/dashboard/tests/test_rgw_client.py b/src/pybind/mgr/dashboard/tests/test_rgw_client.py index 7cde2d4c3ff42..f12fa4aa8d5b6 100644 --- a/src/pybind/mgr/dashboard/tests/test_rgw_client.py +++ b/src/pybind/mgr/dashboard/tests/test_rgw_client.py @@ -3,34 +3,35 @@ import unittest try: - from unittest.mock import patch + from unittest.mock import MagicMock, patch except ImportError: - from mock import patch # type: ignore + from mock import MagicMock, patch # type: ignore -from ..services.rgw_client import RgwClient, _parse_frontend_config +from ..services.rgw_client import NoCredentialsException, RgwClient, \ + RgwDaemon, _parse_frontend_config from ..settings import Settings from . import KVStoreMockMixin # pylint: disable=no-name-in-module -def _dummy_daemon_info(): - return { - 'addr': '172.20.0.2:0/256594694', - 'metadata': { - 'zonegroup_name': 'zonegroup2-realm1' - } - } +def _get_daemons_stub(): + daemon = RgwDaemon() + daemon.host = 'rgw.1.myorg.com' + daemon.port = 8000 + daemon.ssl = True + daemon.name = 'rgw.1.myorg.com' + daemon.zonegroup_name = 'zonegroup2-realm1' + return {daemon.name: daemon} -@patch('dashboard.services.rgw_client._get_daemon_info', _dummy_daemon_info) +@patch('dashboard.services.rgw_client._get_daemons', _get_daemons_stub) +@patch('dashboard.services.rgw_client.RgwClient._get_user_id', MagicMock( + return_value='dummy_admin')) class RgwClientTest(unittest.TestCase, KVStoreMockMixin): def setUp(self): - RgwClient._user_instances.clear() # pylint: disable=protected-access self.mock_kv_store() self.CONFIG_KEY_DICT.update({ 'RGW_API_ACCESS_KEY': 'klausmustermann', 'RGW_API_SECRET_KEY': 'supergeheim', - 'RGW_API_HOST': 'localhost', - 'RGW_API_USER_ID': 'rgwadmin' }) def test_ssl_verify(self): @@ -43,6 +44,24 @@ class RgwClientTest(unittest.TestCase, KVStoreMockMixin): instance = RgwClient.admin_instance() self.assertFalse(instance.session.verify) + def test_no_credentials(self): + self.CONFIG_KEY_DICT.update({ + 'RGW_API_ACCESS_KEY': '', + 'RGW_API_SECRET_KEY': '', + }) + with self.assertRaises(NoCredentialsException) as cm: + RgwClient.admin_instance() + self.assertIn('No RGW credentials found', str(cm.exception)) + + def test_default_daemon_wrong_settings(self): + self.CONFIG_KEY_DICT.update({ + 'RGW_API_HOST': 'localhost', + 'RGW_API_PORT': '7990', + }) + with self.assertRaises(LookupError) as cm: + RgwClient.admin_instance() + self.assertIn('No RGW daemon found with host:', str(cm.exception)) + @patch.object(RgwClient, '_get_daemon_zone_info') def test_get_placement_targets_from_zone(self, zone_info): zone_info.return_value = { diff --git a/src/pybind/mgr/dashboard/tests/test_settings.py b/src/pybind/mgr/dashboard/tests/test_settings.py index 240eafee348ab..e92b580e3a627 100644 --- a/src/pybind/mgr/dashboard/tests/test_settings.py +++ b/src/pybind/mgr/dashboard/tests/test_settings.py @@ -15,10 +15,10 @@ from . import ControllerTestCase, KVStoreMockMixin # pylint: disable=no-name-in class SettingsTest(unittest.TestCase, KVStoreMockMixin): @classmethod def setUpClass(cls): + setattr(settings.Options, 'GRAFANA_API_HOST', settings.Setting('localhost', [str])) + setattr(settings.Options, 'GRAFANA_API_PORT', settings.Setting(3000, [int])) + setattr(settings.Options, 'GRAFANA_ENABLED', settings.Setting(False, [bool])) # pylint: disable=protected-access - settings.Options.GRAFANA_API_HOST = ('localhost', str) - settings.Options.GRAFANA_API_PORT = (3000, int) - settings.Options.GRAFANA_ENABLED = (False, bool) settings._OPTIONS_COMMAND_MAP = settings._options_command_map() def setUp(self): @@ -138,9 +138,8 @@ class SettingsControllerTest(ControllerTestCase, KVStoreMockMixin): @classmethod def setUpClass(cls): super().setUpClass() - # pylint: disable=protected-access - settings.Options.GRAFANA_API_HOST = ('localhost', str) - settings.Options.GRAFANA_ENABLED = (False, bool) + setattr(settings.Options, 'GRAFANA_API_HOST', settings.Setting('localhost', [str])) + setattr(settings.Options, 'GRAFANA_ENABLED', settings.Setting(False, [bool])) @classmethod def tearDownClass(cls): -- 2.39.5