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