From: Pedro Gonzalez Gomez Date: Thu, 2 May 2024 14:37:38 +0000 (+0200) Subject: mgr/dashboard: add RGW lifecycle management X-Git-Tag: v19.1.1~103^2 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F58593%2Fhead;p=ceph.git mgr/dashboard: add RGW lifecycle management Fixes: https://tracker.ceph.com/issues/50327 Signed-off-by: Pedro Gonzalez Gomez (cherry picked from commit b54efd44e8d72d9b56fe4c69a96d53934de1df2a) Conflicts: src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts --- diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 08f62a2f315f7..d53b834e9b4a9 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -391,6 +391,18 @@ class RgwBucket(RgwRESTController): rgw_client = RgwClient.instance(owner, daemon_name) return rgw_client.set_tags(bucket_name, tags) + def _get_lifecycle(self, bucket_name: str, daemon_name, owner): + rgw_client = RgwClient.instance(owner, daemon_name) + return rgw_client.get_lifecycle(bucket_name) + + def _set_lifecycle(self, bucket_name: str, lifecycle: str, daemon_name, owner): + rgw_client = RgwClient.instance(owner, daemon_name) + return rgw_client.set_lifecycle(bucket_name, lifecycle) + + def _delete_lifecycle(self, bucket_name: str, daemon_name, owner): + rgw_client = RgwClient.instance(owner, daemon_name) + return rgw_client.delete_lifecycle(bucket_name) + def _get_acl(self, bucket_name, daemon_name, owner): rgw_client = RgwClient.instance(owner, daemon_name) return str(rgw_client.get_acl(bucket_name)) @@ -473,6 +485,7 @@ class RgwBucket(RgwRESTController): result['bucket_policy'] = self._get_policy(bucket_name, daemon_name, result['owner']) result['acl'] = self._get_acl(bucket_name, daemon_name, result['owner']) result['replication'] = self._get_replication(bucket_name, result['owner'], daemon_name) + result['lifecycle'] = self._get_lifecycle(bucket_name, daemon_name, result['owner']) # Append the locking configuration. locking = self._get_locking(result['owner'], daemon_name, bucket_name) @@ -525,7 +538,8 @@ class RgwBucket(RgwRESTController): mfa_delete=None, mfa_token_serial=None, mfa_token_pin=None, lock_mode=None, lock_retention_period_days=None, lock_retention_period_years=None, tags=None, bucket_policy=None, - canned_acl=None, replication=None, daemon_name=None): + canned_acl=None, replication=None, lifecycle=None, daemon_name=None): + # pylint: disable=R0912 encryption_state = str_to_bool(encryption_state) if replication is not None: replication = str_to_bool(replication) @@ -573,8 +587,12 @@ class RgwBucket(RgwRESTController): self._set_policy(bucket_name, bucket_policy, daemon_name, uid) if canned_acl: self._set_acl(bucket_name, canned_acl, uid, daemon_name) - if replication is not None: + if replication: self._set_replication(bucket_name, replication, uid, daemon_name) + if lifecycle and not lifecycle == '{}': + self._set_lifecycle(bucket_name, lifecycle, daemon_name, uid) + else: + self._delete_lifecycle(bucket_name, 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/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 f0b5f1872bcba..74b3e953b52d7 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 @@ -127,49 +127,76 @@ Policies - - - - - - - - - - - - - - - - -
Bucket policy
{{ selection.bucket_policy | json}}
Replication policy
{{ selection.replication | json}}
ACL - - - - - - - - - - - - - - - - - - - - - -
GranteePermissions
Bucket Owner{{ aclPermissions.Owner || '-'}}
Everyone{{ aclPermissions.AllUsers || '-'}}
Authenticated users group{{ aclPermissions.AuthenticatedUsers || '-'}}
-
+
+ + + + + + + +
+
+ + + + + + + + + + + + +
Bucket policy
{{ selection.bucket_policy | json}}
Lifecycle +
+ + +
+
+
{{selection.lifecycle | json}}
+
{{ (selection.lifecycle | xml) || '-'}}
+
Replication policy
{{ selection.replication | json}}
ACL + + + + + + + + + + + + + + + + + + + + + +
GranteePermissions
Bucket Owner{{ aclPermissions.Owner || '-'}}
Everyone{{ aclPermissions.AllUsers || '-'}}
Authenticated users group{{ aclPermissions.AuthenticatedUsers || '-'}}
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss index d293c9d981980..4d05a9f5df70c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss @@ -5,3 +5,9 @@ table { table td { word-wrap: break-word; } + +.table-scroller { + height: 100%; + max-height: 50vh; + overflow: auto; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts index c88b6f2cd0b5d..15382c9fc31ac 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts @@ -13,6 +13,7 @@ export class RgwBucketDetailsComponent implements OnChanges { @Input() selection: any; + lifecycleFormat: 'json' | 'xml' = 'json'; aclPermissions: Record = {}; replicationStatus = $localize`Disabled`; @@ -23,6 +24,9 @@ export class RgwBucketDetailsComponent implements OnChanges { this.rgwBucketService.get(this.selection.bid).subscribe((bucket: object) => { bucket['lock_retention_period_days'] = this.rgwBucketService.getLockDays(bucket); this.selection = bucket; + if (this.lifecycleFormat === 'json' && !this.selection.lifecycle) { + this.selection.lifecycle = {}; + } this.aclPermissions = this.parseXmlAcl(this.selection.acl, this.selection.owner); if (this.selection.replication?.['Rule']?.['Status']) { this.replicationStatus = this.selection.replication?.['Rule']?.['Status']; 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 563237036f650..b25d47fecf33a 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 @@ -467,15 +467,15 @@ class="form-control resize-vertical" id="bucket_policy" formControlName="bucket_policy" - (change)="bucketPolicyOnChange()"> + (change)="textAreaOnChange('bucketPolicyTextArea')"> Invalid json text + i18n>Invalid json text. +
+ +
+ + +
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 a290beab8f4cb..cc2b5206517e0 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 @@ -39,6 +39,7 @@ import { TextAreaJsonFormatterService } from '~/app/shared/services/text-area-js import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service'; import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; import { map, switchMap } from 'rxjs/operators'; +import { TextAreaXmlFormatterService } from '~/app/shared/services/text-area-xml-formatter.service'; @Component({ selector: 'cd-rgw-bucket-form', @@ -49,6 +50,8 @@ import { map, switchMap } from 'rxjs/operators'; export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewChecked { @ViewChild('bucketPolicyTextArea') public bucketPolicyTextArea: ElementRef; + @ViewChild('lifecycleTextArea') + public lifecycleTextArea: ElementRef; bucketForm: CdFormGroup; editing = false; @@ -96,6 +99,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC private notificationService: NotificationService, private rgwEncryptionModal: RgwBucketEncryptionModel, private textAreaJsonFormatterService: TextAreaJsonFormatterService, + private textAreaXmlFormatterService: TextAreaXmlFormatterService, public actionLabels: ActionLabelsI18n, private readonly changeDetectorRef: ChangeDetectorRef, private rgwMultisiteService: RgwMultisiteService, @@ -110,7 +114,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC ngAfterViewChecked(): void { this.changeDetectorRef.detectChanges(); - this.bucketPolicyOnChange(); + this.textAreaOnChange(this.bucketPolicyTextArea); + this.textAreaOnChange(this.lifecycleTextArea); } createForm() { @@ -160,6 +165,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC lock_mode: ['COMPLIANCE'], lock_retention_period_days: [10, [CdValidators.number(false), lockDaysValidator]], bucket_policy: ['{}', CdValidators.json()], + lifecycle: ['{}', CdValidators.jsonOrXml()], grantee: [Grantee.Owner, [Validators.required]], aclPermission: [[aclPermission.FullControl], [Validators.required]], replication: [false] @@ -257,6 +263,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC bidResp['acl'], bidResp['owner'] ); + value['lifecycle'] = JSON.stringify(bidResp['lifecycle'] || {}); } this.bucketForm.setValue(value); if (this.editing) { @@ -335,7 +342,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC xmlStrTags, bucketPolicy, cannedAcl, - values['replication'] + values['replication'], + values['lifecycle'] ) .subscribe( () => { @@ -433,9 +441,11 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC }); } - bucketPolicyOnChange() { - if (this.bucketPolicyTextArea) { - this.textAreaJsonFormatterService.format(this.bucketPolicyTextArea); + textAreaOnChange(textArea: ElementRef) { + if (textArea?.nativeElement?.value?.startsWith?.('<')) { + this.textAreaXmlFormatterService.format(textArea); + } else { + this.textAreaJsonFormatterService.format(textArea); } } @@ -443,8 +453,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC window.open(url, '_blank'); } - clearBucketPolicy() { - this.bucketForm.get('bucket_policy').setValue('{}'); + clearTextArea(field: string, defaultValue: string = '') { + this.bucketForm.get(field).setValue(defaultValue); this.bucketForm.markAsDirty(); this.bucketForm.updateValueAndValidity(); } 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 533d04628ee31..ec0da64df9964 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 @@ -90,11 +90,12 @@ describe('RgwBucketService', () => { null, null, 'private', - 'true' + 'true', + 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&tags=null&bucket_policy=null&canned_acl=private&replication=true` + `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&bucket_policy=null&canned_acl=private&replication=true&lifecycle=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 2bf85e70512e5..595b02ec276d7 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 @@ -109,7 +109,8 @@ export class RgwBucketService extends ApiClient { tags: string, bucketPolicy: string, cannedAcl: string, - replication: string + replication: string, + lifecycle: string ) { return this.rgwDaemonService.request((params: HttpParams) => { params = params.appendAll({ @@ -127,7 +128,8 @@ export class RgwBucketService extends ApiClient { tags: tags, bucket_policy: bucketPolicy, canned_acl: cannedAcl, - replication: replication + replication: replication, + lifecycle: lifecycle }); return this.http.put(`${this.url}/${bucket}`, null, { params: params }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index e2bd674184286..35528cd4feb0d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -626,4 +626,40 @@ export class CdValidators { } }; } + + static xml(): ValidatorFn { + return (control: AbstractControl): Record | null => { + if (!control.value) return null; + const parser = new DOMParser(); + const xml = parser.parseFromString(control.value, 'application/xml'); + const errorNode = xml.querySelector('parsererror'); + if (errorNode) { + return { invalidXml: true }; + } + return null; + }; + } + + static jsonOrXml(): ValidatorFn { + return (control: AbstractControl): Record | null => { + if (!control.value) return null; + + if (control.value.trim().startsWith('<')) { + const parser = new DOMParser(); + const xml = parser.parseFromString(control.value, 'application/xml'); + const errorNode = xml.querySelector('parsererror'); + if (errorNode) { + return { invalidXml: true }; + } + return null; + } else { + try { + JSON.parse(control.value); + return null; + } catch (e) { + return { invalidJson: true }; + } + } + }; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts index b5267aa71216d..d9f1154cf0df8 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts @@ -37,6 +37,7 @@ import { TruncatePipe } from './truncate.pipe'; import { UpperFirstPipe } from './upper-first.pipe'; import { OctalToHumanReadablePipe } from './octal-to-human-readable.pipe'; import { PathPipe } from './path.pipe'; +import { XmlPipe } from './xml.pipe'; @NgModule({ imports: [CommonModule], @@ -76,7 +77,8 @@ import { PathPipe } from './path.pipe'; MdsSummaryPipe, OsdSummaryPipe, OctalToHumanReadablePipe, - PathPipe + PathPipe, + XmlPipe ], exports: [ ArrayPipe, @@ -114,7 +116,8 @@ import { PathPipe } from './path.pipe'; MdsSummaryPipe, OsdSummaryPipe, OctalToHumanReadablePipe, - PathPipe + PathPipe, + XmlPipe ], providers: [ ArrayPipe, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.spec.ts new file mode 100644 index 0000000000000..47ddb2ee17ebb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.spec.ts @@ -0,0 +1,22 @@ +import { TestBed } from '@angular/core/testing'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { JsonToXmlService } from '../services/json-to-xml.service'; +import { XmlPipe } from './xml.pipe'; + +describe('XmlPipe', () => { + let pipe: XmlPipe; + let jsonToXmlService: JsonToXmlService; + + configureTestBed({ + providers: [JsonToXmlService] + }); + + beforeEach(() => { + jsonToXmlService = TestBed.inject(JsonToXmlService); + pipe = new XmlPipe(jsonToXmlService); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.ts new file mode 100644 index 0000000000000..59d7572e9f004 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { JsonToXmlService } from '../services/json-to-xml.service'; + +@Pipe({ + name: 'xml' +}) +export class XmlPipe implements PipeTransform { + constructor(private jsonToXmlService: JsonToXmlService) {} + + transform(value: string, valueFormat: string = 'json'): string { + if (valueFormat === 'json') { + value = this.jsonToXmlService.format(value); + } + return value; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.spec.ts new file mode 100644 index 0000000000000..5035dae9b1f83 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.spec.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing'; + +import { JsonToXmlService } from './json-to-xml.service'; + +describe('JsonToXmlService', () => { + let service: JsonToXmlService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(JsonToXmlService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should transform JSON formatted string to XML string', () => { + const json: string = `{ + "foo": "bar", + "items": [ + { + "name": "item1", + "value": "value1" + }, + { + "name": "item2", + "value": "value2" + } + ] + }`; + const expectedXml = `bar + + item1 + value1 + + + item2 + value2 + +`; + expect(JSON.parse(json)).toBeTruthy(); + expect(service.format(json)).toBe(expectedXml); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.ts new file mode 100644 index 0000000000000..8f1d128c0c59c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class JsonToXmlService { + constructor() {} + + format(json: any, indentSize: number = 2, currentIndent: number = 0): string { + if (!json) return null; + let xml = ''; + if (typeof json === 'string') { + json = JSON.parse(json); + } + + for (const key in json) { + if (json.hasOwnProperty(key)) { + const value = json[key]; + const indentation = ' '.repeat(currentIndent); + + if (Array.isArray(value)) { + value.forEach((item) => { + xml += + `${indentation}<${key}>\n` + + this.format(item, indentSize, currentIndent + indentSize) + + `${indentation}\n`; + }); + } else if (typeof value === 'object') { + xml += + `${indentation}<${key}>\n` + + this.format(value, indentSize, currentIndent + indentSize) + + `${indentation}\n`; + } else { + xml += `${indentation}<${key}>${value}\n`; + } + } + } + return xml; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts index 0e696022affdb..d2f4fb5b05b88 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts @@ -7,7 +7,7 @@ export class TextAreaJsonFormatterService { constructor() {} format(textArea: ElementRef): void { - const value = textArea.nativeElement.value; + const value = textArea?.nativeElement?.value; try { const formatted = JSON.stringify(JSON.parse(value), null, 2); textArea.nativeElement.value = formatted; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.spec.ts new file mode 100644 index 0000000000000..8e91a6045158b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TextAreaXmlFormatterService } from './text-area-xml-formatter.service'; + +describe('TextAreaXmlFormatterService', () => { + let service: TextAreaXmlFormatterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TextAreaXmlFormatterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.ts new file mode 100644 index 0000000000000..ff9d63ff20027 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.ts @@ -0,0 +1,23 @@ +import { ElementRef, Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class TextAreaXmlFormatterService { + constructor() {} + + format(textArea: ElementRef): void { + if (!textArea.nativeElement?.value) return; + const value = textArea.nativeElement.value; + const parser = new DOMParser(); + const formatted = parser.parseFromString(value, 'application/xml'); + const lineNumber = formatted.getElementsByTagName('*').length; + const pixelPerLine = 20; + const pixels = lineNumber * pixelPerLine; + textArea.nativeElement.style.height = pixels + 'px'; + const errorNode = formatted.querySelector('parsererror'); + if (errorNode) { + return; + } + } +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 7593bbe5fc255..806829033d02a 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -10521,6 +10521,8 @@ paths: type: string key_id: type: string + lifecycle: + type: string lock_mode: type: string lock_retention_period_days: diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 17fa33f750851..b58e769d16208 100644 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -764,6 +764,83 @@ class RgwClient(RestClient): raise DashboardException(msg=str(e), component='rgw') return result + @RestClient.api_get('/{bucket_name}?lifecycle') + def get_lifecycle(self, bucket_name, request=None): + # pylint: disable=unused-argument + try: + result = request() # type: ignore + result = {'LifecycleConfiguration': result} + except RequestException as e: + if e.content: + content = json_str_to_object(e.content) + if content.get( + 'Code') == 'NoSuchLifecycleConfiguration': + return None + raise DashboardException(msg=str(e), component='rgw') + return result + + @staticmethod + def dict_to_xml(data): + if not data or data == '{}': + return '' + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + raise DashboardException('Could not load json string') + + def transform(data): + xml: str = '' + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, list): + for item in value: + if key == 'Rules': + key = 'Rule' + xml += f'<{key}>\n{transform(item)}\n' + elif isinstance(value, dict): + xml += f'<{key}>\n{transform(value)}\n' + else: + xml += f'<{key}>{str(value)}\n' + + elif isinstance(data, list): + for item in data: + xml += transform(item) + else: + xml += f'{data}' + + return xml + + return transform(data) + + @RestClient.api_put('/{bucket_name}?lifecycle') + def set_lifecycle(self, bucket_name, lifecycle, request=None): + # pylint: disable=unused-argument + lifecycle = lifecycle.strip() + if lifecycle.startswith('{'): + lifecycle = RgwClient.dict_to_xml(lifecycle) + try: + if lifecycle and '' not in str(lifecycle): + lifecycle = f'{lifecycle}' + result = request(data=lifecycle) # type: ignore + except RequestException as e: + if e.content: + content = json_str_to_object(e.content) + if content.get("Code") == "MalformedXML": + msg = "Invalid Lifecycle document" + raise DashboardException(msg=msg, component='rgw') + raise DashboardException(msg=str(e), component='rgw') + return result + + @RestClient.api_delete('/{bucket_name}?lifecycle') + def delete_lifecycle(self, bucket_name, request=None): + # pylint: disable=unused-argument + try: + result = request() + 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 diff --git a/src/pybind/mgr/dashboard/tests/test_rgw_client.py b/src/pybind/mgr/dashboard/tests/test_rgw_client.py index 4949ba36bf211..fee3d2c05e25e 100644 --- a/src/pybind/mgr/dashboard/tests/test_rgw_client.py +++ b/src/pybind/mgr/dashboard/tests/test_rgw_client.py @@ -355,3 +355,47 @@ class RgwClientHelperTest(TestCase): _parse_frontend_config('mongoose port=8080') self.assertEqual(str(ctx.exception), 'Failed to determine RGW port from "mongoose port=8080"') + + +class TestDictToXML(TestCase): + def test_empty_dict(self): + result = RgwClient.dict_to_xml({}) + self.assertEqual(result, '') + + def test_empty_string(self): + result = RgwClient.dict_to_xml("") + self.assertEqual(result, '') + + def test_invalid_json_string(self): + with self.assertRaises(DashboardException): + RgwClient.dict_to_xml("invalid json") + + def test_simple_dict(self): + data = {"name": "Foo", "age": 30} + expected_xml = "Foo\n30\n" + result = RgwClient.dict_to_xml(data) + self.assertEqual(result, expected_xml) + + def test_nested_dict(self): + data = {"person": {"name": "Foo", "age": 30}} + expected_xml = "\nFoo\n30\n\n" + result = RgwClient.dict_to_xml(data) + self.assertEqual(result, expected_xml) + + def test_list_in_dict(self): + data = {"names": ["Foo", "Boo"]} + expected_xml = "\nFoo\n\nBoo\n" + result = RgwClient.dict_to_xml(data) + self.assertEqual(result, expected_xml) + + def test_rules_list_in_dict(self): + data = {"Rules": [{"id": 1}, {"id": 2}]} + expected_xml = "\n1\n\n\n2\n\n" + result = RgwClient.dict_to_xml(data) + self.assertEqual(result, expected_xml) + + def test_json_string(self): + data = '{"name": "Foo", "age": 30}' + expected_xml = "Foo\n30\n" + result = RgwClient.dict_to_xml(data) + self.assertEqual(result, expected_xml)