]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add tags field to bucket edit 54305/head
authorPedro Gonzalez Gomez <pegonzal@redhat.com>
Thu, 2 Nov 2023 07:25:53 +0000 (08:25 +0100)
committerPedro Gonzalez Gomez <pegonzal@redhat.com>
Wed, 22 Nov 2023 15:38:10 +0000 (16:38 +0100)
Fixes: https://tracker.ceph.com/issues/63412
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@redhat.com>
13 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.ts [new file with mode: 0644]
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.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.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/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_client.py

index 8bfb0f9902d9c2b4f77fa5faa4a28e1481fef01c..edd85af7b034031fbfddbddfb028f7c54c16920f 100644 (file)
@@ -280,6 +280,10 @@ class RgwBucket(RgwRESTController):
         rgw_client = RgwClient.admin_instance()
         return rgw_client.get_bucket_policy(bucket)
 
+    def _set_tags(self, bucket_name, tags, daemon_name, owner):
+        rgw_client = RgwClient.instance(owner, daemon_name)
+        return rgw_client.set_tags(bucket_name, tags)
+
     @staticmethod
     def strip_tenant_from_bucket_name(bucket_name):
         # type (str) -> str
@@ -345,7 +349,7 @@ class RgwBucket(RgwRESTController):
                lock_enabled='false', lock_mode=None,
                lock_retention_period_days=None,
                lock_retention_period_years=None, encryption_state='false',
-               encryption_type=None, key_id=None, daemon_name=None):
+               encryption_type=None, key_id=None, tags=None, daemon_name=None):
         lock_enabled = str_to_bool(lock_enabled)
         encryption_state = str_to_bool(encryption_state)
         try:
@@ -361,6 +365,9 @@ class RgwBucket(RgwRESTController):
             if encryption_state:
                 self._set_encryption(bucket, encryption_type, key_id, daemon_name, uid)
 
+            if tags:
+                self._set_tags(bucket, tags, daemon_name, uid)
+
             return result
         except RequestException as e:  # pragma: no cover - handling is too obvious
             raise DashboardException(e, http_status_code=500, component='rgw')
@@ -370,7 +377,7 @@ class RgwBucket(RgwRESTController):
             encryption_state='false', encryption_type=None, key_id=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, daemon_name=None):
+            lock_retention_period_years=None, tags=None, daemon_name=None):
         encryption_state = str_to_bool(encryption_state)
         # When linking a non-tenant-user owned bucket to a tenanted user, we
         # need to prefix bucket name with '/'. e.g. photos -> /photos
@@ -410,6 +417,8 @@ class RgwBucket(RgwRESTController):
             self._set_encryption(bucket_name, encryption_type, key_id, daemon_name, uid)
         if encryption_status['Status'] == 'Enabled' and (not encryption_state):
             self._delete_encryption(bucket_name, daemon_name, uid)
+        if tags:
+            self._set_tags(bucket_name, tags, daemon_name, uid)
         return self._append_bid(result)
 
     def delete(self, bucket, purge_objects='true', daemon_name=None):
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.html
new file mode 100644 (file)
index 0000000..3e732e3
--- /dev/null
@@ -0,0 +1,59 @@
+<cd-modal [modalRef]="activeModal">
+  <span class="modal-title"
+        i18n>{{ getMode() }} Tag</span>
+
+    <ng-container class="modal-content">
+      <form class="form"
+            #formDir="ngForm"
+            [formGroup]="form">
+        <div class="modal-body">
+          <!-- Key -->
+          <div class="form-group row">
+            <label class="cd-col-form-label required"
+                   for="key"
+                   i18n>Key</label>
+            <div class="cd-col-form-input">
+              <input type="text"
+                     class="form-control"
+                     formControlName="key"
+                     id="key">
+              <span class="invalid-feedback"
+                    *ngIf="form.showError('key', formDir, 'required')"
+                    i18n>This field is required.</span>
+              <span class="invalid-feedback"
+                    *ngIf="form.showError('key', formDir, 'unique')"
+                    i18n>This key must be unique.</span>
+              <span class="invalid-feedback"
+                    *ngIf="form.showError('key', formDir, 'maxLength')"
+                    i18n>Length of the key must be maximum of 128 characters</span>
+            </div>
+          </div>
+
+          <!-- Value -->
+          <div class="form-group row">
+            <label class="cd-col-form-label required"
+                   for="value"
+                   i18n>Value</label>
+            <div class="cd-col-form-input">
+              <input id="value"
+                     class="form-control"
+                     type="text"
+                     formControlName="value">
+              <span *ngIf="form.showError('value', formDir, 'required')"
+                    class="invalid-feedback"
+                    i18n>This field is required.</span>
+              <span class="invalid-feedback"
+                    *ngIf="form.showError('value', formDir, 'maxLength')"
+                    i18n>Length of the value must be a maximum of 128 characters</span>
+            </div>
+          </div>
+        </div>
+
+        <div class="modal-footer">
+          <cd-form-button-panel (submitActionEvent)="onSubmit()"
+                                [form]="form"
+                                [submitText]="getMode()"></cd-form-button-panel>
+        </div>
+      </form>
+    </ng-container>
+  </cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..a54e7ee
--- /dev/null
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { BucketTagModalComponent } from './bucket-tag-modal.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+describe('BucketTagModalComponent', () => {
+  let component: BucketTagModalComponent;
+  let fixture: ComponentFixture<BucketTagModalComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [BucketTagModalComponent],
+      imports: [HttpClientTestingModule, ReactiveFormsModule],
+      providers: [NgbActiveModal]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(BucketTagModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.ts
new file mode 100644 (file)
index 0000000..5135539
--- /dev/null
@@ -0,0 +1,75 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+
+@Component({
+  selector: 'cd-bucket-tag-modal',
+  templateUrl: './bucket-tag-modal.component.html',
+  styleUrls: ['./bucket-tag-modal.component.scss']
+})
+export class BucketTagModalComponent {
+  @Output()
+  submitAction = new EventEmitter();
+
+  form: CdFormGroup;
+  editMode = false;
+  currentKeyTags: string[];
+  storedKey: string;
+
+  constructor(
+    private formBuilder: CdFormBuilder,
+    public activeModal: NgbActiveModal,
+    public actionLabels: ActionLabelsI18n
+  ) {
+    this.createForm();
+  }
+
+  private createForm() {
+    this.form = this.formBuilder.group({
+      key: [
+        null,
+        [
+          Validators.required,
+          CdValidators.custom('unique', (value: string) => {
+            if (_.isEmpty(value) && !this.currentKeyTags) {
+              return false;
+            }
+            return this.storedKey !== value && this.currentKeyTags.includes(value);
+          }),
+          CdValidators.custom('maxLength', (value: string) => {
+            if (_.isEmpty(value)) return false;
+            return value.length > 128;
+          })
+        ]
+      ],
+      value: [
+        null,
+        [
+          Validators.required,
+          CdValidators.custom('maxLength', (value: string) => {
+            if (_.isEmpty(value)) return false;
+            return value.length > 128;
+          })
+        ]
+      ]
+    });
+  }
+
+  onSubmit() {
+    this.submitAction.emit(this.form.value);
+    this.activeModal.close();
+  }
+
+  getMode() {
+    return this.editMode ? this.actionLabels.EDIT : this.actionLabels.ADD;
+  }
+
+  fillForm(tag: Record<string, string>) {
+    this.form.setValue(tag);
+  }
+}
index f2447feab2642bf512131e58eb4d38e0e176eb25..e96a89b234f9ad642827d3ac9674f397fc1251cf 100644 (file)
             </ng-container>
           </tbody>
         </table>
+
+      <!-- Tags -->
+      <ng-container *ngIf="selection.tagset">
+        <legend i18n>Tags</legend>
+        <table class="table table-striped table-bordered">
+          <tbody>
+            <tr *ngFor="let tag of selection.tagset | keyvalue">
+              <td i18n
+                  class="bold w-25">{{tag.key}}</td>
+              <td class="w-75">{{ tag.value }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </ng-container>
+
       </ng-template>
     </ng-container>
 
index 761081c374433226f35d0dae9731b7fd52044bc8..a9704c0bdc89f9305c4a08ab4643b4c251a33b52 100644 (file)
           </div>
         </fieldset>
 
+        <!-- Tags -->
+        <legend class="cd-header"
+                i18n>Tags
+          <cd-helper>Tagging gives you a way to categorize storage</cd-helper>
+        </legend>
+        <span *ngFor="let tag of tags; let i=index;">
+          <ng-container *ngTemplateOutlet="tagTpl; context:{index: i, tag: tag}"></ng-container>
+        </span>
+
+        <div class="row">
+          <div class="col-12">
+            <strong *ngIf="tags.length > 19"
+                    class="text-warning"
+                    i18n>Maximum of 20 tags reached</strong>
+            <button type="button"
+                    id="add-tag"
+                    class="btn btn-light float-end my-3"
+                    [disabled]="tags.length > 19"
+                    (click)="showTagModal()">
+              <i [ngClass]="[icons.add]"></i>
+              <ng-container i18n>Add tag</ng-container>
+            </button>
+          </div>
+        </div>
+
+
       </div>
       <div class="card-footer">
         <cd-form-button-panel (submitActionEvent)="submit()"
     </div>
   </form>
 </div>
+
+<ng-template #tagTpl
+             let-tag="tag"
+             let-index="index">
+  <div class="input-group my-2">
+    <ng-container *ngFor="let config of tagConfig">
+      <input type="text"
+             id="tag-{{config.attribute}}-{{index}}"
+             class="form-control"
+             [ngbTooltip]="config.attribute"
+             [value]="tag[config.attribute]"
+             disabled
+             readonly>
+    </ng-container>
+
+    <!-- Tag actions -->
+    <button type="button"
+            class="btn btn-light"
+            id="tag-edit-{{index}}"
+            i18n-ngbTooltip
+            ngbTooltip="Edit"
+            (click)="showTagModal(index)">
+      <i [ngClass]="[icons.edit]"></i>
+    </button>
+    <button type="button"
+            class="btn btn-light"
+            id="tag-delete-{{index}}"
+            i18n-ngbTooltip
+            ngbTooltip="Delete"
+            (click)="deleteTag(index)">
+      <i [ngClass]="[icons.trash]"></i>
+    </button>
+  </div>
+</ng-template>
index de8e0383ac020f6beca0cd726ea93e78f5119fd0..6b90b45e16a6498bcaa12a7a70fb61e224c95b9c 100644 (file)
@@ -21,6 +21,7 @@ import { RgwBucketEncryptionModel } from '../models/rgw-bucket-encryption';
 import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
 import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
 import { RgwConfigModalComponent } from '../rgw-config-modal/rgw-config-modal.component';
+import { BucketTagModalComponent } from '../bucket-tag-modal/bucket-tag-modal.component';
 
 @Component({
   selector: 'cd-rgw-bucket-form',
@@ -42,6 +43,15 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
   icons = Icons;
   kmsVaultConfig = false;
   s3VaultConfig = false;
+  tags: Record<string, string>[] = [];
+  tagConfig = [
+    {
+      attribute: 'key'
+    },
+    {
+      attribute: 'value'
+    }
+  ];
 
   get isVersioningEnabled(): boolean {
     return this.bucketForm.getValue('versioning');
@@ -191,6 +201,11 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
           value['versioning'] = bidResp['versioning'] === RgwBucketVersioning.ENABLED;
           value['mfa-delete'] = bidResp['mfa_delete'] === RgwBucketMfaDelete.ENABLED;
           value['encryption_enabled'] = bidResp['encryption'] === 'Enabled';
+          if (bidResp['tagset']) {
+            for (const [key, value] of Object.entries(bidResp['tagset'])) {
+              this.tags.push({ key: key, value: value.toString() });
+            }
+          }
           // Append default values.
           value = _.merge(defaults, value);
           // Update the form.
@@ -224,6 +239,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
       return;
     }
     const values = this.bucketForm.value;
+    const xmlStrTags = this.tagsToXML(this.tags);
     if (this.editing) {
       // Edit
       const versioning = this.getVersioningStatus();
@@ -241,7 +257,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
           values['mfa-token-serial'],
           values['mfa-token-pin'],
           values['lock_mode'],
-          values['lock_retention_period_days']
+          values['lock_retention_period_days'],
+          xmlStrTags
         )
         .subscribe(
           () => {
@@ -269,7 +286,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
           values['lock_retention_period_days'],
           values['encryption_enabled'],
           values['encryption_type'],
-          values['keyId']
+          values['keyId'],
+          xmlStrTags
         )
         .subscribe(
           () => {
@@ -337,4 +355,51 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
       .get('encryptionType')
       .setValue(this.bucketForm.getValue('encryption_type') || 'AES256');
   }
+
+  showTagModal(index?: number) {
+    const modalRef = this.modalService.show(BucketTagModalComponent);
+    const modalComponent = modalRef.componentInstance as BucketTagModalComponent;
+    modalComponent.currentKeyTags = this.tags.map((item) => item.key);
+
+    if (_.isNumber(index)) {
+      modalComponent.editMode = true;
+      modalComponent.fillForm(this.tags[index]);
+      modalComponent.storedKey = this.tags[index]['key'];
+    }
+
+    modalComponent.submitAction.subscribe((tag: Record<string, string>) => {
+      this.setTag(tag, index);
+    });
+  }
+
+  deleteTag(index: number) {
+    this.tags.splice(index, 1);
+  }
+
+  private setTag(tag: Record<string, string>, index?: number) {
+    if (_.isNumber(index)) {
+      this.tags[index] = tag;
+    } else {
+      this.tags.push(tag);
+    }
+    this.bucketForm.markAsDirty();
+    this.bucketForm.updateValueAndValidity();
+  }
+
+  private tagsToXML(tags: Record<string, string>[]): string {
+    let xml = '<Tagging><TagSet>';
+    for (const tag of tags) {
+      xml += '<Tag>';
+      for (const key in tag) {
+        if (key === 'key') {
+          xml += `<Key>${tag[key]}</Key>`;
+        } else if (key === 'value') {
+          xml += `<Value>${tag[key]}</Value>`;
+        }
+      }
+      xml += '</Tag>';
+    }
+    xml += '</TagSet></Tagging>';
+    return xml;
+  }
 }
index c16c13a81bd7d11ca4ebd9a6ee2a7f1186be5306..c8a7cf9884f8718a35d831ac6a971053c0ece966 100644 (file)
@@ -44,6 +44,7 @@ import { DashboardV3Module } from '../dashboard-v3/dashboard-v3.module';
 import { RgwSyncPrimaryZoneComponent } from './rgw-sync-primary-zone/rgw-sync-primary-zone.component';
 import { RgwSyncMetadataInfoComponent } from './rgw-sync-metadata-info/rgw-sync-metadata-info.component';
 import { RgwSyncDataInfoComponent } from './rgw-sync-data-info/rgw-sync-data-info.component';
+import { BucketTagModalComponent } from './bucket-tag-modal/bucket-tag-modal.component';
 
 @NgModule({
   imports: [
@@ -100,7 +101,8 @@ import { RgwSyncDataInfoComponent } from './rgw-sync-data-info/rgw-sync-data-inf
     RgwOverviewDashboardComponent,
     RgwSyncPrimaryZoneComponent,
     RgwSyncMetadataInfoComponent,
-    RgwSyncDataInfoComponent
+    RgwSyncDataInfoComponent,
+    BucketTagModalComponent
   ]
 })
 export class RgwModule {}
index 2c42d8b427c0d0f531ecfb430e75376f88e0a551..15821c3b6265be53afe38fcf5937bef892bdbcb3 100644 (file)
@@ -59,11 +59,12 @@ describe('RgwBucketService', () => {
         '5',
         true,
         'aws:kms',
-        'qwerty1'
+        'qwerty1',
+        null
       )
       .subscribe();
     const req = httpTesting.expectOne(
-      `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=5&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&${RgwHelper.DAEMON_QUERY_PARAM}`
+      `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=5&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&tags=null&${RgwHelper.DAEMON_QUERY_PARAM}`
     );
     expect(req.request.method).toBe('POST');
   });
@@ -82,11 +83,12 @@ describe('RgwBucketService', () => {
         '1',
         '223344',
         'GOVERNANCE',
-        '10'
+        '10',
+        null
       )
       .subscribe();
     const req = httpTesting.expectOne(
-      `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=10`
+      `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=10&tags=null`
     );
     expect(req.request.method).toBe('PUT');
   });
index 7207d0b5ca72c88dd89102117239b6c0e1f27929..87561d92d8996318773c5067a2bc74a4ade8e7bd 100644 (file)
@@ -59,7 +59,8 @@ export class RgwBucketService extends ApiClient {
     lock_retention_period_days: string,
     encryption_state: boolean,
     encryption_type: string,
-    key_id: string
+    key_id: string,
+    tags: string
   ) {
     return this.rgwDaemonService.request((params: HttpParams) => {
       return this.http.post(this.url, null, {
@@ -75,6 +76,7 @@ export class RgwBucketService extends ApiClient {
             encryption_state: String(encryption_state),
             encryption_type,
             key_id,
+            tags: tags,
             daemon_name: params.get('daemon_name')
           }
         })
@@ -94,7 +96,8 @@ export class RgwBucketService extends ApiClient {
     mfaTokenSerial: string,
     mfaTokenPin: string,
     lockMode: 'GOVERNANCE' | 'COMPLIANCE',
-    lockRetentionPeriodDays: string
+    lockRetentionPeriodDays: string,
+    tags: string
   ) {
     return this.rgwDaemonService.request((params: HttpParams) => {
       params = params.appendAll({
@@ -108,7 +111,8 @@ export class RgwBucketService extends ApiClient {
         mfa_token_serial: mfaTokenSerial,
         mfa_token_pin: mfaTokenPin,
         lock_mode: lockMode,
-        lock_retention_period_days: lockRetentionPeriodDays
+        lock_retention_period_days: lockRetentionPeriodDays,
+        tags: tags
       });
       return this.http.put(`${this.url}/${bucket}`, null, { params: params });
     });
index d35ea87e15aecf02ca41c017a5289d0c9b04be78..3081d60b16f8f5668597aa3dcc80d7c2bf4eaaaa 100644 (file)
@@ -8817,6 +8817,8 @@ paths:
                   type: string
                 placement_target:
                   type: string
+                tags:
+                  type: string
                 uid:
                   type: string
                 zonegroup:
@@ -9129,6 +9131,8 @@ paths:
                   type: string
                 mfa_token_serial:
                   type: string
+                tags:
+                  type: string
                 uid:
                   type: string
                 versioning_state:
index 2d3226bab3e0044bb1397981301785cfc37378bf..c9db37b393e64d04acd9e98170fa3b806c31648b 100644 (file)
@@ -702,6 +702,19 @@ class RgwClient(RestClient):
         except RequestException as e:
             raise DashboardException(msg=str(e), component='rgw')
 
+    @RestClient.api_put('/{bucket_name}?tagging')
+    def set_tags(self, bucket_name, tags, request=None):
+        # pylint: disable=unused-argument
+        try:
+            ET.fromstring(tags)
+        except ET.ParseError:
+            return "Data must be properly formatted"
+        try:
+            result = request(data=tags)  # type: ignore
+        except RequestException as e:
+            raise DashboardException(msg=str(e), component='rgw')
+        return result
+
     @RestClient.api_get('/{bucket_name}?object-lock')
     def get_bucket_locking(self, bucket_name, request=None):
         # type: (str, Optional[object]) -> dict