From 2c950dc9c5796343aec201da24ee40635a86cb67 Mon Sep 17 00:00:00 2001 From: Pedro Gonzalez Gomez Date: Thu, 2 Nov 2023 08:25:53 +0100 Subject: [PATCH] mgr/dashboard: add tags field to bucket edit Fixes: https://tracker.ceph.com/issues/63412 Signed-off-by: Pedro Gonzalez Gomez (cherry picked from commit 9481b7e52dd66ee035adb234104dafdd2878e3a1) --- src/pybind/mgr/dashboard/controllers/rgw.py | 13 +++- .../bucket-tag-modal.component.html | 59 +++++++++++++++ .../bucket-tag-modal.component.scss | 0 .../bucket-tag-modal.component.spec.ts | 27 +++++++ .../bucket-tag-modal.component.ts | 75 +++++++++++++++++++ .../rgw-bucket-details.component.html | 15 ++++ .../rgw-bucket-form.component.html | 60 +++++++++++++++ .../rgw-bucket-form.component.ts | 69 ++++++++++++++++- .../frontend/src/app/ceph/rgw/rgw.module.ts | 4 +- .../app/shared/api/rgw-bucket.service.spec.ts | 10 ++- .../src/app/shared/api/rgw-bucket.service.ts | 10 ++- src/pybind/mgr/dashboard/openapi.yaml | 4 + .../mgr/dashboard/services/rgw_client.py | 13 ++++ 13 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index fc4c2f48eb1ca..f3653c5bd27b8 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -290,6 +290,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 @@ -355,7 +359,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: @@ -371,6 +375,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') @@ -380,7 +387,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 @@ -420,6 +427,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 index 0000000000000..3e732e3556894 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.html @@ -0,0 +1,59 @@ + + {{ getMode() }} Tag + + +
+ + + +
+
+
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 index 0000000000000..e69de29bb2d1d 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 index 0000000000000..a54e7eeee0825 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.spec.ts @@ -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; + + 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 index 0000000000000..5135539e5d470 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/bucket-tag-modal/bucket-tag-modal.component.ts @@ -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) { + this.form.setValue(tag); + } +} 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 f2447feab2642..e96a89b234f9a 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 @@ -100,6 +100,21 @@ + + + + Tags + + + + + + + +
{{tag.key}}{{ tag.value }}
+
+ 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 761081c374433..a9704c0bdc89f 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 @@ -385,6 +385,32 @@ + + Tags + Tagging gives you a way to categorize storage + + + + + +
+
+ Maximum of 20 tags reached + +
+
+ + + + +
+ + + + + + + +
+
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 de8e0383ac020..6b90b45e16a64 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 @@ -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[] = []; + 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) => { + this.setTag(tag, index); + }); + } + + deleteTag(index: number) { + this.tags.splice(index, 1); + } + + private setTag(tag: Record, 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 { + let xml = ''; + for (const tag of tags) { + xml += ''; + for (const key in tag) { + if (key === 'key') { + xml += `${tag[key]}`; + } else if (key === 'value') { + xml += `${tag[key]}`; + } + } + xml += ''; + } + xml += ''; + return xml; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index 5367a2b93224f..3695593b56b5a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -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 {} 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 2c42d8b427c0d..15821c3b6265b 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 @@ -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'); }); 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 7207d0b5ca72c..87561d92d8996 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 @@ -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 }); }); diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 135379b127bc6..602ee67ee2665 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -9375,6 +9375,8 @@ paths: type: string placement_target: type: string + tags: + type: string uid: type: string zonegroup: @@ -9687,6 +9689,8 @@ paths: type: string mfa_token_serial: type: string + tags: + type: string uid: type: string versioning_state: diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 7db28506ddcb4..a258d99201204 100644 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -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 -- 2.39.5