From: Dnyaneshwari Date: Fri, 31 Jan 2025 09:35:12 +0000 (+0530) Subject: mgr/dashboard: RGW - Create Storage Class X-Git-Tag: v20.0.0~136^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=0a8ceefdc20ed9876fe137173aebd9fb2086909d;p=ceph.git mgr/dashboard: RGW - Create Storage Class Fixes: https://tracker.ceph.com/issues/69750 Signed-off-by: Dnyaneshwari Talwekar --- diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index b2e225a37b042..ef8903afb082e 100755 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -1189,6 +1189,14 @@ class RgwZonegroup(RESTController): 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): diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-storage-class.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-storage-class.model.ts index 2fd9ede9ec03f..0e671db749944 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-storage-class.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-storage-class.model.ts @@ -1,5 +1,6 @@ export interface ZoneGroupDetails { default_zonegroup: string; + name: string; zonegroups: ZoneGroup[]; } @@ -18,10 +19,6 @@ export interface StorageClassDetails { multipart_sync_threshold: number; host_style: string; } -export interface ZoneGroup { - name: string; - placement_targets: Target[]; -} export interface S3Details { endpoint: string; @@ -49,4 +46,55 @@ export interface Target { 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'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.html new file mode 100644 index 0000000000000..6d38088d62763 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.html @@ -0,0 +1,356 @@ +
+
+
+ {{ action | titlecase }} {{ resource | upperFirst }} +
+ + + All fields are required, except where marked optional. + + +
+
+ + + + + + This field is required. + +
+
+ + + + + + + This field is required. + +
+
+ +
+ Storage Class Name + + + + This field is required. + +
+
+
+ + Target Region + + + + This field is required. + +
+
+ + Target Endpoint + + + + This field is required. + +
+
+ + +
+
+ Target Access Key + + + + + This field is required. + +
+
+ + +
+
+ Target Secret Key + + + + + This field is required. + +
+
+ + +
+ Target Path + + + + This field is required. + +
+ +
+ + + +
+
+ Multipart Sync Threshold + + +
+
+ Multipart Minimum Part Size + + +
+
+
+ Retain Head Object + {{ retainHeadObjectText }} + +
+
+
+ +
Advanced
+
+
+ + RGW service would be restarted after creating the storage class. + + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.scss new file mode 100644 index 0000000000000..f8e869be15dd9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.scss @@ -0,0 +1,5 @@ +@use '@carbon/layout'; + +.clipboard { + margin-top: layout.$spacing-06; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.spec.ts new file mode 100644 index 0000000000000..2f1c43ca6c168 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.spec.ts @@ -0,0 +1,140 @@ +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; + + 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(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.ts new file mode 100644 index 0000000000000..ed4677035f321 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-form/rgw-storage-class-form.component.ts @@ -0,0 +1,202 @@ +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 { + 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; + } +} 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 5ea1dd1b59479..16afef45d3689 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 @@ -3,7 +3,6 @@ import { CdTableAction } from '~/app/shared/models/cd-table-action'; 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, @@ -14,19 +13,26 @@ import { 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[]; @@ -41,7 +47,9 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI 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; @@ -76,6 +84,13 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI } ]; 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', 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 37d4e9c4373a4..6bb87d9ec363c 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 @@ -64,6 +64,7 @@ import { RgwMultisiteSyncFlowModalComponent } from './rgw-multisite-sync-flow-mo 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, @@ -77,13 +78,15 @@ import { 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: [ @@ -111,10 +114,13 @@ import { RgwStorageClassDetailsComponent } from './rgw-storage-class-details/rgw IconModule, NgbProgressbar, InputModule, + AccordionModule, CheckboxModule, SelectModule, NumberModule, - TabsModule + TabsModule, + IconModule, + SelectModule ], exports: [ RgwDaemonListComponent, @@ -171,7 +177,8 @@ import { RgwStorageClassDetailsComponent } from './rgw-storage-class-details/rgw RgwUserAccountsFormComponent, RgwUserAccountsDetailsComponent, RgwStorageClassListComponent, - RgwStorageClassDetailsComponent + RgwStorageClassDetailsComponent, + RgwStorageClassFormComponent ], providers: [TitleCasePipe] }) @@ -325,7 +332,14 @@ const routes: Routes = [ { path: 'tiering', data: { breadcrumbs: 'Tiering' }, - children: [{ path: '', component: RgwStorageClassListComponent }] + children: [ + { path: '', component: RgwStorageClassListComponent }, + { + path: URLVerbs.CREATE, + component: RgwStorageClassFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + } + ] }, { path: 'nfs', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.spec.ts index dc8eef51691c8..ac65bfc424da3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.spec.ts @@ -4,6 +4,8 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ 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; @@ -29,4 +31,31 @@ describe('RgwStorageClassService', () => { ); 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'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.ts index 52d0f7c9326f2..9c3e1fcdbcd58 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-storage-class.service.ts @@ -1,16 +1,22 @@ 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); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts index bae4c03d2c0aa..c797dbdcfec84 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts @@ -17,6 +17,7 @@ import { StatefulTabDirective } from './stateful-tab.directive'; 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], @@ -36,7 +37,8 @@ import { ReactiveFormsModule } from '@angular/forms'; CdFormGroupDirective, CdFormValidationDirective, AuthStorageDirective, - RequiredFieldDirective + RequiredFieldDirective, + OptionalFieldDirective ], exports: [ AutofocusDirective, @@ -54,7 +56,8 @@ import { ReactiveFormsModule } from '@angular/forms'; CdFormGroupDirective, CdFormValidationDirective, AuthStorageDirective, - RequiredFieldDirective + RequiredFieldDirective, + OptionalFieldDirective ] }) export class DirectivesModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/optional-field.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/optional-field.directive.spec.ts new file mode 100644 index 0000000000000..5890944765100 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/optional-field.directive.spec.ts @@ -0,0 +1,9 @@ +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(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/optional-field.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/optional-field.directive.ts new file mode 100644 index 0000000000000..aeb6a39b3456d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/optional-field.directive.ts @@ -0,0 +1,19 @@ +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)`); + } + } +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 2ef61bbfcc1bc..88707c0e49ac8 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -13820,6 +13820,46 @@ paths: - 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: diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index ffedf0111957d..92c23f090e6b4 100755 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -1694,46 +1694,89 @@ class RgwMultisite: 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'] @@ -1787,7 +1830,7 @@ class RgwMultisite: 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, diff --git a/src/pybind/mgr/dashboard/services/service.py b/src/pybind/mgr/dashboard/services/service.py index 9b789c0c85929..679e2919b7df2 100644 --- a/src/pybind/mgr/dashboard/services/service.py +++ b/src/pybind/mgr/dashboard/services/service.py @@ -117,24 +117,25 @@ class RgwServiceManager: 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', []):