diff options
author | Volker Theile <vtheile@suse.com> | 2020-03-10 12:38:20 +0100 |
---|---|---|
committer | Volker Theile <vtheile@suse.com> | 2020-03-12 11:56:21 +0100 |
commit | b0b188da570c9d1afcfd9cd5a8123b43bf41b07b (patch) | |
tree | cdbc4a4c9b150fdaa6ad8be9e1ca35255b096ef9 | |
parent | Merge pull request #33831 from dillaman/wip-44396 (diff) | |
download | ceph-b0b188da570c9d1afcfd9cd5a8123b43bf41b07b.tar.xz ceph-b0b188da570c9d1afcfd9cd5a8123b43bf41b07b.zip |
mgr/dashboard: Create bucket with x-amz-bucket-object-lock-enabled
Fixes: https://tracker.ceph.com/issues/43446
Signed-off-by: Volker Theile <vtheile@suse.com>
11 files changed, 544 insertions, 86 deletions
diff --git a/qa/tasks/mgr/dashboard/test_rgw.py b/qa/tasks/mgr/dashboard/test_rgw.py index 72878498ee8..9cb3504b5ae 100644 --- a/qa/tasks/mgr/dashboard/test_rgw.py +++ b/qa/tasks/mgr/dashboard/test_rgw.py @@ -265,7 +265,7 @@ class RgwBucketTest(RgwTestCase): self.assertStatus(200) self.assertEqual(len(data), 0) - def test_create_get_update_delete_w_tenant(self): + def test_crud_w_tenant(self): # Create a new bucket. The tenant of the user is used when # the bucket is created. self._post( @@ -361,6 +361,56 @@ class RgwBucketTest(RgwTestCase): self.assertStatus(200) self.assertEqual(len(data), 0) + def test_crud_w_locking(self): + # Create + self._post('/api/rgw/bucket', + params={ + 'bucket': 'teuth-test-bucket', + 'uid': 'teuth-test-user', + 'zonegroup': 'default', + 'placement_target': 'default-placement', + 'lock_enabled': 'true', + 'lock_mode': 'GOVERNANCE', + 'lock_retention_period_days': '0', + 'lock_retention_period_years': '1' + }) + self.assertStatus(201) + # Read + data = self._get('/api/rgw/bucket/teuth-test-bucket') + self.assertStatus(200) + self.assertSchema( + data, + JObj(sub_elems={ + 'lock_enabled': JLeaf(bool), + 'lock_mode': JLeaf(str), + 'lock_retention_period_days': JLeaf(int), + 'lock_retention_period_years': JLeaf(int) + }, + allow_unknown=True)) + self.assertTrue(data['lock_enabled']) + self.assertEqual(data['lock_mode'], 'GOVERNANCE') + self.assertEqual(data['lock_retention_period_days'], 0) + self.assertEqual(data['lock_retention_period_years'], 1) + # Update + self._put('/api/rgw/bucket/teuth-test-bucket', + params={ + 'bucket_id': data['id'], + 'uid': 'teuth-test-user', + 'lock_mode': 'COMPLIANCE', + 'lock_retention_period_days': '15', + 'lock_retention_period_years': '0' + }) + self.assertStatus(200) + data = self._get('/api/rgw/bucket/teuth-test-bucket') + self.assertTrue(data['lock_enabled']) + self.assertEqual(data['lock_mode'], 'COMPLIANCE') + self.assertEqual(data['lock_retention_period_days'], 15) + self.assertEqual(data['lock_retention_period_years'], 0) + self.assertStatus(200) + # Delete + self._delete('/api/rgw/bucket/teuth-test-bucket') + self.assertStatus(204) + class RgwDaemonTest(RgwTestCase): diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 542ba396752..433113763f1 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -14,20 +14,18 @@ from ..security import Scope, Permission from ..services.auth import AuthManager, JwtManager from ..services.ceph_service import CephService from ..services.rgw_client import RgwClient -from ..tools import json_str_to_object +from ..tools import json_str_to_object, str_to_bool try: from typing import List except ImportError: pass # Just for type checking - logger = logging.getLogger('controllers.rgw') @ApiController('/rgw', Scope.RGW) class Rgw(BaseController): - @Endpoint() @ReadPermission def status(self): @@ -56,7 +54,6 @@ class Rgw(BaseController): @ApiController('/rgw/daemon', Scope.RGW) class RgwDaemon(RESTController): - def list(self): # type: () -> List[dict] daemons = [] @@ -103,7 +100,6 @@ class RgwDaemon(RESTController): class RgwRESTController(RESTController): - def proxy(self, method, path, params=None, json_response=True): try: instance = RgwClient.admin_instance() @@ -117,7 +113,6 @@ class RgwRESTController(RESTController): @ApiController('/rgw/site', Scope.RGW) class RgwSite(RgwRESTController): - def list(self, query=None): if query == 'placement-targets': instance = RgwClient.admin_instance() @@ -132,7 +127,6 @@ class RgwSite(RgwRESTController): @ApiController('/rgw/bucket', Scope.RGW) class RgwBucket(RgwRESTController): - def _append_bid(self, bucket): """ Append the bucket identifier that looks like [<tenant>/]<bucket>. @@ -161,6 +155,17 @@ class RgwBucket(RgwRESTController): 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) + return rgw_client.get_bucket_locking(bucket_name) + + def _set_locking(self, owner, bucket_name, mode, + retention_period_days, retention_period_years): + rgw_client = RgwClient.instance(owner) + return rgw_client.set_bucket_locking(bucket_name, mode, + int(retention_period_days), + int(retention_period_years)) + @staticmethod def strip_tenant_from_bucket_name(bucket_name): # type (str) -> str @@ -195,41 +200,69 @@ class RgwBucket(RgwRESTController): def get(self, bucket): # type: (str) -> dict result = self.proxy('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) + result['versioning'] = versioning['Status'] + result['mfa_delete'] = versioning['MfaDelete'] - bucket_versioning =\ - self._get_versioning(result['owner'], - RgwBucket.get_s3_bucket_name(result['bucket'], result['tenant'])) - result['versioning'] = bucket_versioning['Status'] - result['mfa_delete'] = bucket_versioning['MfaDelete'] + # Append the locking configuration. + locking = self._get_locking(result['owner'], bucket_name) + result.update(locking) return self._append_bid(result) - def create(self, bucket, uid, zonegroup=None, placement_target=None): + 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_enabled = str_to_bool(lock_enabled) try: rgw_client = RgwClient.instance(uid) - return rgw_client.create_bucket(bucket, zonegroup, placement_target) + result = rgw_client.create_bucket(bucket, zonegroup, + placement_target, + lock_enabled) + if lock_enabled: + self._set_locking(uid, bucket, lock_mode, + lock_retention_period_days, + lock_retention_period_years) + return result except RequestException as e: raise DashboardException(e, http_status_code=500, component='rgw') - def set(self, bucket, bucket_id, uid, versioning_state=None, mfa_delete=None, - mfa_token_serial=None, mfa_token_pin=None): + 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): # 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', 'bucket', { - 'bucket': bucket, - 'bucket-id': bucket_id, - 'uid': uid - }, json_response=False) + result = self.proxy('PUT', + 'bucket', { + 'bucket': bucket, + 'bucket-id': bucket_id, + 'uid': uid + }, + json_response=False) + + uid_tenant = uid[:uid.find('$')] if uid.find('$') >= 0 else None + bucket_name = RgwBucket.get_s3_bucket_name(bucket, uid_tenant) if versioning_state: - uid_tenant = uid[:uid.find('$')] if uid.find('$') >= 0 else None - self._set_versioning(uid, - RgwBucket.get_s3_bucket_name(bucket, uid_tenant), - versioning_state, mfa_delete, mfa_token_serial, mfa_token_pin) + self._set_versioning(uid, 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) + if locking['lock_enabled']: + self._set_locking(uid, bucket_name, lock_mode, + lock_retention_period_days, + lock_retention_period_years) return self._append_bid(result) @@ -242,7 +275,6 @@ class RgwBucket(RgwRESTController): @ApiController('/rgw/user', Scope.RGW) class RgwUser(RgwRESTController): - def _append_uid(self, user): """ Append the user identifier that looks like [<tenant>$]<user>. diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html index 9f0c2ddd5dd..3d0cb7b4829 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html @@ -103,6 +103,35 @@ </tbody> </table> </div> + + <!-- Locking --> + <legend i18n>Locking</legend> + <table class="table table-striped table-bordered"> + <tbody> + <tr> + <td i18n + class="bold w-25">Enabled</td> + <td class="w-75">{{ bucket.lock_enabled | booleanText }}</td> + </tr> + <ng-container *ngIf="bucket.lock_enabled"> + <tr> + <td i18n + class="bold">Mode</td> + <td>{{ bucket.lock_mode }}</td> + </tr> + <tr> + <td i18n + class="bold">Days</td> + <td>{{ bucket.lock_retention_period_days }}</td> + </tr> + <tr> + <td i18n + class="bold">Years</td> + <td>{{ bucket.lock_retention_period_years }}</td> + </tr> + </ng-container> + </tbody> + </table> </div> </tab> </tabset> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html index 6d5dcca0102..a8bf4c0d957 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html @@ -119,10 +119,10 @@ </div> <!-- Versioning --> - <legend class="cd-header" - i18n>Versioning</legend> + <fieldset *ngIf="editing"> + <legend class="cd-header" + i18n>Versioning</legend> - <ng-container *ngIf="editing"> <div class="form-group row"> <div class="cd-col-form-offset"> <div class="custom-control custom-checkbox"> @@ -135,14 +135,17 @@ (change)="updateVersioning()"> <label class="custom-control-label" for="versioning" - i18n>Versioning enabled</label> + i18n>Enabled</label> <cd-helper> <span i18n>Enables versioning for the objects in the bucket.</span> </cd-helper> </div> </div> </div> + </fieldset> + <!-- Multi-Factor Authentication --> + <fieldset *ngIf="editing"> <!-- MFA Delete --> <legend class="cd-header" i18n>Multi-Factor Authentication</legend> @@ -198,8 +201,96 @@ i18n>This field is required.</span> </div> </div> - </ng-container> + </fieldset> + + <!-- Locking --> + <fieldset> + <legend class="cd-header" + i18n>Locking</legend> + + <!-- Locking enabled --> + <div class="form-group row"> + <div class="cd-col-form-offset"> + <div class="custom-control custom-checkbox"> + <input class="custom-control-input" + id="lock_enabled" + formControlName="lock_enabled" + type="checkbox"> + <label class="custom-control-label" + for="lock_enabled" + i18n>Enabled</label> + <cd-helper> + <span i18n>Enables locking for the objects in the bucket. Locking can only be enabled while creating a bucket.</span> + </cd-helper> + </div> + </div> + </div> + + <!-- Locking mode --> + <div *ngIf="bucketForm.getValue('lock_enabled')" + class="form-group row"> + <label class="cd-col-form-label" + for="lock_mode" + i18n>Mode</label> + <div class="cd-col-form-input"> + <select class="form-control custom-select" + formControlName="lock_mode" + name="lock_mode" + id="lock_mode"> + <option i18n + value="COMPLIANCE">Compliance</option> + <option i18n + value="GOVERNANCE">Governance</option> + </select> + </div> + </div> + <!-- Retention period (days) --> + <div *ngIf="bucketForm.getValue('lock_enabled')" + class="form-group row"> + <label class="cd-col-form-label" + for="lock_retention_period_days"> + <ng-container i18n>Days</ng-container> + <cd-helper i18n>The number of days that you want to specify for the default retention period that will be applied to new objects placed in this bucket.</cd-helper> + </label> + <div class="cd-col-form-input"> + <input class="form-control" + type="number" + id="lock_retention_period_days" + formControlName="lock_retention_period_days" + min="0"> + <span class="invalid-feedback" + *ngIf="bucketForm.showError('lock_retention_period_days', frm, 'pattern')" + i18n>The entered value must be a positive integer.</span> + <span class="invalid-feedback" + *ngIf="bucketForm.showError('lock_retention_period_days', frm, 'eitherDaysOrYears')" + i18n>Retention period requires either Days or Years.</span> + </div> + </div> + + <!-- Retention period (years) --> + <div *ngIf="bucketForm.getValue('lock_enabled')" + class="form-group row"> + <label class="cd-col-form-label" + for="lock_retention_period_years"> + <ng-container i18n>Years</ng-container> + <cd-helper i18n>The number of years that you want to specify for the default retention period that will be applied to new objects placed in this bucket.</cd-helper> + </label> + <div class="cd-col-form-input"> + <input class="form-control" + type="number" + id="lock_retention_period_years" + formControlName="lock_retention_period_years" + min="0"> + <span class="invalid-feedback" + *ngIf="bucketForm.showError('lock_retention_period_days', frm, 'pattern')" + i18n>The entered value must be a positive integer.</span> + <span class="invalid-feedback" + *ngIf="bucketForm.showError('lock_retention_period_years', frm, 'eitherDaysOrYears')" + i18n>Retention period requires either Days or Years.</span> + </div> + </div> + </fieldset> </div> <div class="card-footer"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts index 53880bdb027..0cb297c3f1f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts @@ -8,7 +8,7 @@ import * as _ from 'lodash'; import { ToastrModule } from 'ngx-toastr'; import { of as observableOf } from 'rxjs'; -import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { configureTestBed, FormHelper, i18nProviders } from '../../../../testing/unit-test-helper'; import { RgwBucketService } from '../../../shared/api/rgw-bucket.service'; import { RgwSiteService } from '../../../shared/api/rgw-site.service'; import { NotificationType } from '../../../shared/enum/notification-type.enum'; @@ -24,6 +24,7 @@ describe('RgwBucketFormComponent', () => { let rgwBucketService: RgwBucketService; let getPlacementTargetsSpy: jasmine.Spy; let rgwBucketServiceGetSpy: jasmine.Spy; + let formHelper: FormHelper; configureTestBed({ declarations: [RgwBucketFormComponent], @@ -43,6 +44,7 @@ describe('RgwBucketFormComponent', () => { rgwBucketService = TestBed.get(RgwBucketService); rgwBucketServiceGetSpy = spyOn(rgwBucketService, 'get'); getPlacementTargetsSpy = spyOn(TestBed.get(RgwSiteService), 'getPlacementTargets'); + formHelper = new FormHelper(component.bucketForm); }); it('should create', () => { @@ -315,4 +317,75 @@ describe('RgwBucketFormComponent', () => { ); }); }); + + describe('object locking', () => { + const setDaysAndYears = (fn: (name: string) => void) => { + ['lock_retention_period_days', 'lock_retention_period_years'].forEach(fn); + }; + + const expectPatternLockError = (value: string) => { + formHelper.setValue('lock_enabled', true, true); + setDaysAndYears((name: string) => { + formHelper.setValue(name, value); + formHelper.expectError(name, 'pattern'); + }); + }; + + const expectValidLockInputs = (enabled: boolean, mode: string, days: string, years: string) => { + formHelper.setValue('lock_enabled', enabled); + formHelper.setValue('lock_mode', mode); + formHelper.setValue('lock_retention_period_days', days); + formHelper.setValue('lock_retention_period_years', years); + [ + 'lock_enabled', + 'lock_mode', + 'lock_retention_period_days', + 'lock_retention_period_years' + ].forEach((name) => { + const control = component.bucketForm.get(name); + expect(control.valid).toBeTruthy(); + expect(control.errors).toBeNull(); + }); + }; + + it('should check lock enabled checkbox [mode=create]', () => { + component.createForm(); + const control = component.bucketForm.get('lock_enabled'); + expect(control.disabled).toBeFalsy(); + }); + + it('should check lock enabled checkbox [mode=edit]', () => { + component.editing = true; + component.createForm(); + const control = component.bucketForm.get('lock_enabled'); + expect(control.disabled).toBeTruthy(); + }); + + it('should have the "eitherDaysOrYears" error', () => { + formHelper.setValue('lock_enabled', true); + setDaysAndYears((name: string) => { + const control = component.bucketForm.get(name); + control.updateValueAndValidity(); + expect(control.value).toBe(0); + expect(control.invalid).toBeTruthy(); + formHelper.expectError(control, 'eitherDaysOrYears'); + }); + }); + + it('should have the "pattern" error [1]', () => { + expectPatternLockError('-1'); + }); + + it('should have the "pattern" error [2]', () => { + expectPatternLockError('1.2'); + }); + + it('should have valid values [1]', () => { + expectValidLockInputs(true, 'Governance', '0', '1'); + }); + + it('should have valid values [2]', () => { + expectValidLockInputs(false, 'Compliance', '100', '0'); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts index 8eab976a7f4..bd1cfcbd051 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts @@ -57,6 +57,16 @@ export class RgwBucketFormComponent implements OnInit { } createForm() { + const self = this; + const eitherDaysOrYears = CdValidators.custom('eitherDaysOrYears', () => { + if (!self.bucketForm || !_.get(self.bucketForm.getRawValue(), 'lock_enabled')) { + return false; + } + const years = self.bucketForm.getValue('lock_retention_period_years'); + const days = self.bucketForm.getValue('lock_retention_period_days'); + return (days > 0 && years > 0) || (days === 0 && years === 0); + }); + const lockPeriodDefinition = [0, [CdValidators.number(false), eitherDaysOrYears]]; this.bucketForm = this.formBuilder.group({ id: [null], bid: [null, [Validators.required], this.editing ? [] : [this.bucketNameValidator()]], @@ -65,7 +75,11 @@ export class RgwBucketFormComponent implements OnInit { versioning: [null], 'mfa-delete': [null], 'mfa-token-serial': [''], - 'mfa-token-pin': [''] + 'mfa-token-pin': [''], + lock_enabled: [{ value: false, disabled: this.editing }], + lock_mode: ['COMPLIANCE'], + lock_retention_period_days: lockPeriodDefinition, + lock_retention_period_years: lockPeriodDefinition }); } @@ -103,10 +117,13 @@ export class RgwBucketFormComponent implements OnInit { this.rgwBucketService.get(bid).subscribe((resp: object) => { this.loading = false; - // Get the default values. - const defaults = _.clone(this.bucketForm.value); - // Extract the values displayed in the form. - let value: object = _.pick(resp, _.keys(this.bucketForm.value)); + // Get the default values (incl. the values from disabled fields). + const defaults = _.clone(this.bucketForm.getRawValue()); + // Get the values displayed in the form. We need to do that to + // extract those key/value pairs from the response data, otherwise + // the Angular react framework will throw an error if there is no + // field for a given key. + let value: object = _.pick(resp, _.keys(defaults)); value['placement-target'] = resp['placement_rule']; // Append default values. value = _.merge(defaults, value); @@ -133,31 +150,29 @@ export class RgwBucketFormComponent implements OnInit { this.goToListView(); return; } - const bidCtl = this.bucketForm.get('bid'); - const ownerCtl = this.bucketForm.get('owner'); - const placementTargetCtl = this.bucketForm.get('placement-target'); + const values = this.bucketForm.value; if (this.editing) { // Edit - const idCtl = this.bucketForm.get('id'); const versioning = this.getVersioningStatus(); const mfaDelete = this.getMfaDeleteStatus(); - const mfaTokenSerial = this.bucketForm.getValue('mfa-token-serial'); - const mfaTokenPin = this.bucketForm.getValue('mfa-token-pin'); this.rgwBucketService .update( - bidCtl.value, - idCtl.value, - ownerCtl.value, + values['bid'], + values['id'], + values['owner'], versioning, mfaDelete, - mfaTokenSerial, - mfaTokenPin + values['mfa-token-serial'], + values['mfa-token-pin'], + values['lock_mode'], + values['lock_retention_period_days'], + values['lock_retention_period_years'] ) .subscribe( () => { this.notificationService.show( NotificationType.success, - this.i18n('Updated Object Gateway bucket "{{bid}}".', { bid: bidCtl.value }) + this.i18n('Updated Object Gateway bucket "{{bid}}".', values) ); this.goToListView(); }, @@ -169,12 +184,21 @@ export class RgwBucketFormComponent implements OnInit { } else { // Add this.rgwBucketService - .create(bidCtl.value, ownerCtl.value, this.zonegroup, placementTargetCtl.value) + .create( + values['bid'], + values['owner'], + this.zonegroup, + values['placement-target'], + values['lock_enabled'], + values['lock_mode'], + values['lock_retention_period_days'], + values['lock_retention_period_years'] + ) .subscribe( () => { this.notificationService.show( NotificationType.success, - this.i18n('Created Object Gateway bucket "{{bid}}"', { bid: bidCtl.value }) + this.i18n('Created Object Gateway bucket "{{bid}}"', values) ); this.goToListView(); }, @@ -290,7 +314,6 @@ export class RgwBucketFormComponent implements OnInit { updateVersioning() { this.isVersioningEnabled = !this.isVersioningEnabled; - this.setMfaDeleteValidators(); } @@ -304,7 +327,6 @@ export class RgwBucketFormComponent implements OnInit { updateMfaDelete() { this.isMfaDeleteEnabled = !this.isMfaDeleteEnabled; - this.setMfaDeleteValidators(); } } 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 53c57cc5e50..0d342c48d1c 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 @@ -62,17 +62,21 @@ describe('RgwBucketService', () => { }); it('should call create', () => { - service.create('foo', 'bar', 'default', 'default-placement').subscribe(); + service + .create('foo', 'bar', 'default', 'default-placement', false, 'COMPLIANCE', '10', '0') + .subscribe(); const req = httpTesting.expectOne( - 'api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement' + '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' ); expect(req.request.method).toBe('POST'); }); it('should call update', () => { - service.update('foo', 'bar', 'baz', 'Enabled', 'Enabled', '1', '223344').subscribe(); + service + .update('foo', 'bar', 'baz', 'Enabled', 'Enabled', '1', '223344', 'GOVERNANCE', '0', '1') + .subscribe(); 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' + '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' ); expect(req.request.method).toBe('PUT'); }); 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 70a1f3985e6..71907497d70 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 @@ -48,14 +48,30 @@ export class RgwBucketService { return this.http.get(`${this.url}/${bucket}`); } - create(bucket: string, uid: string, zonegroup: string, placementTarget: string) { - let params = new HttpParams(); - params = params.append('bucket', bucket); - params = params.append('uid', uid); - params = params.append('zonegroup', zonegroup); - params = params.append('placement_target', placementTarget); - - return this.http.post(this.url, null, { params: params }); + create( + bucket: string, + uid: string, + zonegroup: string, + placementTarget: string, + lockEnabled: boolean, + lock_mode: 'GOVERNANCE' | 'COMPLIANCE', + 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 + } + }) + }); } update( @@ -65,7 +81,10 @@ export class RgwBucketService { versioningState: string, mfaDelete: string, mfaTokenSerial: string, - mfaTokenPin: string + mfaTokenPin: string, + lockMode: 'GOVERNANCE' | 'COMPLIANCE', + lockRetentionPeriodDays: string, + lockRetentionPeriodYears: string ) { let params = new HttpParams(); params = params.append('bucket_id', bucketId); @@ -74,6 +93,9 @@ export class RgwBucketService { 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 }); } diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 794340e7577..78d8623ef48 100644 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -11,7 +11,8 @@ from ..awsauth import S3Auth from ..exceptions import DashboardException from ..settings import Settings, Options from ..rest_client import RestClient, RequestException -from ..tools import build_url, dict_contains_path, json_str_to_object, partial_dict +from ..tools import build_url, dict_contains_path, json_str_to_object,\ + partial_dict, dict_get from .. import mgr try: @@ -19,7 +20,6 @@ try: except ImportError: pass # For typing only - logger = logging.getLogger('rgw_client') @@ -399,21 +399,23 @@ class RgwClient(RestClient): return self._admin_get_user_keys(self.admin_path, userid) @RestClient.api('/{admin_path}/{path}') - def _proxy_request(self, # pylint: disable=too-many-arguments - admin_path, - path, - method, - params, - data, - request=None): + def _proxy_request( + self, # pylint: disable=too-many-arguments + admin_path, + path, + method, + params, + data, + request=None): # pylint: disable=unused-argument - return request( - method=method, params=params, data=data, raw_content=True) + return request(method=method, params=params, data=data, + raw_content=True) def proxy(self, method, path, params, data): - logger.debug("proxying method=%s path=%s params=%s data=%s", method, - path, params, data) - return self._proxy_request(self.admin_path, path, method, params, data) + logger.debug("proxying method=%s path=%s params=%s data=%s", + method, path, params, data) + return self._proxy_request(self.admin_path, path, method, + params, data) @RestClient.api_get('/', resp_structure='[1][*] > Name') def get_buckets(self, request=None): @@ -447,7 +449,9 @@ class RgwClient(RestClient): raise e @RestClient.api_put('/{bucket_name}') - def create_bucket(self, bucket_name, zonegroup=None, placement_target=None, request=None): + def create_bucket(self, bucket_name, zonegroup=None, + placement_target=None, lock_enabled=False, + request=None): logger.info("Creating bucket: %s, zonegroup: %s, placement_target: %s", bucket_name, zonegroup, placement_target) data = None @@ -455,9 +459,13 @@ class RgwClient(RestClient): create_bucket_configuration = ET.Element('CreateBucketConfiguration') location_constraint = ET.SubElement(create_bucket_configuration, 'LocationConstraint') location_constraint.text = '{}:{}'.format(zonegroup, placement_target) - data = ET.tostring(create_bucket_configuration, encoding='utf-8') + data = ET.tostring(create_bucket_configuration, encoding='unicode') + + headers = None # type: Optional[dict] + if lock_enabled: + headers = {'x-amz-bucket-object-lock-enabled': 'true'} - return request(data=data) + return request(data=data, headers=headers) def get_placement_targets(self): # type: () -> dict zone = self._get_daemon_zone_info() @@ -523,7 +531,7 @@ class RgwClient(RestClient): mfa_delete_element = ET.SubElement(versioning_configuration, 'MfaDelete') mfa_delete_element.text = mfa_delete - data = ET.tostring(versioning_configuration, encoding='utf-8') + data = ET.tostring(versioning_configuration, encoding='unicode') try: request(data=data, headers=headers) @@ -536,3 +544,109 @@ class RgwClient(RestClient): raise DashboardException(msg=msg, http_status_code=http_status_code, component='rgw') + + @RestClient.api_get('/{bucket_name}?object-lock') + def get_bucket_locking(self, bucket_name, request=None): + # type: (str, Optional[object]) -> dict + """ + Gets the locking configuration for a bucket. The locking + configuration will be applied by default to every new object + placed in the specified bucket. + :param bucket_name: The name of the bucket. + :type bucket_name: str + :return: The locking configuration. + :rtype: Dict + """ + # pylint: disable=unused-argument + + # Try to get the Object Lock configuration. If there is none, + # then return default values. + try: + result = request() # type: ignore + return { + 'lock_enabled': dict_get(result, 'ObjectLockEnabled') == 'Enabled', + 'lock_mode': dict_get(result, 'Rule.DefaultRetention.Mode'), + 'lock_retention_period_days': dict_get(result, 'Rule.DefaultRetention.Days', 0), + 'lock_retention_period_years': dict_get(result, 'Rule.DefaultRetention.Years', 0) + } + except RequestException as e: + if e.content: + content = json_str_to_object(e.content) + if content.get( + 'Code') == 'ObjectLockConfigurationNotFoundError': + return { + 'lock_enabled': False, + 'lock_mode': 'compliance', + 'lock_retention_period_days': None, + 'lock_retention_period_years': None + } + raise e + + @RestClient.api_put('/{bucket_name}?object-lock') + def set_bucket_locking(self, + bucket_name, + mode, + retention_period_days, + retention_period_years, + request=None): + # type: (str, str, int, int, Optional[object]) -> None + """ + Places the locking configuration on the specified bucket. The + locking configuration will be applied by default to every new + object placed in the specified bucket. + :param bucket_name: The name of the bucket. + :type bucket_name: str + :param mode: The lock mode, e.g. `COMPLIANCE` or `GOVERNANCE`. + :type mode: str + :param retention_period_days: + :type retention_period_days: int + :param retention_period_years: + :type retention_period_years: int + :rtype: None + """ + # pylint: disable=unused-argument + + # Do some validations. + if retention_period_days and retention_period_years: + # https://docs.aws.amazon.com/AmazonS3/latest/API/archive-RESTBucketPUTObjectLockConfiguration.html + msg = "Retention period requires either Days or Years. "\ + "You can't specify both at the same time." + raise DashboardException(msg=msg, component='rgw') + if not retention_period_days and not retention_period_years: + msg = "Retention period requires either Days or Years. "\ + "You must specify at least one." + raise DashboardException(msg=msg, component='rgw') + + # Generate the XML data like this: + # <ObjectLockConfiguration> + # <ObjectLockEnabled>string</ObjectLockEnabled> + # <Rule> + # <DefaultRetention> + # <Days>integer</Days> + # <Mode>string</Mode> + # <Years>integer</Years> + # </DefaultRetention> + # </Rule> + # </ObjectLockConfiguration> + locking_configuration = ET.Element('ObjectLockConfiguration') + enabled_element = ET.SubElement(locking_configuration, + 'ObjectLockEnabled') + enabled_element.text = 'Enabled' # Locking can't be disabled. + rule_element = ET.SubElement(locking_configuration, 'Rule') + default_retention_element = ET.SubElement(rule_element, + 'DefaultRetention') + mode_element = ET.SubElement(default_retention_element, 'Mode') + mode_element.text = mode.upper() + if retention_period_days: + days_element = ET.SubElement(default_retention_element, 'Days') + days_element.text = str(retention_period_days) + if retention_period_years: + years_element = ET.SubElement(default_retention_element, 'Years') + years_element.text = str(retention_period_years) + + data = ET.tostring(locking_configuration, encoding='unicode') + + try: + _ = request(data=data) # type: ignore + except RequestException as e: + raise DashboardException(msg=str(e), component='rgw') diff --git a/src/pybind/mgr/dashboard/tests/test_tools.py b/src/pybind/mgr/dashboard/tests/test_tools.py index 1960b2a5584..0f27ec8e634 100644 --- a/src/pybind/mgr/dashboard/tests/test_tools.py +++ b/src/pybind/mgr/dashboard/tests/test_tools.py @@ -14,7 +14,8 @@ from . import ControllerTestCase from ..services.exception import handle_rados_error from ..controllers import RESTController, ApiController, Controller, \ BaseController, Proxy -from ..tools import dict_contains_path, json_str_to_object, partial_dict, RequestLoggingTool +from ..tools import dict_contains_path, json_str_to_object, partial_dict,\ + dict_get, RequestLoggingTool # pylint: disable=W0613 @@ -196,3 +197,8 @@ class TestFunctions(unittest.TestCase): self.assertRaises(KeyError, partial_dict, {'a': 1, 'b': 2, 'c': 3}, ['d']) self.assertRaises(TypeError, partial_dict, None, ['a']) self.assertRaises(TypeError, partial_dict, {'a': 1, 'b': 2, 'c': 3}, None) + + def test_dict_get(self): + self.assertFalse(dict_get({'foo': {'bar': False}}, 'foo.bar')) + self.assertIsNone(dict_get({'foo': {'bar': False}}, 'foo.bar.baz')) + self.assertEqual(dict_get({'foo': {'bar': False}, 'baz': 'xyz'}, 'baz'), 'xyz') diff --git a/src/pybind/mgr/dashboard/tools.py b/src/pybind/mgr/dashboard/tools.py index ce730862d22..2b6d92ca55f 100644 --- a/src/pybind/mgr/dashboard/tools.py +++ b/src/pybind/mgr/dashboard/tools.py @@ -742,6 +742,21 @@ def dict_contains_path(dct, keys): return True +def dict_get(obj, path, default=None): + """ + Get the value at any depth of a nested object based on the path + described by `path`. If path doesn't exist, `default` is returned. + """ + current = obj + for part in path.split('.'): + if not isinstance(current, dict): + return default + if part not in current.keys(): + return default + current = current.get(part, {}) + return current + + if sys.version_info > (3, 0): wraps = functools.wraps _getargspec = inspect.getfullargspec |