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)
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)
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):
-.item-action-btn {
- margin-top: 2rem;
-}
</cds-code-snippet>
</td>
</tr>
+ <tr *ngIf="selection.lifecycle_progress?.length > 0">
+ <td i18n
+ class="bold w-25">Lifecycle Progress</td>
+ <td>
+ <cds-tooltip [description]="lifecycleProgressMap.get(lifecycleProgress)?.description"
+ [align]="'top'">
+ <cds-tag size="md"
+ [type]="lifecycleProgressMap.get(lifecycleProgress)?.color">
+ {{ lifecycleProgress }}
+ </cds-tag>
+ </cds-tooltip>
+ </td>
+ </tr>
<tr>
<td i18n
class="bold w-25">Replication policy</td>
</div>
</ng-template>
</ng-container>
+
+ <ng-container ngbNavItem="tiering">
+ <a ngbNavLink
+ i18n>Tiering</a>
+ <ng-template ngbNavContent>
+ <cd-rgw-bucket-lifecycle-list [bucket]="selection"></cd-rgw-bucket-lifecycle-list>
+ </ng-template>
+ </ng-container>
</nav>
<div [ngbNavOutlet]="nav"></div>
export class RgwBucketDetailsComponent implements OnChanges {
@Input()
selection: any;
-
+ lifecycleProgress: string;
+ lifecycleProgressMap = new Map<string, { description: string; color: string }>([
+ ['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<string, string[]> = {};
replicationStatus = $localize`Disabled`;
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;
+ }
+ }
+ );
+ }
});
}
}
--- /dev/null
+<legend i18n>
+ Tiering Configuration
+ <cd-help-text>
+ 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.
+ </cd-help-text>
+</legend>
+<cd-table #table
+ [data]="filteredLifecycleRules$ | async"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="multiClick"
+ (updateSelection)="updateSelection($event)"
+ identifier="ID"
+ (fetchData)="loadLifecyclePolicies($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+</cd-table>
--- /dev/null
+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<RgwBucketLifecycleListComponent>;
+
+ 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();
+ });
+});
--- /dev/null
+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<any[]>;
+ 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();
+ }
+ });
+ }
+}
expect(tableActions).toEqual({
'create,update,delete': {
- actions: ['Create', 'Edit', 'Delete'],
+ actions: ['Create', 'Edit', 'Delete', 'Tiering'],
primary: {
multiple: 'Create',
executing: 'Create',
}
},
'create,update': {
- actions: ['Create', 'Edit'],
+ actions: ['Create', 'Edit', 'Tiering'],
primary: {
multiple: 'Create',
executing: 'Create',
}
},
'update,delete': {
- actions: ['Edit', 'Delete'],
+ actions: ['Edit', 'Delete', 'Tiering'],
primary: {
multiple: '',
executing: '',
}
},
update: {
- actions: ['Edit'],
+ actions: ['Edit', 'Tiering'],
primary: {
- multiple: 'Edit',
- executing: 'Edit',
- single: 'Edit',
- no: 'Edit'
+ multiple: '',
+ executing: '',
+ single: '',
+ no: ''
}
},
delete: {
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';
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();
}
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, {
--- /dev/null
+<cds-modal size="md"
+ [open]="open"
+ (overlaySelected)="closeModal()">
+ <cds-modal-header (closeSelect)="closeModal()"
+ i18n>{{editing ? 'Edit' : 'Create'}} Tiering configuration</cds-modal-header>
+
+<ng-container *cdFormLoading="loading">
+ <section cdsModalContent>
+ <legend>
+ <cd-help-text i18n>
+ All fields are required, except where marked optional.
+ </cd-help-text>
+ </legend>
+ <cd-alert-panel
+ *ngIf="(snapScheduleModuleStatus$ | async) === false"
+ type="info"
+ spacingClass="mb-3"
+ i18n
+ class="align-items-center"
+ actionName="Enable"
+ (action)="enableSnapshotSchedule()"
+>
+ In order to access the snapshot scheduler feature, the snap_scheduler module must be enabled
+ </cd-alert-panel>
+ <cd-alert-panel *ngIf="storageClassList?.length === 0 &&
+ isStorageClassFetched"
+ type="info"
+ spacingClass="mb-3"
+ class="align-items-center"
+ actionName="Create"
+ i18n
+ (action)="goToCreateStorageClass()">
+ No storage class found. Consider creating it first to proceed.
+ </cd-alert-panel>
+ <form name="tieringForm"
+ #formDir="ngForm"
+ [formGroup]="tieringForm"
+ novalidate>
+ <div class="form-item">
+ <cds-text-label
+ labelInputID="rule_name"
+ [invalid]="!tieringForm.controls.name.valid && tieringForm.controls.name.dirty"
+ [invalidText]="ruleNameError"
+ [helperText]="ruleHelper"
+ i18n
+ >Rule Name
+ <input
+ cdsText
+ type="text"
+ id="rule_name"
+ maxlength="255"
+ formControlName="name"
+ />
+ </cds-text-label>
+ <ng-template #ruleHelper>
+ <span i18n>Unique identifier for the rule. The value cannot be longer than 255 characters.
+ </span>
+ </ng-template>
+ <ng-template #ruleNameError>
+ <span *ngIf="tieringForm.showError('name', formDir, 'required')"
+ class="invalid-feedback">
+ <ng-container i18n>This field is required.</ng-container>
+ </span>
+ <span *ngIf="tieringForm.showError('name', formDir, 'duplicate')"
+ class="invalid-feedback">
+ <ng-container i18n>Please enter a unique name.</ng-container>
+ </span>
+ </ng-template>
+ </div>
+ <div class="form-item">
+ <cds-select id="storageClass"
+ formControlName="storageClass"
+ label="Storage Class"
+ [helperText]="storageClassHelper">
+ <option *ngIf="storageClassList === null"
+ value="">Loading...</option>
+ <option *ngIf="storageClassList !== null && storageClassList.length === 0"
+ value="">-- No storage class available --</option>
+ <option *ngIf="storageClassList !== null && storageClassList.length > 0"
+ value="">-- Select the storage class --</option>
+ <option *ngFor="let tier of storageClassList"
+ [value]="tier.storage_class">
+ {{ tier.storage_class }}
+ </option>
+ </cds-select>
+ <ng-template #storageClassHelper>
+ <span i18n>The storage class to which you want the object to transition.
+ </span>
+ </ng-template>
+ </div>
+ <legend class="cds--label">Choose a configuration scope</legend>
+ <div>
+ <cds-radio-group
+ formControlName="hasPrefix"
+ >
+ <cds-radio [value]="false"
+ i18n>
+ {{ 'Apply to all objects in the bucket' }}
+ </cds-radio>
+ <cds-radio [value]="true"
+ i18n>
+ {{ 'Limit the scope of this rule to selected filter criteria' }}
+ </cds-radio>
+ </cds-radio-group>
+ </div>
+ <div class="form-item"
+ *ngIf="tieringForm.controls.hasPrefix.value">
+ <cds-text-label labelInputID="prefix"
+ [invalid]="!tieringForm.controls.prefix.valid && tieringForm.controls.prefix.dirty"
+ [invalidText]="prefixError"
+ [helperText]="prefixHelper"
+ i18n>Prefix
+ <input cdsText
+ type="text"
+ id="prefix"
+ formControlName="prefix"/>
+ </cds-text-label>
+ <ng-template #prefixHelper>
+ <span i18n>
+ Prefix identifying one or more objects to which the rule applies
+ </span>
+ </ng-template>
+ <ng-template #prefixError>
+ <span *ngIf="tieringForm.showError('prefix', formDir, 'required')"
+ class="invalid-feedback">
+ <ng-container i18n>This field is required.</ng-container>
+ </span>
+ </ng-template>
+ </div>
+
+ <!-- tags -->
+ <div *ngIf="tieringForm.controls.hasPrefix.value">
+ <div class="form-group-header">Tags</div>
+ <div>All the tags must exist in the object's tag set for the rule to apply.</div>
+ <ng-container formArrayName="tags"
+ *ngFor="let tags of tags.controls; index as i">
+ <ng-container [formGroupName]="i">
+ <div cdsRow
+ class="form-item form-item-append">
+ <div cdsCol>
+ <cds-text-label labelInputID="Key"
+ i18n>Name of the object key
+ <input cdsText
+ type="text"
+ placeholder="Enter name of the object key"
+ id="Key"
+ formControlName="Key"
+ i18n-placeholder/>
+ </cds-text-label>
+ </div>
+ <div cdsCol>
+ <cds-text-label labelInputID="Value"
+ i18n>Value of the tag
+ <input cdsText
+ type="text"
+ placeholder="Enter value of the tag"
+ id="Value"
+ formControlName="Value"
+ i18n-placeholder/>
+ </cds-text-label>
+ </div>
+ <div cdsCol
+ [columnNumbers]="{ lg: 2, md: 2 }"
+ class="item-action-btn">
+ <cds-icon-button kind="tertiary"
+ size="sm"
+ (click)="removeTags(i)">
+ <svg cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon"></svg>
+ </cds-icon-button>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+ <div class="form-item">
+ <button cdsButton="tertiary"
+ type="button"
+ (click)="addTags()"
+ i18n>Add tags
+ <svg cdsIcon="add"
+ size="32"
+ class="cds--btn__icon"
+ icon></svg>
+ </button>
+ </div>
+ </div>
+
+ <legend class="cds--label">Status</legend>
+ <div>
+ <cds-radio-group
+ formControlName="status">
+ <cds-radio [value]="'Enabled'"
+ i18n>Enabled </cds-radio>
+ <cds-radio [value]="'Disabled'"
+ i18n>Disabled </cds-radio>
+ </cds-radio-group>
+ </div>
+ <div class="form-item">
+ <cds-number formControlName="days"
+ label="Number of days"
+ [min]="1"
+ [invalid]="!tieringForm.controls.days.valid && tieringForm.controls.days.dirty"
+ [invalidText]="daysError"
+ [helperText]="daysHelper"></cds-number>
+ <ng-template #daysHelper>
+ <span i18n>Select the number of days to transition the objects to the specified storage class. The value must be a positive integer.
+ </span>
+ </ng-template>
+ <ng-template #daysError>
+ <span *ngIf="tieringForm.showError('days', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span *ngIf="tieringForm.showError('days', formDir, 'pattern')"
+ i18n>Enter a valid positive number</span>
+ </ng-template>
+ </div>
+ </form>
+ </section>
+</ng-container>
+ <cd-form-button-panel
+ (submitActionEvent)="submitTieringConfig()"
+ [form]="tieringForm"
+ [submitText]="editing ? actionLabels.EDIT : actionLabels.CREATE"
+ [modalForm]="true"
+ [disabled]="storageClassList?.length === 0 && isStorageClassFetched"
+ ></cd-form-button-panel>
+</cds-modal>
--- /dev/null
+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<RgwBucketTieringFormComponent>;
+ 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'
+ );
+ });
+});
--- /dev/null
+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<void> {
+ 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']);
+ }
+}
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';
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';
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();
},
});
}
- 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;
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';
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: [
NumberModule,
TabsModule,
IconModule,
- SelectModule
+ SelectModule,
+ RadioModule,
+ SelectModule,
+ NumberModule,
+ TagModule,
+ TooltipModule
],
exports: [
RgwDaemonListComponent,
RgwUserAccountsDetailsComponent,
RgwStorageClassListComponent,
RgwStorageClassDetailsComponent,
- RgwStorageClassFormComponent
+ RgwStorageClassFormComponent,
+ RgwBucketTieringFormComponent,
+ RgwBucketLifecycleListComponent
],
providers: [TitleCasePipe]
})
--- /dev/null
+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
+ };
+ }
+}
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 });
+ });
+ }
}
SET: string;
SUBMIT: string;
SHOW: string;
+ TIERING: string;
TRASH: string;
UNPROTECT: string;
UNSET: string;
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`;
.cds--snippet {
width: fit-content;
}
+
+/******************************************
+Tooltip
+******************************************/
+.cds--tooltip-content {
+ background-color: theme.$layer-02;
+}
@use '../vendor/variables' as vv;
@use '@carbon/colors';
+@use '@carbon/layout';
+@use '@carbon/type';
/* Forms */
.required::after {
fieldset {
@extend .cds--fieldset;
}
+
+.item-action-btn {
+ margin-top: layout.$spacing-07;
+}
+
+.form-group-header {
+ @include type.type-style('heading-01');
+}
- 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: []
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)