From 2a6f6f0b00a0054c1352d93ee8428a6286e52853 Mon Sep 17 00:00:00 2001 From: Dnyaneshwari Date: Fri, 1 Aug 2025 10:00:18 +0530 Subject: [PATCH] mgr/dashboard:RGW- Storage Class ACL Mapping Fixes: https://tracker.ceph.com/issues/72362 Signed-off-by: Dnyaneshwari Talwekar (cherry picked from commit 38b237c8cb12cb77fd29a560c10c7e2225786955) --- .../rgw/models/rgw-storage-class.model.ts | 124 +++++-- .../rgw-storage-class-details.component.html | 72 ++--- ...gw-storage-class-details.component.spec.ts | 52 ++- .../rgw-storage-class-details.component.ts | 59 ++-- .../rgw-storage-class-form.component.html | 160 +++++++-- .../rgw-storage-class-form.component.ts | 304 ++++++++++++++---- .../app/ceph/rgw/utils/rgw-bucket-tiering.ts | 1 + .../api/rgw-storage-class.service.spec.ts | 1 + .../mgr/dashboard/services/rgw_client.py | 7 + 9 files changed, 589 insertions(+), 191 deletions(-) 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 5fd5ffe34b1..7a34f945065 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 @@ -48,6 +48,8 @@ export interface StorageClassDetails { read_through_restore_days?: number; restore_storage_class?: string; retain_head_object?: boolean; + acls?: ACL[]; + acl_mappings?: ACL[]; } export interface ZoneGroup { @@ -56,6 +58,24 @@ export interface ZoneGroup { placement_targets?: Target[]; } +export interface ACL { + key: string; + val: ACLVal; +} + +export interface ACLVal extends AclMapping { + type: string; +} + +export interface AclMapping { + source_id: string; + dest_id: string; +} + +export interface GroupedACLs { + [type: string]: AclMapping[]; +} + export interface S3Details { endpoint: string; access_key: string; @@ -69,6 +89,7 @@ export interface S3Details { host_style: boolean; retain_head_object?: boolean; allow_read_through?: boolean; + acl_mappings?: ACL[]; } export interface S3Glacier { glacier_restore_days: number; @@ -84,6 +105,7 @@ export interface PlacementTarget { placement_id: string; tags?: string[]; tier_type?: TIER_TYPE; + tier_config_rm: TierConfigRm; tier_config?: { endpoint: string; access_key: string; @@ -99,13 +121,18 @@ export interface PlacementTarget { restore_storage_class?: string; read_through_restore_days?: number; target_storage_class?: string; + acls?: ACL[]; }; storage_class?: string; name?: string; tier_targets?: TierTarget[]; } -export interface StorageClassOption { +export interface TierConfigRm { + [key: string]: string; +} + +export interface TypeOption { value: string; label: string; } @@ -171,35 +198,27 @@ export const TIER_TYPE_DISPLAY = { GLACIER: 'Cloud S3 Glacier' }; -export const GLACIER_TARGET_STORAGE_CLASS = 'GLACIER'; +export const GLACIER_TARGET_STORAGE_CLASS = $localize`GLACIER`; -export const ALLOW_READ_THROUGH_TEXT = - 'Enables fetching objects from remote cloud S3 if not found locally.'; +export const ALLOW_READ_THROUGH_TEXT = $localize`Enables fetching objects from remote cloud S3 if not found locally.`; -export const MULTIPART_MIN_PART_TEXT = - 'It specifies that objects this size or larger are transitioned to the cloud using multipart upload.'; +export const MULTIPART_MIN_PART_TEXT = $localize`It specifies that objects this size or larger are transitioned to the cloud using multipart upload.`; -export const MULTIPART_SYNC_THRESHOLD_TEXT = - 'It specifies the minimum part size to use when transitioning objects using multipart upload.'; +export const MULTIPART_SYNC_THRESHOLD_TEXT = $localize`It specifies the minimum part size to use when transitioning objects using multipart upload.`; -export const TARGET_PATH_TEXT = - 'Target Path refers to the storage location (e.g., bucket or container) in the cloud where data will be stored.'; +export const TARGET_PATH_TEXT = $localize`Target Path refers to the storage location (e.g., bucket or container) in the cloud where data will be stored.`; -export const TARGET_REGION_TEXT = - 'The region of the remote cloud service where storage is located.'; +export const TARGET_REGION_TEXT = $localize`The region of the remote cloud service where storage is located.`; -export const TARGET_ENDPOINT_TEXT = - 'The URL endpoint of the remote cloud service for accessing storage.'; +export const TARGET_ENDPOINT_TEXT = $localize`The URL endpoint of the remote cloud service for accessing storage.`; -export const TARGET_ACCESS_KEY_TEXT = - "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."; +export const TARGET_ACCESS_KEY_TEXT = $localize`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.`; -export const TARGET_SECRET_KEY_TEXT = - "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 secret key. You can view and copy the key by following the instructions provided."; +export const TARGET_SECRET_KEY_TEXT = $localize`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 secret key. You can view and copy the key by following the instructions provided.`; -export const RETAIN_HEAD_OBJECT_TEXT = 'Retain object metadata after transition to the cloud.'; +export const RETAIN_HEAD_OBJECT_TEXT = $localize`Retain object metadata after transition to the cloud.`; -export const HOST_STYLE = `The URL format for accessing the remote S3 endpoint: +export const HOST_STYLE = $localize`The URL format for accessing the remote S3 endpoint: - 'Path': Use for a path-based URL - 'Virtual': Use for a domain-based URL`; @@ -226,3 +245,68 @@ export const RESTORE_STORAGE_CLASS_TEXT = $localize`The storage class to which o export const ZONEGROUP_TEXT = $localize`A Zone Group is a logical grouping of one or more zones that share the same data and metadata, allowing for multi-site replication and geographic distribution of data.`; + +export type AclType = 'id' | 'email' | 'uri'; + +export interface AclLabelAndHelper { + source: string; + destination: string; +} + +export interface AclMaps { + [key: string]: AclLabelAndHelper & { + [field: string]: string; + }; +} + +export enum AclLabel { + source = 'Source', + destination = 'Destination' +} + +export enum AclFieldType { + Source = 'source', + Destination = 'destination' +} + +export const AclTypeOptions = [ + { value: 'id', label: 'ID' }, + { value: 'email', label: 'Email' }, + { value: 'uri', label: 'URI' } +] as const; + +export const AclTypeConst = { + ID: 'id', + EMAIL: 'email', + URI: 'uri' +} as const; + +export const AclTypeLabel: AclMaps = { + id: { + source: $localize`Source User`, + destination: $localize`Destination User` + }, + email: { + source: $localize`Source Email`, + destination: $localize`Destination Email` + }, + uri: { + source: $localize`Source URI`, + destination: $localize`Destination URI` + } +}; + +export const AclHelperText: AclMaps = { + id: { + source: $localize`The unique user ID in the source system.`, + destination: $localize`The unique user ID in the destination system.` + }, + email: { + source: $localize`The email address of the source user.`, + destination: $localize`The email address of the destination user.` + }, + uri: { + source: $localize`The URI identifying the source group or user.`, + destination: $localize`The URI identifying the destination group or user.` + } +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.html index dc8886ef5aa..701ae11f27f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.html @@ -7,9 +7,7 @@ data-testid="rgw-storage-details" > - @if( isTierMatch( - TIER_TYPE_DISPLAY.LOCAL - )){ + @if( isTierMatch( TIER_TYPE_DISPLAY.LOCAL )){ @@ -22,10 +20,7 @@ {{ selection?.zonegroup_name }} - } - @if(isTierMatch( - TIER_TYPE_DISPLAY.LOCAL - )){ + } @if(isTierMatch( TIER_TYPE_DISPLAY.LOCAL )){ @@ -39,8 +34,7 @@ {{ selection?.placement_target }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ + } @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -53,8 +47,6 @@ {{ selection?.target_path }} - } - @if(isTierMatch(TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -83,8 +75,6 @@ - } - @if(isTierMatch(TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -111,8 +101,6 @@ - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -123,8 +111,6 @@ {{ selection?.host_style }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -135,8 +121,6 @@ {{ selection?.retain_head_object ? 'Enabled' : 'Disabled' }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -149,9 +133,9 @@ {{ selection?.allow_read_through ? 'Enabled' : 'Disabled' }} - } - @if(isTierMatch(TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER) && (selection?.allow_read_through)) { - + } @if(isTierMatch(TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER) && + (selection?.allow_read_through)) { + Read through Restore Days @@ -161,10 +145,12 @@ - {{ selection?.read_through_restore_days }} + + {{ selection?.read_through_restore_days }} + {{ selection?.read_through_restore_days === 1 ? 'Day' : 'Days' }} + - } - @if(isTierMatch( TIER_TYPE_DISPLAY.GLACIER)){ + } @if(isTierMatch( TIER_TYPE_DISPLAY.GLACIER)){ @@ -175,10 +161,9 @@ - {{ selection?.glacier_restore_days }} + {{ selection?.glacier_restore_days }} + {{ selection?.glacier_restore_days === 1 ? 'Day' : 'Days' }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.GLACIER)) { @@ -191,8 +176,7 @@ {{ selection?.glacier_restore_tier_type }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ + } @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -205,8 +189,6 @@ {{ selection?.restore_storage_class }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ @@ -219,21 +201,39 @@ {{ selection?.multipart_min_part_size | dimlessBinary }} - } - @if(isTierMatch( TIER_TYPE_DISPLAY.CLOUD_TIER, TIER_TYPE_DISPLAY.GLACIER)){ Multipart Sync Threshold - {{ multipartSyncThreholdText }} + {{ multipartSyncThreholdText }} {{ selection?.multipart_sync_threshold | dimlessBinary }} - } + } @if(selection?.acl_mappings.length > 0) { + + ACLs + + +
+
{{ type.key }}:
+
+ + {{ item.source_id }} : {{ item.dest_id }} + +
+
+
+ + + } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.spec.ts index 6275c62d485..1321a72c035 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.spec.ts @@ -1,16 +1,29 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { RgwStorageClassDetailsComponent } from './rgw-storage-class-details.component'; import { StorageClassDetails } from '../models/rgw-storage-class.model'; 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 { SimpleChange } from '@angular/core'; describe('RgwStorageClassDetailsComponent', () => { let component: RgwStorageClassDetailsComponent; let fixture: ComponentFixture; + const mockSelection: StorageClassDetails = { + access_key: 'TestAccessKey', + secret: 'TestSecret', + target_path: '/test/path', + multipart_min_part_size: 100, + multipart_sync_threshold: 200, + host_style: 'path', + retain_head_object: true, + allow_read_through: true, + tier_type: 'local', + acl_mappings: [] + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ @@ -24,6 +37,8 @@ describe('RgwStorageClassDetailsComponent', () => { fixture = TestBed.createComponent(RgwStorageClassDetailsComponent); component = fixture.componentInstance; + component.selection = mockSelection; + fixture.detectChanges(); }); @@ -32,19 +47,28 @@ describe('RgwStorageClassDetailsComponent', () => { }); it('should update storageDetails when selection input changes', () => { - const mockSelection: StorageClassDetails = { - access_key: 'TestAccessKey', - secret: 'TestSecret', - target_path: '/test/path', - multipart_min_part_size: 100, - multipart_sync_threshold: 200, - host_style: 'path', - retain_head_object: true, - allow_read_through: true, - tier_type: 'local' + const newSelection: StorageClassDetails = { + access_key: 'NewAccessKey', + secret: 'NewSecret', + target_path: '/new/path', + multipart_min_part_size: 500, + multipart_sync_threshold: 1000, + host_style: 'virtual', + retain_head_object: false, + allow_read_through: false, + tier_type: 'archive', + glacier_restore_days: 1, + glacier_restore_tier_type: 'standard', + placement_targets: '', + read_through_restore_days: 7, + restore_storage_class: 'restored', + zonegroup_name: 'zone1' }; - component.selection = mockSelection; - component.ngOnChanges(); - expect(component.storageDetails).toEqual(mockSelection); + + component.selection = newSelection; + component.ngOnChanges({ + selection: new SimpleChange(null, newSelection, false) + }); + expect(component.storageDetails).toEqual(newSelection); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.ts index 40049a69b95..69c8f9dbfe8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-details/rgw-storage-class-details.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnChanges } from '@angular/core'; +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { ALLOW_READ_THROUGH_TEXT, @@ -17,14 +17,16 @@ import { RESTORE_DAYS_TEXT, READTHROUGH_RESTORE_DAYS_TEXT, RESTORE_STORAGE_CLASS_TEXT, - ZONEGROUP_TEXT + ZONEGROUP_TEXT, + ACL, + GroupedACLs } from '../models/rgw-storage-class.model'; @Component({ selector: 'cd-rgw-storage-class-details', templateUrl: './rgw-storage-class-details.component.html', styleUrls: ['./rgw-storage-class-details.component.scss'] }) -export class RgwStorageClassDetailsComponent implements OnChanges { +export class RgwStorageClassDetailsComponent implements OnChanges, OnInit { @Input() selection: StorageClassDetails; columns: CdTableColumn[] = []; @@ -45,31 +47,48 @@ export class RgwStorageClassDetailsComponent implements OnChanges { readthroughrestoreDaysText = READTHROUGH_RESTORE_DAYS_TEXT; restoreStorageClassText = RESTORE_STORAGE_CLASS_TEXT; zoneGroupText = ZONEGROUP_TEXT; + groupedACLs: GroupedACLs = {}; - ngOnChanges() { - if (this.selection) { + ngOnChanges(changes: SimpleChanges): void { + if (changes['selection']) { this.storageDetails = { - zonegroup_name: this.selection.zonegroup_name, - placement_targets: this.selection.placement_targets, - access_key: this.selection.access_key, - secret: this.selection.secret, - target_path: this.selection.target_path, - tier_type: this.selection.tier_type, - multipart_min_part_size: this.selection.multipart_min_part_size, - multipart_sync_threshold: this.selection.multipart_sync_threshold, - host_style: this.selection.host_style, - retain_head_object: this.selection.retain_head_object, - allow_read_through: this.selection.allow_read_through, - glacier_restore_days: this.selection.glacier_restore_days, - glacier_restore_tier_type: this.selection.glacier_restore_tier_type, - restore_storage_class: this.selection.restore_storage_class, - read_through_restore_days: this.selection.read_through_restore_days + zonegroup_name: this.selection?.zonegroup_name, + placement_targets: this.selection?.placement_targets, + access_key: this.selection?.access_key, + secret: this.selection?.secret, + target_path: this.selection?.target_path, + tier_type: this.selection?.tier_type, + multipart_min_part_size: this.selection?.multipart_min_part_size, + multipart_sync_threshold: this.selection?.multipart_sync_threshold, + host_style: this.selection?.host_style, + retain_head_object: this.selection?.retain_head_object, + allow_read_through: this.selection?.allow_read_through, + glacier_restore_days: this.selection?.glacier_restore_days, + glacier_restore_tier_type: this.selection?.glacier_restore_tier_type, + restore_storage_class: this.selection?.restore_storage_class, + read_through_restore_days: this.selection?.read_through_restore_days }; } } + ngOnInit() { + this.groupedACLs = this.groupByType(this.selection.acl_mappings); + } + isTierMatch(...types: string[]): boolean { const tier_type = this.selection.tier_type?.toLowerCase(); return types.some((type) => type.toLowerCase() === tier_type); } + + groupByType(acls: ACL[]): GroupedACLs { + return acls?.reduce((groupAcls: GroupedACLs, item: ACL) => { + const type = item.val?.type?.toUpperCase(); + groupAcls[type] = groupAcls[type] ?? []; + groupAcls[type].push({ + source_id: item.val?.source_id, + dest_id: item.val?.dest_id + }); + return groupAcls; + }, {}); + } } 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 index 57076071a5c..138bc4131ec 100644 --- 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 @@ -80,7 +80,7 @@ [invalid]="storageClassForm.showError('placement_target', formDir, 'required')" [invalidText]="placementError" > - @@ -374,7 +362,7 @@ [helperText]="helpTextLabels.tiertypeText" i18n-label > - @@ -474,6 +462,114 @@ + +
+ ACLs Mapping + + @for (acl of acls.controls; let i = $index; track acl) { + +
+
+ + @for (type of typeOptions; track type.value) { + + } + +
+
+
+
+ + {{ getAclLabel('source', acl.get('type')?.value) }} + + +
+
+ + {{ getAclLabel('destination', acl.get('type')?.value) }} + + +
+ +
+ + + +
+
+ + + +
+
+
+ } +
+
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 index 4a1c7589ca5..4a81ef203c3 100644 --- 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 @@ -1,5 +1,11 @@ -import { Component, OnInit } from '@angular/core'; -import { AbstractControl, FormControl, Validators } from '@angular/forms'; +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormControl, + ValidationErrors, + Validators +} from '@angular/forms'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdForm } from '~/app/shared/forms/cd-form'; import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; @@ -8,6 +14,7 @@ import _ from 'lodash'; import { ActivatedRoute, Router } from '@angular/router'; import { RgwStorageClassService } from '~/app/shared/api/rgw-storage-class.service'; import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service'; + import { ALLOW_READ_THROUGH_TEXT, DEFAULT_PLACEMENT, @@ -37,19 +44,30 @@ import { RESTORE_STORAGE_CLASS_TEXT, TIER_TYPE_DISPLAY, S3Glacier, - StorageClassOption, + TypeOption, STORAGE_CLASS_CONSTANTS, STANDARD_TIER_TYPE_TEXT, EXPEDITED_TIER_TYPE_TEXT, TextLabels, CLOUD_TIER_REQUIRED_FIELDS, GLACIER_REQUIRED_FIELDS, - GLACIER_TARGET_STORAGE_CLASS + GLACIER_TARGET_STORAGE_CLASS, + AclHelperText, + AclTypeLabel, + AclFieldType, + TierConfigRm, + ACL, + AclTypeOptions, + AclTypeConst, + ACLVal, + AclLabel, + AclType } from '../models/rgw-storage-class.model'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { NotificationService } from '~/app/shared/services/notification.service'; import { CdValidators } from '~/app/shared/forms/cd-validators'; import { FormatterService } from '~/app/shared/services/formatter.service'; +import validator from 'validator'; @Component({ selector: 'cd-rgw-storage-class-form', @@ -74,9 +92,17 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { allowReadThrough: boolean = false; TIER_TYPE = TIER_TYPE; TIER_TYPE_DISPLAY = TIER_TYPE_DISPLAY; - storageClassOptions: StorageClassOption[]; + storageClassOptions: TypeOption[]; helpTextLabels: TextLabels; - + typeOptions: TypeOption[]; + aclTypeLabel = AclTypeLabel; + aclHelperText = AclHelperText; + aclList: ACL[] = []; + removedAclSourceIds: string[] = []; + urlValidator = (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + return !value || validator.isURL(value) ? null : { invalidUrl: true }; + }; constructor( public actionLabels: ActionLabelsI18n, private formBuilder: CdFormBuilder, @@ -85,7 +111,8 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { private rgwZoneGroupService: RgwZonegroupService, private router: Router, private route: ActivatedRoute, - public formatter: FormatterService + public formatter: FormatterService, + private cdRef: ChangeDetectorRef ) { super(); this.resource = $localize`Tiering Storage Class`; @@ -117,6 +144,7 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { { value: TIER_TYPE.CLOUD_TIER, label: TIER_TYPE_DISPLAY.CLOUD_TIER }, { value: TIER_TYPE.GLACIER, label: TIER_TYPE_DISPLAY.GLACIER } ]; + this.typeOptions = [...AclTypeOptions]; this.createForm(); this.storageClassTypeText(); this.updateTierTypeHelpText(); @@ -134,6 +162,7 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { this.storageClassInfo.storage_class ); let response = this.tierTargetInfo?.val?.s3; + const aclMappings = this.tierTargetInfo?.val?.s3?.acl_mappings || []; this.storageClassForm.get('zonegroup').disable(); this.storageClassForm.get('placement_target').disable(); this.storageClassForm.get('storage_class').disable(); @@ -143,6 +172,7 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { ) { this.storageClassForm.get('storageClassType').disable(); } + this.aclList = this.tierTargetInfo?.val?.s3?.acl_mappings || []; this.storageClassForm.patchValue({ zonegroup: this.storageClassInfo?.zonegroup_name, region: response?.region, @@ -158,8 +188,23 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { multipart_min_part_size: response?.multipart_min_part_size || '', allow_read_through: this.tierTargetInfo?.val?.allow_read_through || false, restore_storage_class: this.tierTargetInfo?.val?.restore_storage_class, - read_through_restore_days: this.tierTargetInfo?.val?.read_through_restore_days + read_through_restore_days: this.tierTargetInfo?.val?.read_through_restore_days, + acl_mappings: this.tierTargetInfo?.val?.s3?.acl_mappings || [] }); + this.acls?.clear(); + if (aclMappings.length > 0) { + aclMappings.forEach((acl) => { + this.acls?.push( + this.formBuilder.group({ + source_id: [acl.val?.source_id || ''], + dest_id: [acl.val?.dest_id || ''], + type: [acl.val?.type || AclTypeConst.ID, Validators.required] + }) + ); + }); + } else { + this.addAcls(); + } if (this.tierTargetInfo?.val?.tier_type == TIER_TYPE.GLACIER) { let glacierResponse = this.tierTargetInfo?.val['s3-glacier']; this.storageClassForm.patchValue({ @@ -177,57 +222,6 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { }); } - private updateValidatorsBasedOnStorageClass(value: string) { - GLACIER_REQUIRED_FIELDS.forEach((field) => { - const control = this.storageClassForm.get(field); - - if ( - (value === TIER_TYPE.CLOUD_TIER && CLOUD_TIER_REQUIRED_FIELDS.includes(field)) || - (value === TIER_TYPE.GLACIER && GLACIER_REQUIRED_FIELDS.includes(field)) - ) { - control.setValidators([Validators.required]); - } else { - control.clearValidators(); - } - control.updateValueAndValidity(); - }); - - if (this.editing) { - const defaultValues = { - allow_read_through: false, - read_through_restore_days: STORAGE_CLASS_CONSTANTS.DEFAULT_READTHROUGH_RESTORE_DAYS, - restore_storage_class: STORAGE_CLASS_CONSTANTS.DEFAULT_STORAGE_CLASS, - multipart_min_part_size: STORAGE_CLASS_CONSTANTS.DEFAULT_MULTIPART_MIN_PART_SIZE, - multipart_sync_threshold: STORAGE_CLASS_CONSTANTS.DEFAULT_MULTIPART_SYNC_THRESHOLD - }; - Object.keys(defaultValues).forEach((key) => { - this.storageClassForm.get(key).setValue(defaultValues[key]); - }); - } - } - - storageClassTypeText() { - this.storageClassForm?.get('storageClassType')?.valueChanges.subscribe((value) => { - if (value === TIER_TYPE.LOCAL) { - this.helpTextLabels.storageClassText = LOCAL_STORAGE_CLASS_TEXT; - } else if (value === TIER_TYPE.CLOUD_TIER) { - this.helpTextLabels.storageClassText = CLOUDS3_STORAGE_CLASS_TEXT; - } else if (value === TIER_TYPE.GLACIER) { - this.helpTextLabels.storageClassText = GLACIER_STORAGE_CLASS_TEXT; - } - }); - } - - updateTierTypeHelpText() { - this.storageClassForm?.get('glacier_restore_tier_type')?.valueChanges.subscribe((value) => { - if (value === STORAGE_CLASS_CONSTANTS.DEFAULT_STORAGE_CLASS) { - this.helpTextLabels.tiertypeText = STANDARD_TIER_TYPE_TEXT; - } else { - this.helpTextLabels.tiertypeText = EXPEDITED_TIER_TYPE_TEXT; - } - }); - } - createForm() { const self = this; @@ -252,9 +246,6 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { placement_target: new FormControl('', { validators: [Validators.required] }), - target_endpoint: new FormControl(null, { - validators: [CdValidators.url, Validators.required] - }), access_key: new FormControl(null, [ CdValidators.composeIf({ storageClassType: TIER_TYPE.CLOUD_TIER }, [Validators.required]) ]), @@ -268,6 +259,7 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { glacier_restore_tier_type: new FormControl(STORAGE_CLASS_CONSTANTS.DEFAULT_STORAGE_CLASS, [ CdValidators.composeIf({ storageClassType: TIER_TYPE.GLACIER }, [Validators.required]) ]), + target_endpoint: new FormControl('', [Validators.required, this.urlValidator]), glacier_restore_days: new FormControl(STORAGE_CLASS_CONSTANTS.DEFAULT_GLACIER_RESTORE_DAYS, [ CdValidators.composeIf({ storageClassType: TIER_TYPE.GLACIER || TIER_TYPE.CLOUD_TIER }, [ CdValidators.number(false), @@ -295,7 +287,143 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { STORAGE_CLASS_CONSTANTS.DEFAULT_MULTIPART_MIN_PART_SIZE ), allow_read_through: new FormControl(false), - storageClassType: new FormControl(TIER_TYPE.LOCAL, Validators.required) + storageClassType: new FormControl(TIER_TYPE.LOCAL, Validators.required), + acls: new FormArray([this.createAcls()]) + }); + } + + public createAcls(): CdFormGroup { + const group = this.formBuilder.group({ + type: new FormControl(AclTypeConst.ID, Validators.required), + source_id: new FormControl(''), + dest_id: new FormControl('') + }); + + const sourceId = group.get('source_id'); + const destId = group.get('dest_id'); + + const validators = this.getValidatorsType(AclTypeConst.ID); + + sourceId.setValidators(validators); + destId.setValidators(validators); + + sourceId.updateValueAndValidity(); + destId.updateValueAndValidity(); + + group.get('type')?.valueChanges.subscribe((newType: AclType) => { + const sourceId = group.get('source_id'); + const destId = group.get('dest_id'); + + const validators = this.getValidatorsType(newType); + + sourceId.setValidators(validators); + destId.setValidators(validators); + + sourceId.updateValueAndValidity(); + destId.updateValueAndValidity(); + }); + + return group; + } + + private getValidatorsType(type: AclType) { + switch (type) { + case AclTypeConst.EMAIL: + return [Validators.email]; + case AclTypeConst.URI: + return [this.urlValidator]; + case AclTypeConst.ID: + default: + return [Validators.required]; + } + } + + get acls(): FormArray { + return this.storageClassForm.get('acls') as FormArray; + } + + private updateValidatorsBasedOnStorageClass(value: string) { + GLACIER_REQUIRED_FIELDS.forEach((field) => { + const control = this.storageClassForm.get(field); + + if ( + (value === TIER_TYPE.CLOUD_TIER && CLOUD_TIER_REQUIRED_FIELDS.includes(field)) || + (value === TIER_TYPE.GLACIER && GLACIER_REQUIRED_FIELDS.includes(field)) + ) { + control.setValidators([Validators.required]); + } else { + control.clearValidators(); + } + control.updateValueAndValidity(); + }); + + if (this.editing) { + const defaultValues = { + allow_read_through: false, + read_through_restore_days: STORAGE_CLASS_CONSTANTS.DEFAULT_READTHROUGH_RESTORE_DAYS, + restore_storage_class: STORAGE_CLASS_CONSTANTS.DEFAULT_STORAGE_CLASS, + multipart_min_part_size: STORAGE_CLASS_CONSTANTS.DEFAULT_MULTIPART_MIN_PART_SIZE, + multipart_sync_threshold: STORAGE_CLASS_CONSTANTS.DEFAULT_MULTIPART_SYNC_THRESHOLD + }; + Object.keys(defaultValues).forEach((key) => { + this.storageClassForm.get(key).setValue(defaultValues[key]); + }); + } + } + + addAcls() { + this.acls.push(this.createAcls()); + } + + removeAcl(index: number) { + if (this.acls.length > 1) { + this.acls.removeAt(index); + } else { + const removedAcl = this.acls.at(0).value; + + if (removedAcl?.source_id) { + this.removedAclSourceIds.push(removedAcl.source_id); + } + const newGroup = this.createAcls(); + this.acls.setControl(0, newGroup); + } + + this.cdRef.detectChanges(); + } + + getAclLabel(field: AclFieldType, type?: string): string { + if (!type) { + return field === AclFieldType.Source ? AclLabel.source : AclLabel.destination; + } + return ( + this.aclTypeLabel[type]?.[field] || + (field === AclFieldType.Source ? AclLabel.source : AclLabel.destination) + ); + } + + getAclHelperText(type: string, field: AclFieldType): string { + return this.aclHelperText[type]?.[field] || ''; + } + + storageClassTypeText() { + this.storageClassForm?.get('storageClassType')?.valueChanges.subscribe((value) => { + if (value === TIER_TYPE.LOCAL) { + this.helpTextLabels.storageClassText = LOCAL_STORAGE_CLASS_TEXT; + } else if (value === TIER_TYPE.CLOUD_TIER) { + this.helpTextLabels.storageClassText = CLOUDS3_STORAGE_CLASS_TEXT; + } else if (value === TIER_TYPE.GLACIER) { + this.helpTextLabels.storageClassText = GLACIER_STORAGE_CLASS_TEXT; + } + }); + } + + updateTierTypeHelpText() { + this.storageClassForm?.get('glacier_restore_tier_type')?.valueChanges.subscribe((value) => { + if (value === STORAGE_CLASS_CONSTANTS.DEFAULT_STORAGE_CLASS) { + this.helpTextLabels.tiertypeText = STANDARD_TIER_TYPE_TEXT; + } else { + this.helpTextLabels.tiertypeText = EXPEDITED_TIER_TYPE_TEXT; + } }); } @@ -426,6 +554,23 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { const multipart_sync_threshold = this.formatter.toBytes( this.storageClassForm.get('multipart_sync_threshold').value ); + + const removeAclList: ACLVal[] = rawFormValue.acls || []; + const tier_config_rm: TierConfigRm = {}; + this.removedAclSourceIds.forEach((sourceId: string, index: number) => { + tier_config_rm[`acls[${index}].source_id`] = sourceId; + }); + if (this.aclList.length > rawFormValue.acls.length) { + this.aclList.forEach((acl: ACL, index: number) => { + const sourceId = acl?.val?.source_id; + const ifExist = removeAclList.find((acl: ACLVal) => acl?.source_id === sourceId); + + if (!ifExist) { + tier_config_rm[`acls[${index}].source_id`] = sourceId; + } + }); + } + return this.buildPlacementTargets( storageClassType, zoneGroup, @@ -434,7 +579,8 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { retain_head_object, rawFormValue, multipart_sync_threshold, - multipart_min_part_size + multipart_min_part_size, + tier_config_rm ); } @@ -446,11 +592,13 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { retain_head_object: boolean, rawFormValue: any, multipart_sync_threshold: number, - multipart_min_part_size: number + multipart_min_part_size: number, + tier_config_rm: TierConfigRm ): RequestModel { const baseTarget = { placement_id: placementId, - storage_class: storageClass + storage_class: storageClass, + tier_config_rm: tier_config_rm }; if (storageClassType === TIER_TYPE.LOCAL) { @@ -460,6 +608,19 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { }; } + const aclConfig: { [key: string]: string } = {}; + + rawFormValue.acls.forEach((acl: ACLVal, index: number) => { + const sourceId = acl?.source_id?.trim(); + if (!sourceId) return; + + const destId = acl?.dest_id?.trim() || ''; + const type = acl?.type?.trim() || AclTypeConst.ID; + + aclConfig[`acls[${index}].source_id`] = sourceId; + aclConfig[`acls[${index}].dest_id`] = destId; + aclConfig[`acls[${index}].type`] = type as AclType; + }); const tierConfig = { endpoint: rawFormValue.target_endpoint, access_key: rawFormValue.access_key, @@ -468,12 +629,13 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { retain_head_object, allow_read_through: rawFormValue.allow_read_through, region: rawFormValue.region, - multipart_sync_threshold: multipart_sync_threshold, - multipart_min_part_size: multipart_min_part_size, + multipart_sync_threshold, + multipart_min_part_size, restore_storage_class: rawFormValue.restore_storage_class, ...(rawFormValue.allow_read_through ? { read_through_restore_days: rawFormValue.read_through_restore_days } - : {}) + : {}), + ...aclConfig }; if (storageClassType === TIER_TYPE.CLOUD_TIER) { @@ -483,6 +645,7 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { { ...baseTarget, tier_type: TIER_TYPE.CLOUD_TIER, + tier_config_rm: tier_config_rm, tier_config: { ...tierConfig } @@ -498,6 +661,7 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { { ...baseTarget, tier_type: TIER_TYPE.GLACIER, + tier_config_rm: tier_config_rm, tier_config: { ...tierConfig, glacier_restore_days: rawFormValue.glacier_restore_days, @@ -508,6 +672,8 @@ export class RgwStorageClassFormComponent extends CdForm implements OnInit { ] }; } + + this.removedAclSourceIds = []; return { zone_group: zoneGroup, placement_targets: [baseTarget] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts index ba5bd46f9d0..c6896d62fa0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts @@ -45,6 +45,7 @@ export class BucketTieringUtils { allow_read_through: val.allow_read_through, restore_storage_class: val.restore_storage_class, read_through_restore_days: val.read_through_restore_days, + acls: val.s3.acl_mappings, ...val.s3 }; 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 892e6458705..06d7b7c2133 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 @@ -41,6 +41,7 @@ describe('RgwStorageClassService', () => { placement_id: 'default-placement', storage_class: 'test1', tier_type: 'cloud-s3', + tier_config_rm: { 'acls.source_id': 'test1' }, tier_config: { endpoint: 'http://198.162.100.100:80', access_key: 'test56', diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index d83b3b3a72c..7798c821197 100755 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -2104,6 +2104,13 @@ class RgwMultisite: '--tier-type', tier_type, '--tier-config', tier_config_str ] + tier_config_rm = placement_target.get('tier_config_rm', {}) + if tier_config_rm: + tier_config_rm_str = ','.join( + f"{key}={value}" for key, value in tier_config_rm.items() + ) + cmd_add_placement_options += ['--tier-config-rm', tier_config_rm_str] + if placement_target.get('tags') and storage_class_name != STANDARD_STORAGE_CLASS: cmd_add_placement_options += ['--tags', placement_target['tags']] -- 2.39.5