]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/controllers/rgw.py
import ceph pacific 16.2.5
[ceph.git] / ceph / src / pybind / mgr / dashboard / controllers / rgw.py
CommitLineData
11fdf7f2
TL
1# -*- coding: utf-8 -*-
2from __future__ import absolute_import
3
4import json
f67539c2 5import logging
11fdf7f2
TL
6
7import cherrypy
f67539c2 8
11fdf7f2
TL
9from ..exceptions import DashboardException
10from ..rest_client import RequestException
f67539c2 11from ..security import Permission, Scope
9f95a23c 12from ..services.auth import AuthManager, JwtManager
11fdf7f2 13from ..services.ceph_service import CephService
f67539c2 14from ..services.rgw_client import NoRgwDaemonsException, RgwClient
9f95a23c 15from ..tools import json_str_to_object, str_to_bool
f67539c2
TL
16from . import ApiController, BaseController, ControllerDoc, Endpoint, \
17 EndpointDoc, ReadPermission, RESTController, allow_empty_body
9f95a23c
TL
18
19try:
f67539c2 20 from typing import Any, List, Optional
f6b5b4d7 21except ImportError: # pragma: no cover
9f95a23c
TL
22 pass # Just for type checking
23
f67539c2
TL
24logger = logging.getLogger("controllers.rgw")
25
26RGW_SCHEMA = {
27 "available": (bool, "Is RGW available?"),
28 "message": (str, "Descriptions")
29}
30
31RGW_DAEMON_SCHEMA = {
32 "id": (str, "Daemon ID"),
33 "version": (str, "Ceph Version"),
34 "server_hostname": (str, ""),
35 "zonegroup_name": (str, "Zone Group"),
36 "zone_name": (str, "Zone")
37}
38
39RGW_USER_SCHEMA = {
40 "list_of_users": ([str], "list of rgw users")
41}
11fdf7f2
TL
42
43
44@ApiController('/rgw', Scope.RGW)
f67539c2 45@ControllerDoc("RGW Management API", "Rgw")
11fdf7f2 46class Rgw(BaseController):
11fdf7f2
TL
47 @Endpoint()
48 @ReadPermission
f67539c2
TL
49 @EndpointDoc("Display RGW Status",
50 responses={200: RGW_SCHEMA})
51 def status(self) -> dict:
11fdf7f2
TL
52 status = {'available': False, 'message': None}
53 try:
54 instance = RgwClient.admin_instance()
55 # Check if the service is online.
adb31ebb 56 try:
f67539c2 57 is_online = instance.is_service_online()
adb31ebb
TL
58 except RequestException as e:
59 # Drop this instance because the RGW client seems not to
60 # exist anymore (maybe removed via orchestrator). Removing
61 # the instance from the cache will result in the correct
62 # error message next time when the backend tries to
63 # establish a new connection (-> 'No RGW found' instead
64 # of 'RGW REST API failed request ...').
65 # Note, this only applies to auto-detected RGW clients.
f67539c2 66 RgwClient.drop_instance(instance)
adb31ebb
TL
67 raise e
68 if not is_online:
11fdf7f2
TL
69 msg = 'Failed to connect to the Object Gateway\'s Admin Ops API.'
70 raise RequestException(msg)
11fdf7f2 71 # Ensure the system flag is set for the API user ID.
f6b5b4d7 72 if not instance.is_system_user(): # pragma: no cover - no complexity there
11fdf7f2
TL
73 msg = 'The system flag is not set for user "{}".'.format(
74 instance.userid)
75 raise RequestException(msg)
76 status['available'] = True
f67539c2 77 except (DashboardException, RequestException, NoRgwDaemonsException) as ex:
9f95a23c 78 status['message'] = str(ex) # type: ignore
11fdf7f2
TL
79 return status
80
81
82@ApiController('/rgw/daemon', Scope.RGW)
f67539c2 83@ControllerDoc("RGW Daemon Management API", "RgwDaemon")
11fdf7f2 84class RgwDaemon(RESTController):
f67539c2
TL
85 @EndpointDoc("Display RGW Daemons",
86 responses={200: [RGW_DAEMON_SCHEMA]})
87 def list(self) -> List[dict]:
88 daemons: List[dict] = []
89 try:
90 instance = RgwClient.admin_instance()
91 except NoRgwDaemonsException:
92 return daemons
93
11fdf7f2
TL
94 for hostname, server in CephService.get_service_map('rgw').items():
95 for service in server['services']:
96 metadata = service['metadata']
97
98 # extract per-daemon service data and health
99 daemon = {
f67539c2 100 'id': metadata['id'],
11fdf7f2 101 'version': metadata['ceph_version'],
f67539c2
TL
102 'server_hostname': hostname,
103 'zonegroup_name': metadata['zonegroup_name'],
104 'zone_name': metadata['zone_name'],
105 'default': instance.daemon.name == metadata['id']
11fdf7f2
TL
106 }
107
108 daemons.append(daemon)
109
110 return sorted(daemons, key=lambda k: k['id'])
111
112 def get(self, svc_id):
9f95a23c 113 # type: (str) -> dict
11fdf7f2
TL
114 daemon = {
115 'rgw_metadata': [],
116 'rgw_id': svc_id,
117 'rgw_status': []
118 }
119 service = CephService.get_service('rgw', svc_id)
120 if not service:
121 raise cherrypy.NotFound('Service rgw {} is not available'.format(svc_id))
122
123 metadata = service['metadata']
124 status = service['status']
125 if 'json' in status:
126 try:
127 status = json.loads(status['json'])
128 except ValueError:
129 logger.warning('%s had invalid status json', service['id'])
130 status = {}
131 else:
132 logger.warning('%s has no key "json" in status', service['id'])
133
134 daemon['rgw_metadata'] = metadata
135 daemon['rgw_status'] = status
136 return daemon
137
138
139class RgwRESTController(RESTController):
f67539c2 140 def proxy(self, daemon_name, method, path, params=None, json_response=True):
11fdf7f2 141 try:
f67539c2 142 instance = RgwClient.admin_instance(daemon_name=daemon_name)
11fdf7f2 143 result = instance.proxy(method, path, params, None)
9f95a23c
TL
144 if json_response:
145 result = json_str_to_object(result)
11fdf7f2
TL
146 return result
147 except (DashboardException, RequestException) as e:
f67539c2
TL
148 http_status_code = e.status if isinstance(e, DashboardException) else 500
149 raise DashboardException(e, http_status_code=http_status_code, component='rgw')
11fdf7f2
TL
150
151
9f95a23c 152@ApiController('/rgw/site', Scope.RGW)
f67539c2 153@ControllerDoc("RGW Site Management API", "RgwSite")
9f95a23c 154class RgwSite(RgwRESTController):
f67539c2 155 def list(self, query=None, daemon_name=None):
9f95a23c 156 if query == 'placement-targets':
f67539c2
TL
157 return RgwClient.admin_instance(daemon_name=daemon_name).get_placement_targets()
158 if query == 'realms':
159 return RgwClient.admin_instance(daemon_name=daemon_name).get_realms()
9f95a23c 160
f67539c2
TL
161 # @TODO: for multisite: by default, retrieve cluster topology/map.
162 raise DashboardException(http_status_code=501, component='rgw', msg='Not Implemented')
9f95a23c
TL
163
164
11fdf7f2 165@ApiController('/rgw/bucket', Scope.RGW)
f67539c2 166@ControllerDoc("RGW Bucket Management API", "RgwBucket")
11fdf7f2 167class RgwBucket(RgwRESTController):
11fdf7f2
TL
168 def _append_bid(self, bucket):
169 """
170 Append the bucket identifier that looks like [<tenant>/]<bucket>.
171 See http://docs.ceph.com/docs/nautilus/radosgw/multitenancy/ for
172 more information.
173 :param bucket: The bucket parameters.
174 :type bucket: dict
175 :return: The modified bucket parameters including the 'bid' parameter.
176 :rtype: dict
177 """
178 if isinstance(bucket, dict):
179 bucket['bid'] = '{}/{}'.format(bucket['tenant'], bucket['bucket']) \
180 if bucket['tenant'] else bucket['bucket']
181 return bucket
182
f67539c2
TL
183 def _get_versioning(self, owner, daemon_name, bucket_name):
184 rgw_client = RgwClient.instance(owner, daemon_name)
9f95a23c
TL
185 return rgw_client.get_bucket_versioning(bucket_name)
186
f67539c2 187 def _set_versioning(self, owner, daemon_name, bucket_name, versioning_state, mfa_delete,
9f95a23c 188 mfa_token_serial, mfa_token_pin):
f67539c2 189 bucket_versioning = self._get_versioning(owner, daemon_name, bucket_name)
9f95a23c
TL
190 if versioning_state != bucket_versioning['Status']\
191 or (mfa_delete and mfa_delete != bucket_versioning['MfaDelete']):
f67539c2 192 rgw_client = RgwClient.instance(owner, daemon_name)
9f95a23c
TL
193 rgw_client.set_bucket_versioning(bucket_name, versioning_state, mfa_delete,
194 mfa_token_serial, mfa_token_pin)
195
f67539c2
TL
196 def _get_locking(self, owner, daemon_name, bucket_name):
197 rgw_client = RgwClient.instance(owner, daemon_name)
9f95a23c
TL
198 return rgw_client.get_bucket_locking(bucket_name)
199
f67539c2 200 def _set_locking(self, owner, daemon_name, bucket_name, mode,
9f95a23c 201 retention_period_days, retention_period_years):
f67539c2 202 rgw_client = RgwClient.instance(owner, daemon_name)
9f95a23c 203 return rgw_client.set_bucket_locking(bucket_name, mode,
b3b6e05e
TL
204 retention_period_days,
205 retention_period_years)
9f95a23c 206
eafe8130 207 @staticmethod
9f95a23c
TL
208 def strip_tenant_from_bucket_name(bucket_name):
209 # type (str) -> str
eafe8130 210 """
9f95a23c 211 >>> RgwBucket.strip_tenant_from_bucket_name('tenant/bucket-name')
eafe8130 212 'bucket-name'
9f95a23c 213 >>> RgwBucket.strip_tenant_from_bucket_name('bucket-name')
eafe8130
TL
214 'bucket-name'
215 """
9f95a23c 216 return bucket_name[bucket_name.find('/') + 1:]
eafe8130 217
9f95a23c
TL
218 @staticmethod
219 def get_s3_bucket_name(bucket_name, tenant=None):
220 # type (str, str) -> str
221 """
222 >>> RgwBucket.get_s3_bucket_name('bucket-name', 'tenant')
223 'tenant:bucket-name'
224 >>> RgwBucket.get_s3_bucket_name('tenant/bucket-name', 'tenant')
225 'tenant:bucket-name'
226 >>> RgwBucket.get_s3_bucket_name('bucket-name')
227 'bucket-name'
228 """
229 bucket_name = RgwBucket.strip_tenant_from_bucket_name(bucket_name)
230 if tenant:
231 bucket_name = '{}:{}'.format(tenant, bucket_name)
eafe8130
TL
232 return bucket_name
233
f67539c2
TL
234 def list(self, stats=False, daemon_name=None):
235 # type: (bool, Optional[str]) -> List[Any]
f91f0fd5 236 query_params = '?stats' if stats else ''
f67539c2 237 result = self.proxy(daemon_name, 'GET', 'bucket{}'.format(query_params))
f91f0fd5
TL
238
239 if stats:
240 result = [self._append_bid(bucket) for bucket in result]
241
242 return result
11fdf7f2 243
f67539c2
TL
244 def get(self, bucket, daemon_name=None):
245 # type: (str, Optional[str]) -> dict
246 result = self.proxy(daemon_name, 'GET', 'bucket', {'bucket': bucket})
9f95a23c
TL
247 bucket_name = RgwBucket.get_s3_bucket_name(result['bucket'],
248 result['tenant'])
249
250 # Append the versioning configuration.
f67539c2 251 versioning = self._get_versioning(result['owner'], daemon_name, bucket_name)
9f95a23c
TL
252 result['versioning'] = versioning['Status']
253 result['mfa_delete'] = versioning['MfaDelete']
254
255 # Append the locking configuration.
f67539c2 256 locking = self._get_locking(result['owner'], daemon_name, bucket_name)
9f95a23c
TL
257 result.update(locking)
258
11fdf7f2
TL
259 return self._append_bid(result)
260
f91f0fd5 261 @allow_empty_body
9f95a23c
TL
262 def create(self, bucket, uid, zonegroup=None, placement_target=None,
263 lock_enabled='false', lock_mode=None,
264 lock_retention_period_days=None,
f67539c2 265 lock_retention_period_years=None, daemon_name=None):
9f95a23c 266 lock_enabled = str_to_bool(lock_enabled)
11fdf7f2 267 try:
f67539c2 268 rgw_client = RgwClient.instance(uid, daemon_name)
9f95a23c
TL
269 result = rgw_client.create_bucket(bucket, zonegroup,
270 placement_target,
271 lock_enabled)
272 if lock_enabled:
f67539c2 273 self._set_locking(uid, daemon_name, bucket, lock_mode,
9f95a23c
TL
274 lock_retention_period_days,
275 lock_retention_period_years)
276 return result
f6b5b4d7 277 except RequestException as e: # pragma: no cover - handling is too obvious
11fdf7f2
TL
278 raise DashboardException(e, http_status_code=500, component='rgw')
279
f91f0fd5 280 @allow_empty_body
9f95a23c
TL
281 def set(self, bucket, bucket_id, uid, versioning_state=None,
282 mfa_delete=None, mfa_token_serial=None, mfa_token_pin=None,
283 lock_mode=None, lock_retention_period_days=None,
f67539c2 284 lock_retention_period_years=None, daemon_name=None):
9f95a23c
TL
285 # When linking a non-tenant-user owned bucket to a tenanted user, we
286 # need to prefix bucket name with '/'. e.g. photos -> /photos
287 if '$' in uid and '/' not in bucket:
288 bucket = '/{}'.format(bucket)
289
290 # Link bucket to new user:
f67539c2
TL
291 result = self.proxy(daemon_name,
292 'PUT',
9f95a23c
TL
293 'bucket', {
294 'bucket': bucket,
295 'bucket-id': bucket_id,
296 'uid': uid
297 },
298 json_response=False)
299
300 uid_tenant = uid[:uid.find('$')] if uid.find('$') >= 0 else None
301 bucket_name = RgwBucket.get_s3_bucket_name(bucket, uid_tenant)
302
b3b6e05e 303 locking = self._get_locking(uid, daemon_name, bucket_name)
9f95a23c 304 if versioning_state:
b3b6e05e
TL
305 if versioning_state == 'Suspended' and locking['lock_enabled']:
306 raise DashboardException(msg='Bucket versioning cannot be disabled/suspended '
307 'on buckets with object lock enabled ',
308 http_status_code=409, component='rgw')
f67539c2 309 self._set_versioning(uid, daemon_name, bucket_name, versioning_state,
9f95a23c
TL
310 mfa_delete, mfa_token_serial, mfa_token_pin)
311
312 # Update locking if it is enabled.
9f95a23c 313 if locking['lock_enabled']:
f67539c2 314 self._set_locking(uid, daemon_name, bucket_name, lock_mode,
9f95a23c
TL
315 lock_retention_period_days,
316 lock_retention_period_years)
317
11fdf7f2
TL
318 return self._append_bid(result)
319
f67539c2
TL
320 def delete(self, bucket, purge_objects='true', daemon_name=None):
321 return self.proxy(daemon_name, 'DELETE', 'bucket', {
11fdf7f2
TL
322 'bucket': bucket,
323 'purge-objects': purge_objects
324 }, json_response=False)
325
326
327@ApiController('/rgw/user', Scope.RGW)
f67539c2 328@ControllerDoc("RGW User Management API", "RgwUser")
11fdf7f2 329class RgwUser(RgwRESTController):
11fdf7f2
TL
330 def _append_uid(self, user):
331 """
332 Append the user identifier that looks like [<tenant>$]<user>.
333 See http://docs.ceph.com/docs/jewel/radosgw/multitenancy/ for
334 more information.
335 :param user: The user parameters.
336 :type user: dict
337 :return: The modified user parameters including the 'uid' parameter.
338 :rtype: dict
339 """
340 if isinstance(user, dict):
341 user['uid'] = '{}${}'.format(user['tenant'], user['user_id']) \
342 if user['tenant'] else user['user_id']
343 return user
344
9f95a23c
TL
345 @staticmethod
346 def _keys_allowed():
347 permissions = AuthManager.get_user(JwtManager.get_username()).permissions_dict()
348 edit_permissions = [Permission.CREATE, Permission.UPDATE, Permission.DELETE]
349 return Scope.RGW in permissions and Permission.READ in permissions[Scope.RGW] \
350 and len(set(edit_permissions).intersection(set(permissions[Scope.RGW]))) > 0
351
f67539c2
TL
352 @EndpointDoc("Display RGW Users",
353 responses={200: RGW_USER_SCHEMA})
354 def list(self, daemon_name=None):
355 # type: (Optional[str]) -> List[str]
9f95a23c 356 users = [] # type: List[str]
11fdf7f2
TL
357 marker = None
358 while True:
9f95a23c 359 params = {} # type: dict
11fdf7f2
TL
360 if marker:
361 params['marker'] = marker
f67539c2 362 result = self.proxy(daemon_name, 'GET', 'user?list', params)
11fdf7f2
TL
363 users.extend(result['keys'])
364 if not result['truncated']:
365 break
366 # Make sure there is a marker.
367 assert result['marker']
368 # Make sure the marker has changed.
369 assert marker != result['marker']
370 marker = result['marker']
371 return users
372
f67539c2
TL
373 def get(self, uid, daemon_name=None, stats=True) -> dict:
374 query_params = '?stats' if stats else ''
375 result = self.proxy(daemon_name, 'GET', 'user{}'.format(query_params),
376 {'uid': uid, 'stats': stats})
9f95a23c
TL
377 if not self._keys_allowed():
378 del result['keys']
379 del result['swift_keys']
11fdf7f2
TL
380 return self._append_uid(result)
381
382 @Endpoint()
383 @ReadPermission
f67539c2
TL
384 def get_emails(self, daemon_name=None):
385 # type: (Optional[str]) -> List[str]
11fdf7f2 386 emails = []
f67539c2
TL
387 for uid in json.loads(self.list(daemon_name)): # type: ignore
388 user = json.loads(self.get(uid, daemon_name)) # type: ignore
11fdf7f2
TL
389 if user["email"]:
390 emails.append(user["email"])
391 return emails
392
f91f0fd5 393 @allow_empty_body
11fdf7f2
TL
394 def create(self, uid, display_name, email=None, max_buckets=None,
395 suspended=None, generate_key=None, access_key=None,
f67539c2 396 secret_key=None, daemon_name=None):
11fdf7f2
TL
397 params = {'uid': uid}
398 if display_name is not None:
399 params['display-name'] = display_name
400 if email is not None:
401 params['email'] = email
402 if max_buckets is not None:
403 params['max-buckets'] = max_buckets
404 if suspended is not None:
405 params['suspended'] = suspended
406 if generate_key is not None:
407 params['generate-key'] = generate_key
408 if access_key is not None:
409 params['access-key'] = access_key
410 if secret_key is not None:
411 params['secret-key'] = secret_key
f67539c2 412 result = self.proxy(daemon_name, 'PUT', 'user', params)
11fdf7f2
TL
413 return self._append_uid(result)
414
f91f0fd5 415 @allow_empty_body
11fdf7f2 416 def set(self, uid, display_name=None, email=None, max_buckets=None,
f67539c2 417 suspended=None, daemon_name=None):
11fdf7f2
TL
418 params = {'uid': uid}
419 if display_name is not None:
420 params['display-name'] = display_name
421 if email is not None:
422 params['email'] = email
423 if max_buckets is not None:
424 params['max-buckets'] = max_buckets
425 if suspended is not None:
426 params['suspended'] = suspended
f67539c2 427 result = self.proxy(daemon_name, 'POST', 'user', params)
11fdf7f2
TL
428 return self._append_uid(result)
429
f67539c2 430 def delete(self, uid, daemon_name=None):
11fdf7f2 431 try:
f67539c2 432 instance = RgwClient.admin_instance(daemon_name=daemon_name)
11fdf7f2
TL
433 # Ensure the user is not configured to access the RGW Object Gateway.
434 if instance.userid == uid:
435 raise DashboardException(msg='Unable to delete "{}" - this user '
436 'account is required for managing the '
437 'Object Gateway'.format(uid))
438 # Finally redirect request to the RGW proxy.
f67539c2 439 return self.proxy(daemon_name, 'DELETE', 'user', {'uid': uid}, json_response=False)
f6b5b4d7 440 except (DashboardException, RequestException) as e: # pragma: no cover
11fdf7f2
TL
441 raise DashboardException(e, component='rgw')
442
443 # pylint: disable=redefined-builtin
444 @RESTController.Resource(method='POST', path='/capability', status=201)
f91f0fd5 445 @allow_empty_body
f67539c2
TL
446 def create_cap(self, uid, type, perm, daemon_name=None):
447 return self.proxy(daemon_name, 'PUT', 'user?caps', {
11fdf7f2
TL
448 'uid': uid,
449 'user-caps': '{}={}'.format(type, perm)
450 })
451
452 # pylint: disable=redefined-builtin
453 @RESTController.Resource(method='DELETE', path='/capability', status=204)
f67539c2
TL
454 def delete_cap(self, uid, type, perm, daemon_name=None):
455 return self.proxy(daemon_name, 'DELETE', 'user?caps', {
11fdf7f2
TL
456 'uid': uid,
457 'user-caps': '{}={}'.format(type, perm)
458 })
459
460 @RESTController.Resource(method='POST', path='/key', status=201)
f91f0fd5 461 @allow_empty_body
11fdf7f2 462 def create_key(self, uid, key_type='s3', subuser=None, generate_key='true',
f67539c2 463 access_key=None, secret_key=None, daemon_name=None):
11fdf7f2
TL
464 params = {'uid': uid, 'key-type': key_type, 'generate-key': generate_key}
465 if subuser is not None:
466 params['subuser'] = subuser
467 if access_key is not None:
468 params['access-key'] = access_key
469 if secret_key is not None:
470 params['secret-key'] = secret_key
f67539c2 471 return self.proxy(daemon_name, 'PUT', 'user?key', params)
11fdf7f2
TL
472
473 @RESTController.Resource(method='DELETE', path='/key', status=204)
f67539c2 474 def delete_key(self, uid, key_type='s3', subuser=None, access_key=None, daemon_name=None):
11fdf7f2
TL
475 params = {'uid': uid, 'key-type': key_type}
476 if subuser is not None:
477 params['subuser'] = subuser
478 if access_key is not None:
479 params['access-key'] = access_key
f67539c2 480 return self.proxy(daemon_name, 'DELETE', 'user?key', params, json_response=False)
11fdf7f2
TL
481
482 @RESTController.Resource(method='GET', path='/quota')
f67539c2
TL
483 def get_quota(self, uid, daemon_name=None):
484 return self.proxy(daemon_name, 'GET', 'user?quota', {'uid': uid})
11fdf7f2
TL
485
486 @RESTController.Resource(method='PUT', path='/quota')
f91f0fd5 487 @allow_empty_body
f67539c2
TL
488 def set_quota(self, uid, quota_type, enabled, max_size_kb, max_objects, daemon_name=None):
489 return self.proxy(daemon_name, 'PUT', 'user?quota', {
11fdf7f2
TL
490 'uid': uid,
491 'quota-type': quota_type,
492 'enabled': enabled,
493 'max-size-kb': max_size_kb,
494 'max-objects': max_objects
495 }, json_response=False)
496
497 @RESTController.Resource(method='POST', path='/subuser', status=201)
f91f0fd5 498 @allow_empty_body
11fdf7f2
TL
499 def create_subuser(self, uid, subuser, access, key_type='s3',
500 generate_secret='true', access_key=None,
f67539c2
TL
501 secret_key=None, daemon_name=None):
502 return self.proxy(daemon_name, 'PUT', 'user', {
11fdf7f2
TL
503 'uid': uid,
504 'subuser': subuser,
505 'key-type': key_type,
506 'access': access,
507 'generate-secret': generate_secret,
508 'access-key': access_key,
509 'secret-key': secret_key
510 })
511
512 @RESTController.Resource(method='DELETE', path='/subuser/{subuser}', status=204)
f67539c2 513 def delete_subuser(self, uid, subuser, purge_keys='true', daemon_name=None):
11fdf7f2
TL
514 """
515 :param purge_keys: Set to False to do not purge the keys.
516 Note, this only works for s3 subusers.
517 """
f67539c2 518 return self.proxy(daemon_name, 'DELETE', 'user', {
11fdf7f2
TL
519 'uid': uid,
520 'subuser': subuser,
521 'purge-keys': purge_keys
522 }, json_response=False)