From 109c75ea99a161afea644e21671b2348dc9d4b40 Mon Sep 17 00:00:00 2001 From: Naman Munet Date: Fri, 7 Feb 2025 12:23:07 +0530 Subject: [PATCH] mgr/dashboard: add bucket tiering option to create lifecycle policy Fixes: https://tracker.ceph.com/issues/69649 Signed-off-by: Naman Munet --- src/pybind/mgr/dashboard/controllers/rgw.py | 18 +- ...ephfs-snapshotschedule-form.component.scss | 3 - .../rgw-bucket-details.component.html | 21 ++ .../rgw-bucket-details.component.ts | 16 +- .../rgw-bucket-lifecycle-list.component.html | 20 ++ .../rgw-bucket-lifecycle-list.component.scss | 0 ...gw-bucket-lifecycle-list.component.spec.ts | 56 ++++ .../rgw-bucket-lifecycle-list.component.ts | 161 +++++++++++ .../rgw-bucket-list.component.spec.ts | 16 +- .../rgw-bucket-list.component.ts | 16 +- .../rgw-bucket-tiering-form.component.html | 227 +++++++++++++++ .../rgw-bucket-tiering-form.component.scss | 0 .../rgw-bucket-tiering-form.component.spec.ts | 86 ++++++ .../rgw-bucket-tiering-form.component.ts | 270 ++++++++++++++++++ .../rgw-storage-class-list.component.ts | 33 +-- .../frontend/src/app/ceph/rgw/rgw.module.ts | 18 +- .../app/ceph/rgw/utils/rgw-bucket-tiering.ts | 33 +++ .../src/app/shared/api/rgw-bucket.service.ts | 21 ++ .../src/app/shared/constants/app.constants.ts | 2 + .../frontend/src/styles/_carbon-defaults.scss | 7 + .../src/styles/ceph-custom/_forms.scss | 10 + src/pybind/mgr/dashboard/openapi.yaml | 79 +++++ .../mgr/dashboard/services/rgw_client.py | 8 + 23 files changed, 1074 insertions(+), 47 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index ef8903afb08..1a694b47341 100755 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -465,6 +465,10 @@ class RgwBucket(RgwRESTController): rgw_client = RgwClient.instance(owner, daemon_name) return rgw_client.set_tags(bucket_name, tags) + def _get_lifecycle_progress(self): + rgw_client = RgwClient.admin_instance() + return rgw_client.get_lifecycle_progress() + def _get_lifecycle(self, bucket_name: str, daemon_name, owner): rgw_client = RgwClient.instance(owner, daemon_name) return rgw_client.get_lifecycle(bucket_name) @@ -561,7 +565,7 @@ class RgwBucket(RgwRESTController): result['acl'] = self._get_acl(bucket_name, daemon_name, owner) result['replication'] = self._get_replication(bucket_name, owner, daemon_name) result['lifecycle'] = self._get_lifecycle(bucket_name, daemon_name, owner) - + result['lifecycle_progress'] = self._get_lifecycle_progress() # Append the locking configuration. locking = self._get_locking(owner, daemon_name, bucket_name) result.update(locking) @@ -706,6 +710,18 @@ class RgwBucket(RgwRESTController): def get_encryption_config(self, daemon_name=None, owner=None): return CephService.get_encryption_config(daemon_name) + @RESTController.Collection(method='PUT', path='/lifecycle') + @allow_empty_body + def set_lifecycle_policy(self, bucket_name: str = '', lifecycle: str = '', daemon_name=None, + owner=None): + if lifecycle == '{}': + return self._delete_lifecycle(bucket_name, daemon_name, owner) + return self._set_lifecycle(bucket_name, lifecycle, daemon_name, owner) + + @RESTController.Collection(method='GET', path='/lifecycle') + def get_lifecycle_policy(self, bucket_name: str = '', daemon_name=None, owner=None): + return self._get_lifecycle(bucket_name, daemon_name, owner) + @UIRouter('/rgw/bucket', Scope.RGW) class RgwBucketUi(RgwBucket): diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss index 1fa27dde722..e69de29bb2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss @@ -1,3 +0,0 @@ -.item-action-btn { - margin-top: 2rem; -} 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 463eac88b1e..1a422e5396f 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 @@ -168,6 +168,19 @@ + + Lifecycle Progress + + + + {{ lifecycleProgress }} + + + + Replication policy @@ -206,6 +219,14 @@ + + + Tiering + + + +
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 15382c9fc31..79e25808b93 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 @@ -12,7 +12,12 @@ import * as xml2js from 'xml2js'; export class RgwBucketDetailsComponent implements OnChanges { @Input() selection: any; - + lifecycleProgress: string; + lifecycleProgressMap = new Map([ + ['UNINITIAL', { description: $localize`The process has not run yet`, color: 'cool-gray' }], + ['PROCESSING', { description: $localize`The process is currently running`, color: 'cyan' }], + ['COMPLETE', { description: $localize`The process has completed`, color: 'green' }] + ]); lifecycleFormat: 'json' | 'xml' = 'json'; aclPermissions: Record = {}; replicationStatus = $localize`Disabled`; @@ -31,6 +36,15 @@ export class RgwBucketDetailsComponent implements OnChanges { if (this.selection.replication?.['Rule']?.['Status']) { this.replicationStatus = this.selection.replication?.['Rule']?.['Status']; } + if (this.selection.lifecycle_progress?.length > 0) { + this.selection.lifecycle_progress.forEach( + (progress: { bucket: string; status: string; started: string }) => { + if (progress.bucket.includes(this.selection.bucket)) { + this.lifecycleProgress = progress.status; + } + } + ); + } }); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html new file mode 100644 index 00000000000..7e7927f38b5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html @@ -0,0 +1,20 @@ + + Tiering Configuration + + Configure a bucket tiering rule to automatically transition objects between storage classes after a specified number of days. Define the scope of the rule by applying it globally or to objects with specific prefixes and tags. + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts new file mode 100644 index 00000000000..4aabbed98de --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list.component'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { of } from 'rxjs'; +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ToastrModule } from 'ngx-toastr'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { + InputModule, + ModalModule, + ModalService, + NumberModule, + RadioModule, + SelectModule +} from 'carbon-components-angular'; +import { CdLabelComponent } from '~/app/shared/components/cd-label/cd-label.component'; + +class MockRgwBucketService { + setLifecycle = jest.fn().mockReturnValue(of(null)); + getLifecycle = jest.fn().mockReturnValue(of(null)); +} + +describe('RgwBucketLifecycleListComponent', () => { + let component: RgwBucketLifecycleListComponent; + let fixture: ComponentFixture; + + configureTestBed({ + declarations: [RgwBucketLifecycleListComponent, CdLabelComponent], + imports: [ + ReactiveFormsModule, + RadioModule, + SelectModule, + NumberModule, + InputModule, + ToastrModule.forRoot(), + ComponentsModule, + ModalModule + ], + providers: [ + ModalService, + { provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } }, + { provide: RgwBucketService, useClass: MockRgwBucketService } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RgwBucketLifecycleListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts new file mode 100644 index 00000000000..759f4257128 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts @@ -0,0 +1,161 @@ +import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { Bucket } from '../models/rgw-bucket'; +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { Permission } from '~/app/shared/models/permissions'; +import { TableComponent } from '~/app/shared/datatable/table/table.component'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { RgwBucketTieringFormComponent } from '../rgw-bucket-tiering-form/rgw-bucket-tiering-form.component'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { Observable, of } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; + +@Component({ + selector: 'cd-rgw-bucket-lifecycle-list', + templateUrl: './rgw-bucket-lifecycle-list.component.html', + styleUrls: ['./rgw-bucket-lifecycle-list.component.scss'] +}) +export class RgwBucketLifecycleListComponent implements OnInit { + @Input() bucket: Bucket; + @ViewChild(TableComponent, { static: true }) + table: TableComponent; + permission: Permission; + tableActions: CdTableAction[]; + columns: CdTableColumn[] = []; + selection: CdTableSelection = new CdTableSelection(); + filteredLifecycleRules$: Observable; + lifecycleRuleList: any = []; + modalRef: any; + + constructor( + private rgwBucketService: RgwBucketService, + private authStorageService: AuthStorageService, + public actionLabels: ActionLabelsI18n, + private modalService: ModalCdsService, + private notificationService: NotificationService + ) {} + + ngOnInit() { + this.permission = this.authStorageService.getPermissions().rgw; + this.columns = [ + { + name: $localize`Name`, + prop: 'ID', + flexGrow: 2 + }, + { + name: $localize`Days`, + prop: 'Transition.Days', + flexGrow: 1 + }, + { + name: $localize`Storage class`, + prop: 'Transition.StorageClass', + flexGrow: 1 + }, + { + name: $localize`Status`, + prop: 'Status', + flexGrow: 1 + } + ]; + const createAction: CdTableAction = { + permission: 'create', + icon: Icons.add, + click: () => this.openTieringModal(this.actionLabels.CREATE), + name: this.actionLabels.CREATE + }; + const editAction: CdTableAction = { + permission: 'update', + icon: Icons.edit, + disable: () => this.selection.hasMultiSelection, + click: () => this.openTieringModal(this.actionLabels.EDIT), + name: this.actionLabels.EDIT + }; + const deleteAction: CdTableAction = { + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteAction(), + disable: () => !this.selection.hasSelection, + name: this.actionLabels.DELETE, + canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection + }; + this.tableActions = [createAction, editAction, deleteAction]; + } + + loadLifecyclePolicies(context: CdTableFetchDataContext) { + const allLifecycleRules$ = this.rgwBucketService + .getLifecycle(this.bucket.bucket, this.bucket.owner) + .pipe( + tap((lifecycle) => { + this.lifecycleRuleList = lifecycle; + }), + catchError(() => { + context.error(); + return of(null); + }) + ); + + this.filteredLifecycleRules$ = allLifecycleRules$.pipe( + map( + (lifecycle: any) => + lifecycle?.LifecycleConfiguration?.Rules?.filter((rule: object) => + rule.hasOwnProperty('Transition') + ) || [] + ) + ); + } + + openTieringModal(type: string) { + this.modalService.show(RgwBucketTieringFormComponent, { + bucket: this.bucket, + selectedLifecycle: this.selection.first(), + editing: type === this.actionLabels.EDIT ? true : false + }); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteAction() { + const ruleNames = this.selection.selected.map((rule) => rule.ID); + const filteredRules = this.lifecycleRuleList.LifecycleConfiguration.Rules.filter( + (rule: any) => !ruleNames.includes(rule.ID) + ); + const rules = filteredRules.length > 0 ? { Rules: filteredRules } : {}; + this.modalRef = this.modalService.show(DeleteConfirmationModalComponent, { + itemDescription: $localize`Rule`, + itemNames: ruleNames, + actionDescription: $localize`remove`, + submitAction: () => this.submitLifecycleConfig(rules) + }); + } + + submitLifecycleConfig(rules: any) { + this.rgwBucketService + .setLifecycle(this.bucket.bucket, JSON.stringify(rules), this.bucket.owner) + .subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Lifecycle rule deleted successfully` + ); + }, + error: () => { + this.modalRef.componentInstance.stopLoadingSpinner(); + }, + complete: () => { + this.modalService.dismissAll(); + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts index f240ab7d53f..a1dff9182a3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts @@ -55,7 +55,7 @@ describe('RgwBucketListComponent', () => { expect(tableActions).toEqual({ 'create,update,delete': { - actions: ['Create', 'Edit', 'Delete'], + actions: ['Create', 'Edit', 'Delete', 'Tiering'], primary: { multiple: 'Create', executing: 'Create', @@ -64,7 +64,7 @@ describe('RgwBucketListComponent', () => { } }, 'create,update': { - actions: ['Create', 'Edit'], + actions: ['Create', 'Edit', 'Tiering'], primary: { multiple: 'Create', executing: 'Create', @@ -91,7 +91,7 @@ describe('RgwBucketListComponent', () => { } }, 'update,delete': { - actions: ['Edit', 'Delete'], + actions: ['Edit', 'Delete', 'Tiering'], primary: { multiple: '', executing: '', @@ -100,12 +100,12 @@ describe('RgwBucketListComponent', () => { } }, update: { - actions: ['Edit'], + actions: ['Edit', 'Tiering'], primary: { - multiple: 'Edit', - executing: 'Edit', - single: 'Edit', - no: 'Edit' + multiple: '', + executing: '', + single: '', + no: '' } }, delete: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts index 347ed374582..4761ff69d07 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts @@ -24,6 +24,7 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Bucket } from '../models/rgw-bucket'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; +import { RgwBucketTieringFormComponent } from '../rgw-bucket-tiering-form/rgw-bucket-tiering-form.component'; const BASE_URL = 'rgw/bucket'; @@ -129,7 +130,14 @@ export class RgwBucketListComponent extends ListWithDetails implements OnInit, O click: () => this.deleteAction(), name: this.actionLabels.DELETE }; - this.tableActions = [addAction, editAction, deleteAction]; + const tieringAction: CdTableAction = { + permission: 'update', + icon: Icons.edit, + click: () => this.openTieringModal(), + disable: () => !this.selection.hasSelection, + name: this.actionLabels.TIERING + }; + this.tableActions = [addAction, editAction, deleteAction, tieringAction]; this.setTableRefreshTimeout(); } @@ -152,6 +160,12 @@ export class RgwBucketListComponent extends ListWithDetails implements OnInit, O this.selection = selection; } + openTieringModal() { + this.modalService.show(RgwBucketTieringFormComponent, { + bucket: this.selection.first() + }); + } + deleteAction() { const itemNames = this.selection.selected.map((bucket: any) => bucket['bid']); this.modalService.show(DeleteConfirmationModalComponent, { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html new file mode 100644 index 00000000000..045896ee19a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html @@ -0,0 +1,227 @@ + + {{editing ? 'Edit' : 'Create'}} Tiering configuration + + +
+ + + All fields are required, except where marked optional. + + + + In order to access the snapshot scheduler feature, the snap_scheduler module must be enabled + + + No storage class found. Consider creating it first to proceed. + +
+
+ Rule Name + + + + Unique identifier for the rule. The value cannot be longer than 255 characters. + + + + + This field is required. + + + Please enter a unique name. + + +
+
+ + + + + + + + The storage class to which you want the object to transition. + + +
+ Choose a configuration scope +
+ + + {{ 'Apply to all objects in the bucket' }} + + + {{ 'Limit the scope of this rule to selected filter criteria' }} + + +
+
+ Prefix + + + + + Prefix identifying one or more objects to which the rule applies + + + + + This field is required. + + +
+ + +
+
Tags
+
All the tags must exist in the object's tag set for the rule to apply.
+ + +
+
+ Name of the object key + + +
+
+ Value of the tag + + +
+
+ + + +
+
+
+
+
+ +
+
+ + Status +
+ + Enabled + Disabled + +
+
+ + + Select the number of days to transition the objects to the specified storage class. The value must be a positive integer. + + + + This field is required. + Enter a valid positive number + +
+
+
+
+ +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts new file mode 100644 index 00000000000..48c368692aa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RgwBucketTieringFormComponent } from './rgw-bucket-tiering-form.component'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { of } from 'rxjs'; +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ToastrModule } from 'ngx-toastr'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { + InputModule, + ModalModule, + ModalService, + NumberModule, + RadioModule, + SelectModule +} from 'carbon-components-angular'; +import { CdLabelComponent } from '~/app/shared/components/cd-label/cd-label.component'; +import { RouterTestingModule } from '@angular/router/testing'; + +class MockRgwBucketService { + setLifecycle = jest.fn().mockReturnValue(of(null)); + getLifecycle = jest.fn().mockReturnValue(of(null)); +} + +describe('RgwBucketTieringFormComponent', () => { + let component: RgwBucketTieringFormComponent; + let fixture: ComponentFixture; + let rgwBucketService: MockRgwBucketService; + + configureTestBed({ + declarations: [RgwBucketTieringFormComponent, CdLabelComponent], + imports: [ + ReactiveFormsModule, + HttpClientTestingModule, + RadioModule, + SelectModule, + NumberModule, + InputModule, + ToastrModule.forRoot(), + ComponentsModule, + ModalModule, + RouterTestingModule + ], + providers: [ + ModalService, + { provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } }, + { provide: RgwBucketService, useClass: MockRgwBucketService } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RgwBucketTieringFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + rgwBucketService = (TestBed.inject(RgwBucketService) as unknown) as MockRgwBucketService; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call setLifecyclePolicy function', () => { + component.ngOnInit(); + component.tieringForm.setValue({ + name: 'test', + storageClass: 'CLOUD', + hasPrefix: false, + prefix: '', + tags: [], + status: 'Enabled', + days: 60 + }); + const createTieringSpy = jest.spyOn(component, 'submitTieringConfig'); + const setLifecycleSpy = jest.spyOn(rgwBucketService, 'setLifecycle').mockReturnValue(of(null)); + component.submitTieringConfig(); + expect(createTieringSpy).toHaveBeenCalled(); + expect(component.tieringForm.valid).toBe(true); + expect(setLifecycleSpy).toHaveBeenCalled(); + expect(setLifecycleSpy).toHaveBeenCalledWith( + 'bucket1', + JSON.stringify(component.configuredLifecycle.LifecycleConfiguration), + 'dashboard' + ); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts new file mode 100644 index 00000000000..00e4f002b9a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts @@ -0,0 +1,270 @@ +import { ChangeDetectorRef, Component, Inject, OnInit, Optional } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormControl, + FormGroup, + ValidationErrors, + Validators +} from '@angular/forms'; +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'; +import { Bucket } from '../models/rgw-bucket'; +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service'; +import { BucketTieringUtils } from '../utils/rgw-bucket-tiering'; +import { StorageClass, ZoneGroupDetails } from '../models/rgw-storage-class.model'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { Router } from '@angular/router'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; + +export interface Tags { + tagKey: number; + tagValue: string; +} + +@Component({ + selector: 'cd-rgw-bucket-tiering', + templateUrl: './rgw-bucket-tiering-form.component.html', + styleUrls: ['./rgw-bucket-tiering-form.component.scss'] +}) +export class RgwBucketTieringFormComponent extends CdForm implements OnInit { + tieringForm: CdFormGroup; + tagsToRemove: Tags[] = []; + storageClassList: StorageClass[] = null; + configuredLifecycle: any; + isStorageClassFetched = false; + + constructor( + @Inject('bucket') public bucket: Bucket, + @Optional() @Inject('selectedLifecycle') public selectedLifecycle: any, + @Optional() @Inject('editing') public editing = false, + public actionLabels: ActionLabelsI18n, + private rgwBucketService: RgwBucketService, + private fb: CdFormBuilder, + private cd: ChangeDetectorRef, + private rgwZonegroupService: RgwZonegroupService, + private notificationService: NotificationService, + private router: Router + ) { + super(); + } + + ngOnInit() { + this.rgwBucketService + .getLifecycle(this.bucket.bucket, this.bucket.owner) + .subscribe((lifecycle) => { + this.configuredLifecycle = lifecycle || { LifecycleConfiguration: { Rules: [] } }; + if (this.editing) { + const ruleToEdit = this.configuredLifecycle?.['LifecycleConfiguration']?.['Rules'].filter( + (rule: any) => rule?.['ID'] === this.selectedLifecycle?.['ID'] + )[0]; + this.tieringForm.patchValue({ + name: ruleToEdit?.['ID'], + hasPrefix: this.checkIfRuleHasFilters(ruleToEdit), + prefix: + ruleToEdit?.['Prefix'] || + ruleToEdit?.['Filter']?.['Prefix'] || + ruleToEdit?.['Filter']?.['And']?.['Prefix'] || + '', + status: ruleToEdit?.['Status'], + days: ruleToEdit?.['Transition']?.['Days'] + }); + this.setTags(ruleToEdit); + this.tieringForm.get('name').disable(); + } + }); + this.tieringForm = this.fb.group({ + name: [null, [Validators.required, this.duplicateConfigName.bind(this)]], + storageClass: [null, Validators.required], + hasPrefix: [false, [Validators.required]], + prefix: [null, [CdValidators.composeIf({ hasPrefix: true }, [Validators.required])]], + tags: this.fb.array([]), + status: ['Enabled', [Validators.required]], + days: [60, [Validators.required, CdValidators.number(false)]] + }); + this.loadStorageClass(); + } + + checkIfRuleHasFilters(rule: any) { + if ( + this.isValidPrefix(rule?.['Prefix']) || + this.isValidPrefix(rule?.['Filter']?.['Prefix']) || + this.isValidArray(rule?.['Filter']?.['Tags']) || + this.isValidPrefix(rule?.['Filter']?.['And']?.['Prefix']) || + this.isValidArray(rule?.['Filter']?.['And']?.['Tags']) + ) { + return true; + } + return false; + } + + isValidPrefix(value: string) { + return value !== undefined && value !== ''; + } + + isValidArray(value: object[]) { + return Array.isArray(value) && value.length > 0; + } + + setTags(rule: any) { + if (rule?.['Filter']?.['Tags']?.length > 0) { + rule?.['Filter']?.['Tags']?.forEach((tag: { Key: string; Value: string }) => + this.addTags(tag.Key, tag.Value) + ); + } + if (rule?.['Filter']?.['And']?.['Tags']?.length > 0) { + rule?.['Filter']?.['And']?.['Tags']?.forEach((tag: { Key: string; Value: string }) => + this.addTags(tag.Key, tag.Value) + ); + } + } + + get tags() { + return this.tieringForm.get('tags') as FormArray; + } + + addTags(key?: string, value?: string) { + this.tags.push( + new FormGroup({ + Key: new FormControl(key), + Value: new FormControl(value) + }) + ); + this.cd.detectChanges(); + } + + duplicateConfigName(control: AbstractControl): ValidationErrors | null { + if (this.configuredLifecycle?.LifecycleConfiguration?.Rules?.length > 0) { + const ruleIds = this.configuredLifecycle.LifecycleConfiguration.Rules.map( + (rule: any) => rule.ID + ); + return ruleIds.includes(control.value) ? { duplicate: true } : null; + } + return null; + } + + removeTags(idx: number) { + this.tags.removeAt(idx); + this.cd.detectChanges(); + } + + loadStorageClass(): Promise { + return new Promise((resolve, reject) => { + this.rgwZonegroupService.getAllZonegroupsInfo().subscribe( + (data: ZoneGroupDetails) => { + this.storageClassList = []; + const tierObj = BucketTieringUtils.filterAndMapTierTargets(data); + this.isStorageClassFetched = true; + this.storageClassList.push(...tierObj); + if (this.editing) { + this.tieringForm + .get('storageClass') + .setValue(this.selectedLifecycle?.['Transition']?.['StorageClass']); + } + this.loadingReady(); + resolve(); + }, + (error) => { + reject(error); + } + ); + }); + } + + submitTieringConfig() { + const formValue = this.tieringForm.value; + if (!this.tieringForm.valid) { + return; + } + + let lifecycle: any = { + ID: this.tieringForm.getRawValue().name, + Status: formValue.status, + Transition: [ + { + Days: formValue.days, + StorageClass: formValue.storageClass + } + ] + }; + if (formValue.hasPrefix) { + if (this.tags.length > 0) { + Object.assign(lifecycle, { + Filter: { + And: { + Prefix: formValue.prefix, + Tag: this.tags.value + } + } + }); + } else { + Object.assign(lifecycle, { + Filter: { + Prefix: formValue.prefix + } + }); + } + } else { + Object.assign(lifecycle, { + Filter: {} + }); + } + if (!this.editing) { + this.configuredLifecycle.LifecycleConfiguration.Rules.push(lifecycle); + this.rgwBucketService + .setLifecycle( + this.bucket.bucket, + JSON.stringify(this.configuredLifecycle.LifecycleConfiguration), + this.bucket.owner + ) + .subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Bucket lifecycle created succesfully` + ); + }, + error: (error: any) => { + this.notificationService.show(NotificationType.error, error); + this.tieringForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.closeModal(); + } + }); + } else { + const rules = this.configuredLifecycle.LifecycleConfiguration.Rules; + const index = rules.findIndex((rule: any) => rule?.['ID'] === this.selectedLifecycle?.['ID']); + rules.splice(index, 1, lifecycle); + this.rgwBucketService + .setLifecycle( + this.bucket.bucket, + JSON.stringify(this.configuredLifecycle.LifecycleConfiguration), + this.bucket.owner + ) + .subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Bucket lifecycle modified succesfully` + ); + }, + error: (error: any) => { + this.notificationService.show(NotificationType.error, error); + this.tieringForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.closeModal(); + } + }); + } + } + + goToCreateStorageClass() { + this.router.navigate(['rgw/tiering/create']); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-list/rgw-storage-class-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-list/rgw-storage-class-list.component.ts index 1f6fddf032d..445c372b336 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-list/rgw-storage-class-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-list/rgw-storage-class-list.component.ts @@ -4,14 +4,7 @@ import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; -import { - StorageClass, - CLOUD_TIER, - ZoneGroup, - TierTarget, - Target, - ZoneGroupDetails -} from '../models/rgw-storage-class.model'; +import { StorageClass, ZoneGroupDetails } from '../models/rgw-storage-class.model'; import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { Icons } from '~/app/shared/enum/icons.enum'; @@ -23,6 +16,7 @@ import { RgwStorageClassService } from '~/app/shared/api/rgw-storage-class.servi import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Permission } from '~/app/shared/models/permissions'; +import { BucketTieringUtils } from '../utils/rgw-bucket-tiering'; import { Router } from '@angular/router'; @@ -105,18 +99,7 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI this.rgwZonegroupService.getAllZonegroupsInfo().subscribe( (data: ZoneGroupDetails) => { this.storageClassList = []; - - const tierObj = data.zonegroups.flatMap((zoneGroup: ZoneGroup) => - zoneGroup.placement_targets - .filter((target: Target) => target.tier_targets) - .flatMap((target: Target) => - target.tier_targets - .filter((tierTarget: TierTarget) => tierTarget.val.tier_type === CLOUD_TIER) - .map((tierTarget: TierTarget) => { - return this.getTierTargets(tierTarget, zoneGroup.name, target.name); - }) - ) - ); + const tierObj = BucketTieringUtils.filterAndMapTierTargets(data); this.storageClassList.push(...tierObj); resolve(); }, @@ -127,16 +110,6 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI }); } - getTierTargets(tierTarget: TierTarget, zoneGroup: string, targetName: string) { - if (tierTarget.val.tier_type !== CLOUD_TIER) return null; - return { - zonegroup_name: zoneGroup, - placement_target: targetName, - storage_class: tierTarget.val.storage_class, - ...tierTarget.val.s3 - }; - } - removeStorageClassModal() { const storage_class = this.selection.first().storage_class; const placement_target = this.selection.first().placement_target; 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 6bb87d9ec36..fad630b50dc 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 @@ -76,10 +76,13 @@ import { InputModule, CheckboxModule, TreeviewModule, + RadioModule, SelectModule, NumberModule, TabsModule, - AccordionModule + AccordionModule, + TagModule, + TooltipModule } from 'carbon-components-angular'; import { CephSharedModule } from '../shared/ceph-shared.module'; import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.component'; @@ -87,6 +90,8 @@ import { RgwUserAccountsFormComponent } from './rgw-user-accounts-form/rgw-user- import { RgwUserAccountsDetailsComponent } from './rgw-user-accounts-details/rgw-user-accounts-details.component'; import { RgwStorageClassDetailsComponent } from './rgw-storage-class-details/rgw-storage-class-details.component'; import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-storage-class-form.component'; +import { RgwBucketTieringFormComponent } from './rgw-bucket-tiering-form/rgw-bucket-tiering-form.component'; +import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component'; @NgModule({ imports: [ @@ -120,7 +125,12 @@ import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-stora NumberModule, TabsModule, IconModule, - SelectModule + SelectModule, + RadioModule, + SelectModule, + NumberModule, + TagModule, + TooltipModule ], exports: [ RgwDaemonListComponent, @@ -178,7 +188,9 @@ import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-stora RgwUserAccountsDetailsComponent, RgwStorageClassListComponent, RgwStorageClassDetailsComponent, - RgwStorageClassFormComponent + RgwStorageClassFormComponent, + RgwBucketTieringFormComponent, + RgwBucketLifecycleListComponent ], providers: [TitleCasePipe] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts new file mode 100644 index 00000000000..7c33d89f236 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts @@ -0,0 +1,33 @@ +import { + CLOUD_TIER, + Target, + TierTarget, + ZoneGroup, + ZoneGroupDetails +} from '../models/rgw-storage-class.model'; + +export class BucketTieringUtils { + static filterAndMapTierTargets(zonegroupData: ZoneGroupDetails) { + return zonegroupData.zonegroups.flatMap((zoneGroup: ZoneGroup) => + zoneGroup.placement_targets + .filter((target: Target) => target.tier_targets) + .flatMap((target: Target) => + target.tier_targets + .filter((tierTarget: TierTarget) => tierTarget.val.tier_type === CLOUD_TIER) + .map((tierTarget: TierTarget) => { + return this.getTierTargets(tierTarget, zoneGroup.name, target.name); + }) + ) + ); + } + + private static getTierTargets(tierTarget: TierTarget, zoneGroup: string, targetName: string) { + if (tierTarget.val.tier_type !== CLOUD_TIER) return null; + return { + zonegroup_name: zoneGroup, + placement_target: targetName, + storage_class: tierTarget.val.storage_class, + ...tierTarget.val.s3 + }; + } +} 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 ed3134f5cae..621919da59c 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 @@ -272,4 +272,25 @@ export class RgwBucketService extends ApiClient { return this.http.get(`${this.url}/getEncryptionConfig`, { params: params }); }); } + + setLifecycle(bucket_name: string, lifecycle: string, owner: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.appendAll({ + bucket_name: bucket_name, + lifecycle: lifecycle, + owner: owner + }); + return this.http.put(`${this.url}/lifecycle`, null, { params: params }); + }); + } + + getLifecycle(bucket_name: string, owner: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.appendAll({ + bucket_name: bucket_name, + owner: owner + }); + return this.http.get(`${this.url}/lifecycle`, { params: params }); + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index bf7cb6b9567..d9f7854f192 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -120,6 +120,7 @@ export class ActionLabelsI18n { SET: string; SUBMIT: string; SHOW: string; + TIERING: string; TRASH: string; UNPROTECT: string; UNSET: string; @@ -206,6 +207,7 @@ export class ActionLabelsI18n { this.ROLLBACK = $localize`Rollback`; this.SCRUB = $localize`Scrub`; this.SHOW = $localize`Show`; + this.TIERING = $localize`Tiering`; this.TRASH = $localize`Move to Trash`; this.UNPROTECT = $localize`Unprotect`; this.CHANGE = $localize`Change`; diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/_carbon-defaults.scss b/src/pybind/mgr/dashboard/frontend/src/styles/_carbon-defaults.scss index 61ca421101e..2d99c77bd80 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/_carbon-defaults.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/_carbon-defaults.scss @@ -149,3 +149,10 @@ Code snippet .cds--snippet { width: fit-content; } + +/****************************************** +Tooltip +******************************************/ +.cds--tooltip-content { + background-color: theme.$layer-02; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss index 683abc7a237..0801a6ff0d3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss @@ -1,5 +1,7 @@ @use '../vendor/variables' as vv; @use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; /* Forms */ .required::after { @@ -133,3 +135,11 @@ Form Controls fieldset { @extend .cds--fieldset; } + +.item-action-btn { + margin-top: layout.$spacing-07; +} + +.form-group-header { + @include type.type-style('heading-01'); +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index c4fb7fc9465..dc2ccc37055 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -11556,6 +11556,85 @@ paths: - jwt: [] tags: - RgwBucket + /api/rgw/bucket/lifecycle: + get: + parameters: + - default: '' + in: query + name: bucket_name + schema: + type: string + - allowEmptyValue: true + in: query + name: daemon_name + schema: + type: string + - allowEmptyValue: true + in: query + name: owner + schema: + type: string + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + tags: + - RgwBucket + put: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + bucket_name: + default: '' + type: string + daemon_name: + type: string + lifecycle: + default: '' + type: string + owner: + type: string + type: object + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource updated. + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + tags: + - RgwBucket /api/rgw/bucket/setEncryptionConfig: put: parameters: [] diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 92c23f090e6..1b0c05feed4 100755 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -948,6 +948,14 @@ class RgwClient(RestClient): f' For more information about the format look at {link}') raise DashboardException(msg=msg, component='rgw') + def get_lifecycle_progress(self): + rgw_bucket_lc_progress_command = ['lc', 'list'] + code, lifecycle_progress, _err = mgr.send_rgwadmin_command(rgw_bucket_lc_progress_command) + if code != 0: + raise DashboardException(msg=f'Error getting lifecycle status: {_err}', + component='rgw') + return lifecycle_progress + def get_role(self, role_name: str): rgw_get_role_command = ['role', 'get', '--role-name', role_name] code, role, _err = mgr.send_rgwadmin_command(rgw_get_role_command) -- 2.39.5