result = multisite_instance.delete_placement_targets(placement_id, storage_class)
return result
+ @Endpoint('POST', path='storage-class')
+ @CreatePermission
+ # pylint: disable=W0102
+ def storage_class(self, zone_group, placement_targets: List[Dict[str, str]] = []):
+ multisite_instance = RgwMultisite()
+ result = multisite_instance.add_placement_targets(zone_group, placement_targets)
+ return result
+
@Endpoint()
@ReadPermission
def get_all_zonegroups_info(self):
export interface ZoneGroupDetails {
default_zonegroup: string;
+ name: string;
zonegroups: ZoneGroup[];
}
multipart_sync_threshold: number;
host_style: string;
}
-export interface ZoneGroup {
- name: string;
- placement_targets: Target[];
-}
export interface S3Details {
endpoint: string;
tier_targets: TierTarget[];
}
+export interface StorageClassDetails {
+ target_path: string;
+ access_key: string;
+ secret: string;
+ multipart_min_part_size: number;
+ multipart_sync_threshold: number;
+ host_style: string;
+}
+export interface ZoneGroup {
+ name: string;
+ id: string;
+ placement_targets?: Target[];
+}
+
+export interface S3Details {
+ endpoint: string;
+ access_key: string;
+ storage_class: string;
+ target_path: string;
+ target_storage_class: string;
+ region: string;
+ secret: string;
+ multipart_min_part_size: number;
+ multipart_sync_threshold: number;
+ host_style: boolean;
+}
+
+export interface RequestModel {
+ zone_group: string;
+ placement_targets: PlacementTarget[];
+}
+
+export interface PlacementTarget {
+ tags: string[];
+ placement_id: string;
+ storage_class: string;
+ tier_type: typeof CLOUD_TIER;
+ tier_config: {
+ endpoint: string;
+ access_key: string;
+ secret: string;
+ target_path: string;
+ retain_head_object: boolean;
+ region: string;
+ multipart_sync_threshold: number;
+ multipart_min_part_size: number;
+ };
+}
+
export const CLOUD_TIER = 'cloud-s3';
+
+export const DEFAULT_PLACEMENT = 'default-placement';
--- /dev/null
+<div cdsCol
+ [columnNumbers]="{ md: 4 }">
+ <form name="storageClassForm"
+ #formDir="ngForm"
+ [formGroup]="storageClassForm"
+ novalidate>
+ <div i18n="form title"
+ class="form-header">
+ {{ action | titlecase }} {{ resource | upperFirst }}
+ </div>
+ <legend>
+ <cd-help-text i18n>
+ All fields are required, except where marked optional.
+ </cd-help-text>
+ </legend>
+ <div class="form-item form-item-append"
+ cdsRow>
+ <div cdsCol>
+ <!-- Zone Group -->
+ <cds-select
+ label="Zone Group Name"
+ i18n-label
+ formControlName="zonegroup"
+ id="zonegroup"
+ [invalid]="
+ storageClassForm.controls.zonegroup.invalid && storageClassForm.controls.zonegroup.dirty
+ "
+ (change)="onZonegroupChange()"
+ [invalidText]="zonegroupError"
+ >
+ <option *ngFor="let zonegrp of zonegroupNames"
+ [value]="zonegrp.name"
+ [selected]="zonegrp.name === storageClassForm.getValue('zonegroup')"
+ i18n>
+ {{ zonegrp.name }}
+ </option>
+ </cds-select>
+ <ng-template #zonegroupError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('zonegroup', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ <div cdsCol>
+ <!-- Placement Target -->
+ <cds-select
+ label="Placement Target"
+ i18n-label
+ formControlName="placement_target"
+ id="placement_target"
+ [invalid]="
+ storageClassForm.controls.placement_target.invalid &&
+ storageClassForm.controls.placement_target.dirty
+ "
+ [invalidText]="placementError"
+ >
+ <option [value]=""
+ i18n> --Select-- </option>
+ <option *ngFor="let placementTarget of placementTargets"
+ [value]="placementTarget"
+ [selected]="placementTarget === storageClassForm.getValue('placement_target')"
+ i18n>
+ {{ placementTarget }}
+ </option>
+ </cds-select>
+ <ng-template #placementError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('placement_target', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ </div>
+ <!-- Storage Class -->
+ <div class="form-item">
+ <cds-text-label
+ labelInputID="storage_class"
+ i18n
+ [invalid]="
+ storageClassForm.controls.storage_class.invalid &&
+ storageClassForm.controls.storage_class.dirty
+ "
+ [invalidText]="storageError"
+ >Storage Class Name
+ <input
+ cdsText
+ type="type"
+ id="storage_class"
+ formControlName="storage_class"
+ [invalid]="
+ storageClassForm.controls.storage_class.invalid &&
+ storageClassForm.controls.storage_class.dirty
+ "
+ />
+ </cds-text-label>
+ <ng-template #storageError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('storage_class', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ <div class="form-item form-item-append"
+ cdsRow>
+ <div cdsCol>
+ <!-- Target Region -->
+ <cds-text-label
+ labelInputID="region"
+ i18n
+ [invalid]="
+ storageClassForm.controls.region.invalid && storageClassForm.controls.region.dirty
+ "
+ [invalidText]="regionError"
+ [helperText]="targetRegionText"
+ >Target Region
+ <input
+ cdsText
+ type="text"
+ id="region"
+ formControlName="region"
+ placeholder="e.g, us-east-1"
+ i18n-placeholder
+ [invalid]="
+ storageClassForm.controls.region.invalid && storageClassForm.controls.region.dirty
+ "
+ />
+ </cds-text-label>
+ <ng-template #regionError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('region', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ <div cdsCol>
+ <!-- Target Endpoint -->
+ <cds-text-label
+ labelInputID="endpoint"
+ i18n
+ [invalid]="
+ storageClassForm.controls.endpoint.invalid && storageClassForm.controls.endpoint.dirty
+ "
+ [invalidText]="endpointError"
+ [helperText]="targetEndpointText"
+ >Target Endpoint
+ <input
+ cdsText
+ type="text"
+ placeholder="e.g, http://ceph-node-00.com:80"
+ i18n-placeholder
+ id="endpoint"
+ formControlName="endpoint"
+ [invalid]="
+ storageClassForm.controls.endpoint.invalid && storageClassForm.controls.endpoint.dirty
+ "
+ />
+ </cds-text-label>
+ <ng-template #endpointError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('endpoint', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ </div>
+
+ <!-- Access Key -->
+ <div class="form-item">
+ <div cdsCol
+ [columnNumbers]="{ md: 12 }"
+ class="d-flex">
+ <cds-password-label
+ labelInputID="access_key"
+ [invalid]="
+ !storageClassForm.controls.access_key.valid &&
+ storageClassForm.controls.access_key.dirty
+ "
+ [invalidText]="accessError"
+ [helperText]="targetAccessKeyText"
+ i18n
+ >Target Access Key
+ <input
+ cdsPassword
+ type="password"
+ id="access_key"
+ formControlName="access_key"
+ [invalid]="
+ !storageClassForm.controls.access_key.valid &&
+ storageClassForm.controls.access_key.dirty
+ "
+ />
+ </cds-password-label>
+ <cd-copy-2-clipboard-button class="clipboard"> </cd-copy-2-clipboard-button>
+ <ng-template #accessError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('access_key', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ </div>
+
+ <!-- Secret Key -->
+ <div class="form-item">
+ <div cdsCol
+ [columnNumbers]="{ md: 12 }"
+ class="d-flex">
+ <cds-password-label
+ labelInputID="secret_key"
+ [helperText]="targetSecretKeyText"
+ [invalid]="
+ !storageClassForm.controls.secret_key.valid &&
+ storageClassForm.controls.secret_key.dirty
+ "
+ [invalidText]="secretError"
+ i18n
+ >Target Secret Key
+ <input
+ cdsPassword
+ type="password"
+ id="secret_key"
+ formControlName="secret_key"
+ [invalid]="
+ !storageClassForm.controls.secret_key.valid &&
+ storageClassForm.controls.secret_key.dirty
+ "
+ />
+ </cds-password-label>
+ <cd-copy-2-clipboard-button class="clipboard"> </cd-copy-2-clipboard-button>
+ <ng-template #secretError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('secret_key', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ </div>
+
+ <!-- Target Path -->
+ <div class="form-item">
+ <cds-text-label
+ labelInputID="target_path"
+ i18n
+ [invalid]="
+ storageClassForm.controls.target_path.invalid &&
+ storageClassForm.controls.target_path.dirty
+ "
+ [invalidText]="targetError"
+ [helperText]="targetPathText"
+ >Target Path
+ <input
+ cdsText
+ type="text"
+ id="target_path"
+ formControlName="target_path"
+ [invalid]="
+ storageClassForm.controls.target_path.invalid &&
+ storageClassForm.controls.target_path.dirty
+ "
+ />
+ </cds-text-label>
+ <ng-template #targetError>
+ <span
+ class="invalid-feedback"
+ *ngIf="storageClassForm.showError('target_path', formDir, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+
+ <fieldset>
+ <cds-accordion size="lg"
+ class="form-item">
+ <cds-accordion-item
+ [title]="title"
+ id="advanced-fieldset"
+ (selected)="showAdvanced = !showAdvanced"
+ >
+ <!-- Multi Part Sync Threshold -->
+ <div class="form-item form-item-append"
+ cdsRow>
+ <div cdsCol>
+ <cds-text-label
+ labelInputID="multipart_sync_threshold"
+ i18n
+ [helperText]="multipartSyncThreholdText"
+ cdOptionalField="Multipart Sync Threshold"
+ >Multipart Sync Threshold
+ <input
+ cdsText
+ type="text"
+ id="multipart_sync_threshold"
+ formControlName="multipart_sync_threshold"
+ />
+ </cds-text-label>
+ </div>
+ <div cdsCol>
+ <cds-text-label
+ labelInputID="multipart_min_part_size"
+ i18n
+ [helperText]="multipartMinPartText"
+ cdOptionalField="Multipart Minimum Part Size"
+ >Multipart Minimum Part Size
+ <input
+ cdsText
+ type="text"
+ id="multipart_min_part_size"
+ formControlName="multipart_min_part_size"
+ />
+ </cds-text-label>
+ </div>
+ </div>
+ <div class="form-item">
+ <cds-checkbox
+ id="retain_head_object"
+ formControlName="retain_head_object"
+ cdOptionalField="Retain Head Object"
+ i18n-label
+ >Retain Head Object
+ <cd-help-text>{{ retainHeadObjectText }}</cd-help-text>
+ </cds-checkbox>
+ </div>
+ </cds-accordion-item>
+ </cds-accordion>
+ <ng-template #title>
+ <h5 class="cds--accordion__title cd-header">Advanced</h5>
+ </ng-template>
+ </fieldset>
+ <cd-alert-panel type="warning"
+ spacingClass="mb-2">
+ <span i18n>RGW service would be restarted after creating the storage class.</span>
+ </cd-alert-panel>
+ <cd-form-button-panel
+ (submitActionEvent)="submitAction()"
+ [form]="storageClassForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"
+ ></cd-form-button-panel>
+ </form>
+</div>
--- /dev/null
+@use '@carbon/layout';
+
+.clipboard {
+ margin-top: layout.$spacing-06;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { SharedModule } from '~/app/shared/shared.module';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ToastrModule } from 'ngx-toastr';
+import {
+ CheckboxModule,
+ ComboBoxModule,
+ GridModule,
+ InputModule,
+ SelectModule
+} from 'carbon-components-angular';
+import { CoreModule } from '~/app/core/core.module';
+import { RgwStorageClassFormComponent } from './rgw-storage-class-form.component';
+
+describe('RgwStorageClassFormComponent', () => {
+ let component: RgwStorageClassFormComponent;
+ let fixture: ComponentFixture<RgwStorageClassFormComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ReactiveFormsModule,
+ ToastrModule.forRoot(),
+ GridModule,
+ InputModule,
+ CoreModule,
+ SelectModule,
+ ComboBoxModule,
+ CheckboxModule
+ ],
+ declarations: [RgwStorageClassFormComponent]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(RgwStorageClassFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ component.goToListView();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize the form with empty values', () => {
+ const storageClassForm = component.storageClassForm;
+ expect(storageClassForm).toBeTruthy();
+ expect(storageClassForm.get('zonegroup')).toBeTruthy();
+ expect(storageClassForm.get('placement_target')).toBeTruthy();
+ });
+
+ it('on zonegroup changes', () => {
+ component.zoneGroupDeatils = {
+ default_zonegroup: 'zonegroup1',
+ name: 'zonegrp1',
+ zonegroups: [
+ {
+ name: 'zonegroup1',
+ id: 'zonegroup-id-1',
+ placement_targets: [
+ {
+ name: 'default-placement',
+ tier_targets: [
+ {
+ val: {
+ storage_class: 'CLOUDIBM',
+ tier_type: 'cloud-s3',
+ s3: {
+ endpoint: 'https://s3.amazonaws.com',
+ access_key: 'ACCESSKEY',
+ storage_class: 'STANDARD',
+ target_path: '/path/to/storage',
+ target_storage_class: 'STANDARD',
+ region: 'useastr1',
+ secret: 'SECRETKEY',
+ multipart_min_part_size: 87877,
+ multipart_sync_threshold: 987877,
+ host_style: true
+ }
+ }
+ }
+ ]
+ },
+ {
+ name: 'placement1',
+ tier_targets: [
+ {
+ val: {
+ storage_class: 'CloudIBM',
+ tier_type: 'cloud-s3',
+ s3: {
+ endpoint: 'https://s3.amazonaws.com',
+ access_key: 'ACCESSKEY',
+ storage_class: 'GLACIER',
+ target_path: '/pathStorage',
+ target_storage_class: 'CloudIBM',
+ region: 'useast1',
+ secret: 'SECRETKEY',
+ multipart_min_part_size: 187988787,
+ multipart_sync_threshold: 878787878,
+ host_style: false
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ };
+ component.storageClassForm.get('zonegroup').setValue('zonegroup1');
+ component.onZonegroupChange();
+ expect(component.placementTargets).toEqual(['default-placement', 'placement1']);
+ expect(component.storageClassForm.get('placement_target').value).toBe('default-placement');
+ });
+
+ it('should set form values on submit', () => {
+ const storageClassName = 'storageClass1';
+ component.storageClassForm.get('storage_class').setValue(storageClassName);
+ component.storageClassForm.get('zonegroup').setValue('zonegroup1');
+ component.storageClassForm.get('placement_target').setValue('placement1');
+ component.storageClassForm.get('endpoint').setValue('http://ams03.com');
+ component.storageClassForm.get('access_key').setValue('accesskey');
+ component.storageClassForm.get('secret_key').setValue('secretkey');
+ component.storageClassForm.get('target_path').setValue('/target');
+ component.storageClassForm.get('retain_head_object').setValue(true);
+ component.storageClassForm.get('region').setValue('useast1');
+ component.storageClassForm.get('multipart_sync_threshold').setValue(1024);
+ component.storageClassForm.get('multipart_min_part_size').setValue(256);
+ component.goToListView();
+ component.submitAction();
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import _ from 'lodash';
+import { Router } from '@angular/router';
+import { RgwStorageClassService } from '~/app/shared/api/rgw-storage-class.service';
+import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
+import {
+ CLOUD_TIER,
+ DEFAULT_PLACEMENT,
+ RequestModel,
+ Target,
+ ZoneGroup,
+ ZoneGroupDetails
+} from '../models/rgw-storage-class.model';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-rgw-storage-class-form',
+ templateUrl: './rgw-storage-class-form.component.html',
+ styleUrls: ['./rgw-storage-class-form.component.scss']
+})
+export class RgwStorageClassFormComponent extends CdForm implements OnInit {
+ storageClassForm: CdFormGroup;
+ action: string;
+ resource: string;
+ targetPathText: string;
+ targetEndpointText: string;
+ targetRegionText: string;
+ showAdvanced: boolean = false;
+ defaultZoneGroup: string;
+ zonegroupNames: ZoneGroup[];
+ placementTargets: string[] = [];
+ multipartMinPartText: string;
+ multipartSyncThreholdText: string;
+ selectedZoneGroup: string;
+ defaultZonegroup: ZoneGroup;
+ zoneGroupDeatils: ZoneGroupDetails;
+ targetSecretKeyText: string;
+ targetAccessKeyText: string;
+ retainHeadObjectText: string;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private formBuilder: CdFormBuilder,
+ private notificationService: NotificationService,
+ private rgwStorageService: RgwStorageClassService,
+ private rgwZoneGroupService: RgwZonegroupService,
+ private router: Router
+ ) {
+ super();
+ this.resource = $localize`Tiering Storage Class`;
+ }
+
+ ngOnInit() {
+ this.multipartMinPartText =
+ 'It specifies that objects this size or larger are transitioned to the cloud using multipart upload.';
+ this.multipartSyncThreholdText =
+ 'It specifies the minimum part size to use when transitioning objects using multipart upload.';
+ this.targetPathText =
+ 'Target Path refers to the storage location (e.g., bucket or container) in the cloud where data will be stored.';
+ this.targetRegionText = 'The region of the remote cloud service where storage is located.';
+ this.targetEndpointText = 'The URL endpoint of the remote cloud service for accessing storage.';
+ this.targetAccessKeyText =
+ "To view or copy your access key, go to your cloud service's user management or credentials section, find your user profile, and locate the access key. You can view and copy the key by following the instructions provided.";
+
+ this.targetSecretKeyText =
+ "To view or copy your secret key, go to your cloud service's user management or credentials section, find your user profile, and locate the access key. You can view and copy the key by following the instructions provided.";
+ this.retainHeadObjectText =
+ 'Retain object metadata after transition to the cloud (default: deleted).';
+ this.action = this.actionLabels.CREATE;
+ this.createForm();
+ this.loadZoneGroup();
+ }
+
+ createForm() {
+ this.storageClassForm = this.formBuilder.group({
+ storage_class: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ zonegroup: new FormControl(this.selectedZoneGroup, {
+ validators: [Validators.required]
+ }),
+ region: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ placement_target: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ endpoint: new FormControl(null, {
+ validators: [Validators.required]
+ }),
+ access_key: new FormControl(null, Validators.required),
+ secret_key: new FormControl(null, Validators.required),
+ target_path: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ retain_head_object: new FormControl(false),
+ multipart_sync_threshold: new FormControl(33554432),
+ multipart_min_part_size: new FormControl(33554432)
+ });
+ }
+
+ loadZoneGroup(): Promise<void> {
+ return new Promise((resolve, reject) => {
+ this.rgwZoneGroupService.getAllZonegroupsInfo().subscribe(
+ (data: ZoneGroupDetails) => {
+ this.zoneGroupDeatils = data;
+ this.zonegroupNames = [];
+ this.placementTargets = [];
+ if (data.zonegroups && data.zonegroups.length > 0) {
+ this.zonegroupNames = data.zonegroups.map((zoneGroup: ZoneGroup) => {
+ return {
+ id: zoneGroup.id,
+ name: zoneGroup.name
+ };
+ });
+ }
+ this.defaultZonegroup = this.zonegroupNames.find(
+ (zonegroups: ZoneGroup) => zonegroups.id === data.default_zonegroup
+ );
+
+ this.storageClassForm.get('zonegroup').setValue(this.defaultZonegroup.name);
+ this.onZonegroupChange();
+ resolve();
+ },
+ (error) => reject(error)
+ );
+ });
+ }
+
+ onZonegroupChange() {
+ const zoneGroupControl = this.storageClassForm.get('zonegroup').value;
+ const selectedZoneGroup = this.zoneGroupDeatils.zonegroups.find(
+ (zonegroup) => zonegroup.name === zoneGroupControl
+ );
+ const defaultPlacementTarget = selectedZoneGroup.placement_targets.find(
+ (target: Target) => target.name === DEFAULT_PLACEMENT
+ );
+ if (selectedZoneGroup) {
+ const placementTargetNames = selectedZoneGroup.placement_targets.map(
+ (target: Target) => target.name
+ );
+ this.placementTargets = placementTargetNames;
+ }
+ if (defaultPlacementTarget) {
+ this.storageClassForm.get('placement_target').setValue(defaultPlacementTarget.name);
+ }
+ }
+
+ submitAction() {
+ const component = this;
+ const requestModel = this.buildRequest();
+ const storageclassName = this.storageClassForm.get('storage_class').value;
+ this.rgwStorageService.createStorageClass(requestModel).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created Storage Class '${storageclassName}'`
+ );
+ this.goToListView();
+ },
+ () => {
+ component.storageClassForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ goToListView() {
+ this.router.navigate([`rgw/tiering`]);
+ }
+
+ buildRequest() {
+ const rawFormValue = _.cloneDeep(this.storageClassForm.value);
+ const requestModel: RequestModel = {
+ zone_group: rawFormValue.zonegroup,
+ placement_targets: [
+ {
+ tags: [],
+ placement_id: rawFormValue.placement_target,
+ storage_class: rawFormValue.storage_class,
+ tier_type: CLOUD_TIER,
+ tier_config: {
+ endpoint: rawFormValue.endpoint,
+ access_key: rawFormValue.access_key,
+ secret: rawFormValue.secret_key,
+ target_path: rawFormValue.target_path,
+ retain_head_object: rawFormValue.retain_head_object,
+ region: rawFormValue.region,
+ multipart_sync_threshold: rawFormValue.multipart_sync_threshold,
+ multipart_min_part_size: rawFormValue.multipart_min_part_size
+ }
+ }
+ ]
+ };
+ return requestModel;
+ }
+}
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
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 { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { FinishedTask } from '~/app/shared/models/finished-task';
import { RgwStorageClassService } from '~/app/shared/api/rgw-storage-class.service';
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 { Router } from '@angular/router';
+
+const BASE_URL = 'rgw/tiering';
+
@Component({
selector: 'cd-rgw-storage-class-list',
templateUrl: './rgw-storage-class-list.component.html',
- styleUrls: ['./rgw-storage-class-list.component.scss']
+ styleUrls: ['./rgw-storage-class-list.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
})
export class RgwStorageClassListComponent extends ListWithDetails implements OnInit {
columns: CdTableColumn[];
private cdsModalService: ModalCdsService,
private taskWrapper: TaskWrapperService,
private authStorageService: AuthStorageService,
- private rgwStorageClassService: RgwStorageClassService
+ private rgwStorageClassService: RgwStorageClassService,
+ private router: Router,
+ private urlBuilder: URLBuilderService
) {
super();
this.permission = this.authStorageService.getPermissions().rgw;
}
];
this.tableActions = [
+ {
+ name: this.actionLabels.CREATE,
+ permission: 'create',
+ icon: Icons.add,
+ click: () => this.router.navigate([this.urlBuilder.getCreate()]),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
{
name: this.actionLabels.REMOVE,
permission: 'delete',
import { RgwMultisiteSyncPipeModalComponent } from './rgw-multisite-sync-pipe-modal/rgw-multisite-sync-pipe-modal.component';
import { RgwMultisiteTabsComponent } from './rgw-multisite-tabs/rgw-multisite-tabs.component';
import { RgwStorageClassListComponent } from './rgw-storage-class-list/rgw-storage-class-list.component';
+
import {
ButtonModule,
GridModule,
TreeviewModule,
SelectModule,
NumberModule,
- TabsModule
+ TabsModule,
+ AccordionModule
} from 'carbon-components-angular';
import { CephSharedModule } from '../shared/ceph-shared.module';
import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.component';
import { RgwUserAccountsFormComponent } from './rgw-user-accounts-form/rgw-user-accounts-form.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';
@NgModule({
imports: [
IconModule,
NgbProgressbar,
InputModule,
+ AccordionModule,
CheckboxModule,
SelectModule,
NumberModule,
- TabsModule
+ TabsModule,
+ IconModule,
+ SelectModule
],
exports: [
RgwDaemonListComponent,
RgwUserAccountsFormComponent,
RgwUserAccountsDetailsComponent,
RgwStorageClassListComponent,
- RgwStorageClassDetailsComponent
+ RgwStorageClassDetailsComponent,
+ RgwStorageClassFormComponent
],
providers: [TitleCasePipe]
})
{
path: 'tiering',
data: { breadcrumbs: 'Tiering' },
- children: [{ path: '', component: RgwStorageClassListComponent }]
+ children: [
+ { path: '', component: RgwStorageClassListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: RgwStorageClassFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ }
+ ]
},
{
path: 'nfs',
import { RgwStorageClassService } from './rgw-storage-class.service';
import { configureTestBed } from '~/testing/unit-test-helper';
+import { RequestModel } from '~/app/ceph/rgw/models/rgw-storage-class.model';
+
describe('RgwStorageClassService', () => {
let service: RgwStorageClassService;
let httpTesting: HttpTestingController;
);
expect(req.request.method).toBe('DELETE');
});
+
+ it('should call create', () => {
+ const request: RequestModel = {
+ zone_group: 'default',
+ placement_targets: [
+ {
+ tags: [],
+ placement_id: 'default-placement',
+ storage_class: 'test1',
+ tier_type: 'cloud-s3',
+ tier_config: {
+ endpoint: 'http://198.162.100.100:80',
+ access_key: 'test56',
+ secret: 'test56',
+ target_path: 'tsest-dnyanee',
+ retain_head_object: false,
+ region: 'ams3d',
+ multipart_sync_threshold: 33554432,
+ multipart_min_part_size: 33554432
+ }
+ }
+ ]
+ };
+ service.createStorageClass(request).subscribe();
+ const req = httpTesting.expectOne('api/rgw/zonegroup/storage-class');
+ expect(req.request.method).toBe('POST');
+ });
});
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
+import { RequestModel } from '~/app/ceph/rgw/models/rgw-storage-class.model';
+
@Injectable({
providedIn: 'root'
})
export class RgwStorageClassService {
- private url = 'api/rgw/zonegroup';
+ private url = 'api/rgw/zonegroup/storage-class';
constructor(private http: HttpClient) {}
removeStorageClass(placement_target: string, storage_class: string) {
- return this.http.delete(`${this.url}/storage-class/${placement_target}/${storage_class}`, {
+ return this.http.delete(`${this.url}/${placement_target}/${storage_class}`, {
observe: 'response'
});
}
+
+ createStorageClass(requestModel: RequestModel) {
+ return this.http.post(`${this.url}`, requestModel);
+ }
}
import { TrimDirective } from './trim.directive';
import { RequiredFieldDirective } from './required-field.directive';
import { ReactiveFormsModule } from '@angular/forms';
+import { OptionalFieldDirective } from './optional-field.directive';
@NgModule({
imports: [ReactiveFormsModule],
CdFormGroupDirective,
CdFormValidationDirective,
AuthStorageDirective,
- RequiredFieldDirective
+ RequiredFieldDirective,
+ OptionalFieldDirective
],
exports: [
AutofocusDirective,
CdFormGroupDirective,
CdFormValidationDirective,
AuthStorageDirective,
- RequiredFieldDirective
+ RequiredFieldDirective,
+ OptionalFieldDirective
]
})
export class DirectivesModule {}
--- /dev/null
+import { ElementRef } from '@angular/core';
+import { OptionalFieldDirective } from './optional-field.directive';
+
+describe('OptionalFieldDirective', () => {
+ it('should create an instance', () => {
+ const directive = new OptionalFieldDirective(new ElementRef(''), null);
+ expect(directive).toBeTruthy();
+ });
+});
--- /dev/null
+import { AfterViewInit, Directive, ElementRef, Input, Renderer2 } from '@angular/core';
+
+@Directive({
+ selector: '[cdOptionalField]'
+})
+export class OptionalFieldDirective implements AfterViewInit {
+ @Input('cdOptionalField') label: string;
+ @Input() skeleton: boolean;
+ constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
+
+ ngAfterViewInit() {
+ if (!this.label || this.skeleton) return;
+ const labelElement = this.elementRef.nativeElement.querySelector('.cds--label');
+
+ if (labelElement) {
+ this.renderer.setProperty(labelElement, 'textContent', `${this.label} (optional)`);
+ }
+ }
+}
- jwt: []
tags:
- RgwZonegroup
+ /api/rgw/zonegroup/storage-class:
+ post:
+ parameters: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ placement_targets:
+ default: []
+ type: string
+ zone_group:
+ type: string
+ required:
+ - zone_group
+ type: object
+ responses:
+ '201':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: Resource created.
+ '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:
+ - RgwZonegroup
/api/rgw/zonegroup/storage-class/{placement_id}/{storage_class}:
delete:
parameters:
raise DashboardException(error, http_status_code=500, component='rgw')
return out
- # If realm list is empty restart RGW daemons else update the period.
- def handle_rgw_realm(self):
+ # If realm list is empty, restart RGW daemons. Otherwise, update the period.
+ def ensure_realm_and_sync_period(self):
rgw_realm_list = self.list_realms()
if len(rgw_realm_list['realms']) < 1:
rgw_service_manager = RgwServiceManager()
- rgw_service_manager.restart_rgw_daemons_and_set_credentials()
+ rgw_service_manager.restart_rgw_daemons()
else:
self.update_period()
def add_placement_targets(self, zonegroup_name: str, placement_targets: List[Dict]):
rgw_add_placement_cmd = ['zonegroup', 'placement', 'add']
- for placement_target in placement_targets:
- cmd_add_placement_options = ['--rgw-zonegroup', zonegroup_name,
- '--placement-id', placement_target['placement_id']]
- if placement_target['tags']:
+ STANDARD_STORAGE_CLASS = "STANDARD"
+ CLOUD_S3_TIER_TYPE = "cloud-s3"
+
+ for placement_target in placement_targets: # pylint: disable=R1702
+ cmd_add_placement_options = [
+ '--rgw-zonegroup', zonegroup_name,
+ '--placement-id', placement_target['placement_id']
+ ]
+ storage_class_name = placement_target.get('storage_class', None)
+
+ if (
+ placement_target.get('tier_type') == CLOUD_S3_TIER_TYPE
+ and storage_class_name != STANDARD_STORAGE_CLASS
+ ):
+ tier_config = placement_target.get('tier_config', {})
+ if tier_config:
+ tier_config_items = (
+ f'{key}={value}' for key, value in tier_config.items()
+ )
+ tier_config_str = ','.join(tier_config_items)
+ cmd_add_placement_options += [
+ '--tier-type', 'cloud-s3', '--tier-config', tier_config_str
+ ]
+
+ if placement_target.get('tags') and storage_class_name != STANDARD_STORAGE_CLASS:
cmd_add_placement_options += ['--tags', placement_target['tags']]
+
+ storage_classes = (
+ placement_target['storage_class'].split(",")
+ if placement_target['storage_class']
+ else []
+ )
rgw_add_placement_cmd += cmd_add_placement_options
- try:
- exit_code, _, err = mgr.send_rgwadmin_command(rgw_add_placement_cmd)
- if exit_code > 0:
- raise DashboardException(e=err,
- msg='Unable to add placement target {} to zonegroup {}'.format(placement_target['placement_id'], zonegroup_name), # noqa E501 #pylint: disable=line-too-long
- http_status_code=500, component='rgw')
- except SubprocessError as error:
- raise DashboardException(error, http_status_code=500, component='rgw')
- self.update_period()
- storage_classes = placement_target['storage_class'].split(",") if placement_target['storage_class'] else [] # noqa E501 #pylint: disable=line-too-long
+
+ if not storage_classes:
+ try:
+ exit_code, _, err = mgr.send_rgwadmin_command(rgw_add_placement_cmd)
+ if exit_code > 0:
+ raise DashboardException(
+ e=err,
+ msg=(
+ f'Unable to add placement target '
+ f'{placement_target["placement_id"]} '
+ f'to zonegroup {zonegroup_name}'
+ )
+ )
+ except SubprocessError as error:
+ raise DashboardException(error, http_status_code=500, component='rgw')
+ self.ensure_realm_and_sync_period()
+
if storage_classes:
for sc in storage_classes:
- cmd_add_placement_options = ['--storage-class', sc]
- try:
- exit_code, _, err = mgr.send_rgwadmin_command(
- rgw_add_placement_cmd + cmd_add_placement_options)
- if exit_code > 0:
- raise DashboardException(e=err,
- msg='Unable to add placement target {} to zonegroup {}'.format(placement_target['placement_id'], zonegroup_name), # noqa E501 #pylint: disable=line-too-long
- http_status_code=500, component='rgw')
- except SubprocessError as error:
- raise DashboardException(error, http_status_code=500, component='rgw')
- self.update_period()
+ if sc == storage_class_name:
+ cmd_add_placement_options = ['--storage-class', sc]
+ try:
+ exit_code, _, err = mgr.send_rgwadmin_command(
+ rgw_add_placement_cmd + cmd_add_placement_options
+ )
+ if exit_code > 0:
+ raise DashboardException(
+ e=err,
+ msg=(
+ f'Unable to add placement target '
+ f'{placement_target["placement_id"]} '
+ f'to zonegroup {zonegroup_name}'
+ ),
+ http_status_code=500,
+ component='rgw'
+ )
+ except SubprocessError as error:
+ raise DashboardException(error, http_status_code=500, component='rgw')
+ self.ensure_realm_and_sync_period()
def modify_placement_targets(self, zonegroup_name: str, placement_targets: List[Dict]):
rgw_add_placement_cmd = ['zonegroup', 'placement', 'modify']
except SubprocessError as error:
raise DashboardException(error, http_status_code=500, component='rgw')
- self.handle_rgw_realm()
+ self.ensure_realm_and_sync_period()
# pylint: disable=W0102
def edit_zonegroup(self, realm_name: str, zonegroup_name: str, new_zonegroup_name: str,
return port
def restart_rgw_daemons_and_set_credentials(self):
- # Restart RGW daemons and set credentials.
- logger.info("Restarting RGW daemons and setting credentials")
+ if self.restart_rgw_daemons():
+ logger.info("All daemons are up, configuring RGW credentials")
+ self.configure_rgw_credentials()
+ else:
+ logger.error("Not all daemons are up, skipping RGW credentials configuration")
+
+ def restart_rgw_daemons(self):
+ # Restart RGW daemons
+ logger.info("Restarting RGW daemons")
orch = OrchClient.instance()
services, _ = orch.services.list(service_type='rgw', offset=0)
-
all_daemons_up = True
for service in services:
logger.info("Verifying service restart for: %s", service['service_id'])
daemons_up = verify_service_restart('rgw', service['service_id'])
if not daemons_up:
- logger.error("Service %s restart verification failed", service['service_id'])
all_daemons_up = False
- if all_daemons_up:
- logger.info("All daemons are up, configuring RGW credentials")
- self.configure_rgw_credentials()
- else:
- logger.error("Not all daemons are up, skipping RGW credentials configuration")
+ return all_daemons_up
def _parse_secrets(self, user: str, data: dict) -> Tuple[str, str]:
for key in data.get('keys', []):