]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add RGW lifecycle management 58593/head
authorPedro Gonzalez Gomez <pegonzal@redhat.com>
Thu, 2 May 2024 14:37:38 +0000 (16:37 +0200)
committerPedro Gonzalez Gomez <pegonzal@redhat.com>
Mon, 15 Jul 2024 12:39:24 +0000 (14:39 +0200)
Fixes: https://tracker.ceph.com/issues/50327
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@redhat.com>
(cherry picked from commit b54efd44e8d72d9b56fe4c69a96d53934de1df2a)

 Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts

20 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts
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/shared/api/rgw-bucket.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/xml.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/json-to-xml.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-xml-formatter.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_client.py
src/pybind/mgr/dashboard/tests/test_rgw_client.py

index 08f62a2f315f7399685d64c253d28475dbe63b2e..d53b834e9b4a9f3e95ad7fd4057f5f7251914049 100644 (file)
@@ -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):
index f0b5f1872bcbac03c5932b701051fecc22977a3c..74b3e953b52d796c66adeeb8cecb06f76f725635 100644 (file)
       <a ngbNavLink
          i18n>Policies</a>
       <ng-template ngbNavContent>
-
-        <table class="table table-striped table-bordered">
-          <tbody>
-            <tr>
-              <td i18n
-                  class="bold w-25">Bucket policy</td>
-              <td><pre>{{ selection.bucket_policy | json}}</pre></td>
-            </tr>
-            <tr>
-              <td i18n
-                  class="bold w-25">Replication policy</td>
-              <td><pre>{{ selection.replication | json}}</pre></td>
-            </tr>
-            <tr>
-              <td i18n
-                  class="bold w-25">ACL</td>
-              <td>
-                <table class="table">
-                  <thead>
-                    <tr i18n>
-                      <th>Grantee</th>
-                      <th>Permissions</th>
-                    </tr>
-                  </thead>
-                  <tbody>
-                    <tr i18n>
-                      <td>Bucket Owner</td>
-                      <td>{{ aclPermissions.Owner || '-'}}</td>
-                    </tr>
-                    <tr i18n>
-                      <td>Everyone</td>
-                      <td>{{ aclPermissions.AllUsers || '-'}}</td>
-                    </tr>
-                    <tr i18n>
-                      <td>Authenticated users group</td>
-                      <td>{{ aclPermissions.AuthenticatedUsers || '-'}}</td>
-                    </tr>
-                  </tbody>
-                </table>
-              </td>
-            </tr>
-          </tbody>
-        </table>
+        <div class="table-scroller">
+          <table class="table table-striped table-bordered">
+            <tbody>
+              <tr>
+                <td i18n
+                    class="bold w-25">Bucket policy</td>
+                <td><pre>{{ selection.bucket_policy | json}}</pre></td>
+              </tr>
+              <tr>
+                <div>
+                  <td i18n
+                      class="bold w-25">Lifecycle
+                    <div *ngIf="(selection.lifecycle | json) !== '{}'"
+                         class="input-group">
+                      <button type="button"
+                              class="btn btn-light"
+                              [ngClass]="{'active': lifecycleFormat === 'json'}"
+                              (click)="lifecycleFormat = 'json'">
+                            JSON
+                      </button>
+                      <button type="button"
+                              class="btn btn-light"
+                              [ngClass]="{'active': lifecycleFormat === 'xml'}"
+                              (click)="lifecycleFormat = 'xml'">
+                            XML
+                      </button>
+                    </div>
+                  </td>
+                </div>
+                <td>
+                  <pre *ngIf="lifecycleFormat === 'json'">{{selection.lifecycle | json}}</pre>
+                  <pre *ngIf="lifecycleFormat === 'xml'">{{ (selection.lifecycle | xml) || '-'}}</pre>
+                </td>
+              </tr>
+              <tr>
+                <td i18n
+                    class="bold w-25">Replication policy</td>
+                <td><pre>{{ selection.replication | json}}</pre></td>
+              </tr>
+              <tr>
+                <td i18n
+                    class="bold w-25">ACL</td>
+                <td>
+                  <table class="table">
+                    <thead>
+                      <tr i18n>
+                        <th>Grantee</th>
+                        <th>Permissions</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr i18n>
+                        <td>Bucket Owner</td>
+                        <td>{{ aclPermissions.Owner || '-'}}</td>
+                      </tr>
+                      <tr i18n>
+                        <td>Everyone</td>
+                        <td>{{ aclPermissions.AllUsers || '-'}}</td>
+                      </tr>
+                      <tr i18n>
+                        <td>Authenticated users group</td>
+                        <td>{{ aclPermissions.AuthenticatedUsers || '-'}}</td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
       </ng-template>
     </ng-container>
   </nav>
index d293c9d9819803e0dc063a737b3afc696a08d54b..4d05a9f5df70cb072814a38ab88f803ad84be7b2 100644 (file)
@@ -5,3 +5,9 @@ table {
 table td {
   word-wrap: break-word;
 }
+
+.table-scroller {
+  height: 100%;
+  max-height: 50vh;
+  overflow: auto;
+}
index c88b6f2cd0b5d17c18f284686a60bef149e24e07..15382c9fc31ac01ef793f4afb68837d52a005306 100644 (file)
@@ -13,6 +13,7 @@ export class RgwBucketDetailsComponent implements OnChanges {
   @Input()
   selection: any;
 
+  lifecycleFormat: 'json' | 'xml' = 'json';
   aclPermissions: Record<string, string[]> = {};
   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'];
index 563237036f650f9e73edbb0391240c593b1004dd..b25d47fecf33aaee9ebb7ab0eb0f432aa3393996 100644 (file)
                             class="form-control resize-vertical"
                             id="bucket_policy"
                             formControlName="bucket_policy"
-                            (change)="bucketPolicyOnChange()">
+                            (change)="textAreaOnChange('bucketPolicyTextArea')">
                   </textarea>
                   <span class="invalid-feedback"
                         *ngIf="bucketForm.showError('bucket_policy', frm, 'invalidJson')"
-                        i18n>Invalid json text</span>
+                        i18n>Invalid json text.</span>
                   <button type="button"
                           id="clear-bucket-policy"
                           class="btn btn-light my-3"
-                          (click)="clearBucketPolicy()"
+                          (click)="clearTextArea('bucket_policy', '{}')"
                           i18n>
                     <i [ngClass]="[icons.destroy]"></i>
                     Clear
                 </div>
               </div>
 
+              <!-- Lifecycle -->
+              <div *ngIf="editing"
+                   class="form-group row">
+              <label i18n
+                     class="cd-col-form-label"
+                     for="id">Lifecycle
+                <cd-helper>JSON or XML formatted document</cd-helper>
+              </label>
+                <div class="cd-col-form-input">
+                  <textarea #lifecycleTextArea
+                            class="form-control resize-vertical"
+                            id="lifecycle"
+                            formControlName="lifecycle"
+                            (change)="textAreaOnChange('lifecycleTextArea')">
+                  </textarea>
+                  <span class="invalid-feedback"
+                        *ngIf="bucketForm.showError('lifecycle', frm, 'invalidJson')"
+                        i18n>Invalid json text.</span>
+                  <span class="invalid-feedback"
+                        *ngIf="bucketForm.showError('lifecycle', frm, 'invalidXml')"
+                        i18n>Invalid xml text.</span>
+                  <button type="button"
+                          id="clear-lifecycle"
+                          class="btn btn-light my-3"
+                          (click)="clearTextArea('lifecycle', '{}')"
+                          i18n>
+                    <i [ngClass]="[icons.destroy]"></i>
+                    Clear
+                  </button>
+                  <div class="btn-group float-end"
+                       role="group"
+                       aria-label="bucket-policy-helpers">
+                    <button type="button"
+                            id="lifecycle-examples-button"
+                            class="btn btn-light my-3"
+                            (click)="openUrl('https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-lifecycle.html#examples')"
+                            i18n>
+                      <i [ngClass]="[icons.externalUrl]"></i>
+                      Policy examples
+                    </button>
+                  </div>
+                </div>
+              </div>
+
               <div class="form-group row">
 
                 <!-- ACL -->
index a290beab8f4cbc2bd69de43a9413b9d81c62104c..cc2b5206517e08b52ef54e259e567c8e29aa184c 100644 (file)
@@ -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<any>;
+  @ViewChild('lifecycleTextArea')
+  public lifecycleTextArea: ElementRef<any>;
 
   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<any>) {
+    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();
   }
index 533d04628ee315544d4efdc1eb4b5669858b2bc4..ec0da64df99646f1b5cef982c9256b3f54a93227 100644 (file)
@@ -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');
   });
index 2bf85e70512e5f43dc696e6a9d1ce38762a0f1df..595b02ec276d73c724e5b92e5f77817027c0096c 100644 (file)
@@ -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 });
     });
index e2bd67418428657a8600b6e77cfe6e984360246f..35528cd4feb0d6b57e0db0adee615bc0c8421cfb 100644 (file)
@@ -626,4 +626,40 @@ export class CdValidators {
       }
     };
   }
+
+  static xml(): ValidatorFn {
+    return (control: AbstractControl): Record<string, boolean> | 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<string, boolean> | 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 };
+        }
+      }
+    };
+  }
 }
index b5267aa71216d2c516baa8cd9a6708cd341ae9ec..d9f1154cf0df8df836578b752573d1d5b4a238bd 100755 (executable)
@@ -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 (file)
index 0000000..47ddb2e
--- /dev/null
@@ -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 (file)
index 0000000..59d7572
--- /dev/null
@@ -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 (file)
index 0000000..5035dae
--- /dev/null
@@ -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 = `<foo>bar</foo>
+<items>
+  <name>item1</name>
+  <value>value1</value>
+</items>
+<items>
+  <name>item2</name>
+  <value>value2</value>
+</items>
+`;
+    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 (file)
index 0000000..8f1d128
--- /dev/null
@@ -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}</${key}>\n`;
+          });
+        } else if (typeof value === 'object') {
+          xml +=
+            `${indentation}<${key}>\n` +
+            this.format(value, indentSize, currentIndent + indentSize) +
+            `${indentation}</${key}>\n`;
+        } else {
+          xml += `${indentation}<${key}>${value}</${key}>\n`;
+        }
+      }
+    }
+    return xml;
+  }
+}
index 0e696022affdbbd2504736e35598e6e5f689b853..d2f4fb5b05b88e0af42d4e29fdd9181c568706a8 100644 (file)
@@ -7,7 +7,7 @@ export class TextAreaJsonFormatterService {
   constructor() {}
 
   format(textArea: ElementRef<any>): 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 (file)
index 0000000..8e91a60
--- /dev/null
@@ -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 (file)
index 0000000..ff9d63f
--- /dev/null
@@ -0,0 +1,23 @@
+import { ElementRef, Injectable } from '@angular/core';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class TextAreaXmlFormatterService {
+  constructor() {}
+
+  format(textArea: ElementRef<any>): 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;
+    }
+  }
+}
index 7593bbe5fc2555c4b022110fe0860fca244dc218..806829033d02a25d6983493b2028232d277a8938 100644 (file)
@@ -10521,6 +10521,8 @@ paths:
                   type: string
                 key_id:
                   type: string
+                lifecycle:
+                  type: string
                 lock_mode:
                   type: string
                 lock_retention_period_days:
index 17fa33f750851f5c5df9dffbf87edc21fd6fe5fa..b58e769d162089a2ffd34ecf8854be7a03240338 100644 (file)
@@ -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)}</{key}>\n'
+                    elif isinstance(value, dict):
+                        xml += f'<{key}>\n{transform(value)}</{key}>\n'
+                    else:
+                        xml += f'<{key}>{str(value)}</{key}>\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 '<LifecycleConfiguration>' not in str(lifecycle):
+                lifecycle = f'<LifecycleConfiguration>{lifecycle}</LifecycleConfiguration>'
+            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
index 4949ba36bf211cd54a4284482001cc6d66fa6b72..fee3d2c05e25e26c6763b6e65da021317803bfa4 100644 (file)
@@ -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 = "<name>Foo</name>\n<age>30</age>\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 = "<person>\n<name>Foo</name>\n<age>30</age>\n</person>\n"
+        result = RgwClient.dict_to_xml(data)
+        self.assertEqual(result, expected_xml)
+
+    def test_list_in_dict(self):
+        data = {"names": ["Foo", "Boo"]}
+        expected_xml = "<names>\nFoo</names>\n<names>\nBoo</names>\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 = "<Rule>\n<id>1</id>\n</Rule>\n<Rule>\n<id>2</id>\n</Rule>\n"
+        result = RgwClient.dict_to_xml(data)
+        self.assertEqual(result, expected_xml)
+
+    def test_json_string(self):
+        data = '{"name": "Foo", "age": 30}'
+        expected_xml = "<name>Foo</name>\n<age>30</age>\n"
+        result = RgwClient.dict_to_xml(data)
+        self.assertEqual(result, expected_xml)