]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Create bucket with x-amz-bucket-object-lock-enabled 33821/head
authorVolker Theile <vtheile@suse.com>
Tue, 10 Mar 2020 11:38:20 +0000 (12:38 +0100)
committerVolker Theile <vtheile@suse.com>
Thu, 12 Mar 2020 10:56:21 +0000 (11:56 +0100)
Fixes: https://tracker.ceph.com/issues/43446
Signed-off-by: Volker Theile <vtheile@suse.com>
qa/tasks/mgr/dashboard/test_rgw.py
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
src/pybind/mgr/dashboard/services/rgw_client.py
src/pybind/mgr/dashboard/tests/test_tools.py
src/pybind/mgr/dashboard/tools.py

index 72878498ee8f4407fb4b7d14dd2dcfadd03d8183..9cb3504b5ae3522d3b69ea1781dadc72d1330db3 100644 (file)
@@ -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):
 
index 542ba3967525e5fe52f8dad89bfd35d7c3133314..433113763f12af65385eea81dcf2d74be626fece 100644 (file)
@@ -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>.
index 9f0c2ddd5dd08681cf0b4c66e80862f907438f6a..3d0cb7b482908db728070cc875239e6fe13049d9 100644 (file)
           </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>
index 6d5dcca01025eca0a09cbcc15c1e4def157edac1..a8bf4c0d9577fd5a6f736378502cadbe313264e1 100644 (file)
         </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">
                        (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>
                     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">
index 53880bdb02796f7c9d566fb02487ec1bdeb8bab4..0cb297c3f1fe5416c92a939469ea15a27ad97c75 100644 (file)
@@ -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');
+    });
+  });
 });
index 8eab976a7f4ee444c5418f1a92a7fd1eca923d5f..bd1cfcbd051bee8a5f36a2c1721f53b07c476a3d 100644 (file)
@@ -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();
   }
 }
index 53c57cc5e505e2cc5db3961b55a16c3078337b6c..0d342c48d1cd4f547647b00811158fd73fca3392 100644 (file)
@@ -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');
   });
index 70a1f3985e67e2d03ffe07159a355b60d51192f9..71907497d701f2306d03ed30e421b3026f751eb3 100644 (file)
@@ -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 });
   }
 
index 794340e75777f81145c1faf2c8b0024e1b63e047..78d8623ef489699cbbd4dc139e47c040de8372ed 100644 (file)
@@ -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')
index 1960b2a55844705cff8d1ff47bbb94da4b38e90f..0f27ec8e634670b849819589f8c0187f28a880a9 100644 (file)
@@ -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')
index ce730862d22015381e7548c1057377adf5e65773..2b6d92ca55f7d1ab90e4dadf2c380d4f7f2e79d7 100644 (file)
@@ -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