summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorVolker Theile <vtheile@suse.com>2020-03-10 12:38:20 +0100
committerVolker Theile <vtheile@suse.com>2020-03-12 11:56:21 +0100
commitb0b188da570c9d1afcfd9cd5a8123b43bf41b07b (patch)
treecdbc4a4c9b150fdaa6ad8be9e1ca35255b096ef9
parentMerge pull request #33831 from dillaman/wip-44396 (diff)
downloadceph-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>
-rw-r--r--qa/tasks/mgr/dashboard/test_rgw.py52
-rw-r--r--src/pybind/mgr/dashboard/controllers/rgw.py84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html101
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts64
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts40
-rw-r--r--src/pybind/mgr/dashboard/services/rgw_client.py150
-rw-r--r--src/pybind/mgr/dashboard/tests/test_tools.py8
-rw-r--r--src/pybind/mgr/dashboard/tools.py15
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