From 50c25d9514170d49cf92fd6a7736169bbd9124cc Mon Sep 17 00:00:00 2001 From: Dnyaneshwari Talwekar Date: Tue, 17 Mar 2026 10:30:13 +0530 Subject: [PATCH] mgr/dashboard: [storage-class]: Deleting local storage class from UI does not remove its entry from zone Fixes: https://tracker.ceph.com/issues/75541 Signed-off-by: Dnyaneshwari Talwekar --- src/pybind/mgr/dashboard/controllers/rgw.py | 4 +- .../rgw-storage-class-list.component.ts | 61 +++++++++++-------- .../api/rgw-storage-class.service.spec.ts | 10 +++ .../shared/api/rgw-storage-class.service.ts | 7 ++- src/pybind/mgr/dashboard/openapi.yaml | 5 ++ .../mgr/dashboard/services/rgw_client.py | 23 ++++++- 6 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index ec1ed24d540f..7fef9480043c 100755 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -1432,9 +1432,9 @@ class RgwZonegroup(RESTController): @Endpoint('DELETE', path='storage-class') @DeletePermission - def remove_storage_class(self, placement_id: str, storage_class: str): + def remove_storage_class(self, placement_id: str, storage_class: str, zone_name: str = ''): multisite_instance = RgwMultisite() - result = multisite_instance.delete_placement_targets(placement_id, storage_class) + result = multisite_instance.delete_placement_targets(placement_id, storage_class, zone_name) return result @Endpoint('POST', path='storage-class') 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 3a42b797f6dd..4abacee58378 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 @@ -5,6 +5,7 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; import { + AllZonesResponse, StorageClass, TIER_TYPE, TIER_TYPE_DISPLAY, @@ -14,6 +15,7 @@ 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 { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service'; +import { RgwZoneService } from '~/app/shared/api/rgw-zone.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; @@ -23,8 +25,8 @@ import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Permission } from '~/app/shared/models/permissions'; import { BucketTieringUtils } from '../utils/rgw-bucket-tiering'; import { Router } from '@angular/router'; -import { Observable, Subscriber } from 'rxjs'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; +import { finalize, switchMap } from 'rxjs/operators'; const BASE_URL = 'rgw/storage-class'; @Component({ @@ -45,6 +47,7 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI constructor( private rgwZonegroupService: RgwZonegroupService, + private rgwZoneService: RgwZoneService, public actionLabels: ActionLabelsI18n, private cdsModalService: ModalCdsService, private taskWrapper: TaskWrapperService, @@ -134,7 +137,10 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI const tierObj = BucketTieringUtils.filterAndMapTierTargets(data); const tierConfig = tierObj.map((tier) => ({ ...tier, - tier_type: this.mapTierTypeDisplay(tier.tier_type) + tier_type: this.mapTierTypeDisplay(tier.tier_type), + storageClass: tier.storage_class, + placementTarget: tier.placement_target, + tierType: this.mapTierTypeDisplay(tier.tier_type) })); this.transformTierData(tierConfig); @@ -172,32 +178,39 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI } removeStorageClassModal() { - const storage_class = this.selection.first().storage_class; - const placement_target = this.selection.first().placement_target; + const selectedItem = this.selection.first(); + const { storageClass, placementTarget, tierType } = selectedItem; + const isLocalStorageClass = + tierType?.toLowerCase() === TIER_TYPE.LOCAL || tierType === TIER_TYPE_DISPLAY.LOCAL; + this.cdsModalService.show(DeleteConfirmationModalComponent, { itemDescription: $localize`Storage class`, - itemNames: [storage_class], + itemNames: [storageClass], actionDescription: 'remove', submitActionObservable: () => { - return new Observable((observer: Subscriber) => { - this.taskWrapper - .wrapTaskAroundCall({ - task: new FinishedTask('rgw/zonegroup/storage-class', { - placement_target: placement_target, - storage_class: storage_class - }), - call: this.rgwStorageClassService.removeStorageClass(placement_target, storage_class) - }) - .subscribe({ - error: (error: any) => { - observer.error(error); - }, - complete: () => { - observer.complete(); - this.table.refreshBtn(); - } - }); - }); + // For local storage classes, delete from both zone and zonegroup + const deleteObservable$ = isLocalStorageClass + ? this.rgwZoneService.getAllZonesInfo().pipe( + switchMap((data: AllZonesResponse) => { + const zoneInfo = BucketTieringUtils.getZoneInfoHelper(data.zones, selectedItem); + return this.rgwStorageClassService.removeStorageClass( + placementTarget, + storageClass, + zoneInfo.zone_name || '' + ); + }) + ) + : this.rgwStorageClassService.removeStorageClass(placementTarget, storageClass); + + return this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('rgw/zonegroup/storage-class', { + placementTarget, + storageClass + }), + call: deleteObservable$ + }) + .pipe(finalize(() => this.table.refreshBtn())); } }); } 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 06d7b7c2133c..49966b31cdee 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 @@ -30,6 +30,16 @@ describe('RgwStorageClassService', () => { 'api/rgw/zonegroup/storage-class/default-placement/Cloud8ibm' ); expect(req.request.method).toBe('DELETE'); + expect(req.request.params.has('zone_name')).toBe(false); + }); + + it('should call remove with zone_name query param when provided', () => { + service.removeStorageClass('default-placement', 'Cloud8ibm', 'us-east-1').subscribe(); + const req = httpTesting.expectOne( + 'api/rgw/zonegroup/storage-class/default-placement/Cloud8ibm?zone_name=us-east-1' + ); + expect(req.request.method).toBe('DELETE'); + expect(req.request.params.get('zone_name')).toBe('us-east-1'); }); it('should call create', () => { 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 cff162286f97..495daba36860 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 @@ -12,8 +12,13 @@ export class RgwStorageClassService { constructor(private http: HttpClient) {} - removeStorageClass(placement_target: string, storage_class: string) { + removeStorageClass(placement_target: string, storage_class: string, zone_name: string = '') { + let params: any = {}; + if (zone_name) { + params.zone_name = zone_name; + } return this.http.delete(`${this.url}/${placement_target}/${storage_class}`, { + params, observe: 'response' }); } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index abcb4e51ef3a..4975d3f21142 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -21405,6 +21405,11 @@ paths: required: true schema: type: string + - default: '' + in: query + name: zone_name + schema: + type: string responses: '202': content: diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 5355a9ef1856..f7325c4e6d31 100755 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -2300,7 +2300,28 @@ class RgwMultisite: if tier_type in CLOUD_S3_TIER_TYPES: self.ensure_realm_and_sync_period() - def delete_placement_targets(self, placement_id: str, storage_class: str): + def delete_placement_targets(self, placement_id: str, storage_class: str, + zone_name: str = ''): + # Delete from zone first if zone_name is provided + if zone_name: + rgw_zone_delete_cmd = ['zone', 'placement', 'rm', '--rgw-zone', zone_name, + '--placement-id', placement_id, + '--storage-class', storage_class] + + try: + exit_code, _, err = mgr.send_rgwadmin_command(rgw_zone_delete_cmd) + if exit_code > 0: + raise DashboardException( + e=err, + msg=(f'Unable to delete placement {placement_id} ' + f'with storage-class {storage_class} from zone {zone_name}'), + http_status_code=500, + component='rgw' + ) + except SubprocessError as error: + raise DashboardException(error, http_status_code=500, component='rgw') + + # Delete from zonegroup rgw_zonegroup_delete_cmd = ['zonegroup', 'placement', 'rm', '--placement-id', placement_id, '--storage-class', storage_class] -- 2.47.3