From: pujashahu Date: Fri, 17 Jul 2026 08:41:06 +0000 (+0530) Subject: mgr/dashboard: Add RADOS Namespace Support in NVMe-oF UI X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=3ffe684ee1fc69b6033c00d6b1da42a77db7d106;p=ceph.git mgr/dashboard: Add RADOS Namespace Support in NVMe-oF UI Fixes: https://tracker.ceph.com/issues/78330 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py index 1496d57865b..344252163df 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd.py +++ b/src/pybind/mgr/dashboard/controllers/rbd.py @@ -82,14 +82,15 @@ class Rbd(RESTController): DEFAULT_LIMIT = 5 - def _rbd_list(self, pool_name=None, offset=0, limit=DEFAULT_LIMIT, search='', sort=''): + def _rbd_list(self, pool_name=None, namespace=None, offset=0, limit=DEFAULT_LIMIT, + search='', sort=''): if pool_name: pools = [pool_name] else: pools = [p['pool_name'] for p in CephService.get_pool_list('rbd')] images, num_total_images = RbdService.rbd_pool_list( - pools, offset=offset, limit=limit, search=search, sort=sort) + pools, namespace=namespace, offset=offset, limit=limit, search=search, sort=sort) cherrypy.response.headers['X-Total-Count'] = num_total_images pool_result = {} for i, image in enumerate(images): @@ -111,14 +112,16 @@ class Rbd(RESTController): @EndpointDoc("Display Rbd Images", parameters={ 'pool_name': (str, 'Pool Name'), + 'namespace': (str, 'Optional RBD namespace. If provided, list images only ' + 'from this namespace within the selected pool(s).'), 'limit': (int, 'limit'), 'offset': (int, 'offset'), }, responses={200: RBD_SCHEMA}) @RESTController.MethodMap(version=APIVersion(2, 0)) # type: ignore - def list(self, pool_name=None, offset: int = 0, limit: int = DEFAULT_LIMIT, + def list(self, pool_name=None, namespace=None, offset: int = 0, limit: int = DEFAULT_LIMIT, search: str = '', sort: str = ''): - return self._rbd_list(pool_name, offset=int(offset), limit=int(limit), + return self._rbd_list(pool_name, namespace=namespace, offset=int(offset), limit=int(limit), search=search, sort=sort) @handle_rbd_error() diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html index c4beb493e61..43d658dfdfc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html @@ -272,6 +272,41 @@ + +
+
+ + + @if (radosNamespaces === null && nsForm.getValue('pool')) { + + } + @for (ns of radosNamespaces; track ns.namespace) { + + } + +
+
+
{ let component: NvmeofNamespacesFormComponent; let fixture: ComponentFixture; let nvmeofService: NvmeofService; + let rbdService: RbdService; let router: Router; let form: CdFormGroup; let formHelper: FormHelper; @@ -97,6 +137,7 @@ describe('NvmeofNamespacesFormComponent', () => { SelectModule ] }).compileComponents(); + fixture = TestBed.createComponent(NvmeofNamespacesFormComponent); component = fixture.componentInstance; }); @@ -104,26 +145,34 @@ describe('NvmeofNamespacesFormComponent', () => { it('should create component', () => { expect(component).toBeTruthy(); }); - describe('should test create form', () => { + + describe('create form', () => { beforeEach(() => { router = TestBed.inject(Router); nvmeofService = TestBed.inject(NvmeofService); + rbdService = TestBed.inject(RbdService); + spyOn(nvmeofService, 'createNamespace').and.returnValue( of(new HttpResponse({ body: MOCK_NS_RESPONSE })) ); - spyOn(nvmeofService, 'getInitiators').and.returnValue( of([{ nqn: 'host1' }, { nqn: 'host2' }]) ); + spyOn(rbdService, 'listNamespaces').and.returnValue(of(MOCK_RADOS_NAMESPACES)); + // Default: no existing namespaces. Individual tests can override via + // (nvmeofService.listNamespaces as jasmine.Spy).and.returnValue(...) + spyOn(nvmeofService, 'listNamespaces').and.returnValue(of({ namespaces: [] })); spyOn(component, 'randomString').and.returnValue(MOCK_RANDOM_STRING); + Object.defineProperty(router, 'url', { get: jasmine.createSpy('url').and.returnValue(MOCK_ROUTER.createUrl) }); + component.ngOnInit(); form = component.nsForm; formHelper = new FormHelper(form); - formHelper.setValue('pool', 'rbd'); }); + it('should call createNamespace on submit with specific hosts', () => { formHelper.setValue('pool', 'rbd'); formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); @@ -145,5 +194,122 @@ describe('NvmeofNamespacesFormComponent', () => { const request = (nvmeofService.createNamespace as jasmine.Spy).calls.mostRecent().args[1]; expect(request.block_size).toBe(1024); }); + + describe('RADOS namespace field', () => { + it('should load RADOS namespaces when a pool is selected', () => { + formHelper.setValue('pool', 'rbd'); + + expect(rbdService.listNamespaces).toHaveBeenCalledWith('rbd'); + expect(component.radosNamespaces).toEqual(MOCK_RADOS_NAMESPACES); + }); + + it('should clear rados_namespace selection and reload namespaces when pool changes', () => { + formHelper.setValue('pool', 'rbd'); + formHelper.setValue('rados_namespace', 'tenant-1'); + + // Switching to a different pool must clear the selected namespace + formHelper.setValue('pool', 'pool-2'); + + expect(form.getValue('rados_namespace')).toBeNull(); + // listNamespaces should have been called for the new pool + expect(rbdService.listNamespaces).toHaveBeenCalledWith('pool-2'); + }); + + it('should not include rados_namespace in the create request when none is selected', () => { + formHelper.setValue('pool', 'rbd'); + formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); + formHelper.setValue('subsystem', MOCK_SUBSYSTEM); + + component.onSubmit(); + + const request = (nvmeofService.createNamespace as jasmine.Spy).calls.mostRecent().args[1]; + expect(request.rados_namespace).toBeUndefined(); + }); + + it('should include rados_namespace in the create request when a namespace is selected', () => { + formHelper.setValue('pool', 'rbd'); + formHelper.setValue('rados_namespace', 'tenant-1'); + formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); + formHelper.setValue('subsystem', MOCK_SUBSYSTEM); + + component.onSubmit(); + + const request = (nvmeofService.createNamespace as jasmine.Spy).calls.mostRecent().args[1]; + expect(request.rados_namespace).toBe('tenant-1'); + }); + }); + + describe('image filtering with RADOS namespace isolation', () => { + /** + * The NvmeofService and RbdService are singletons in TestBed DI. The parent + * beforeEach has already called spyOn() on createNamespace, getInitiators and + * listNamespaces (RBD). Here we only need to override the two methods that + * control which images are "used" and which are available: listNamespaces and + * rbdService.list. We create a new component instance so that ngOnInit() picks + * up the configured listNamespaces stub when it calls fetchUsedImages(). + */ + function buildComponentWithStubs( + usedNamespaces: ReturnType[], + rbdListResponse: { pool_name: string; value: { name: string; size: number }[] }[] + ): void { + // Re-spy the two methods that vary between test cases + (nvmeofService.listNamespaces as jasmine.Spy).and.returnValue( + of({ namespaces: usedNamespaces }) + ); + + fixture = TestBed.createComponent(NvmeofNamespacesFormComponent); + component = fixture.componentInstance; + + // rbdService is injected into the component — spy before ngOnInit + spyOn(component['rbdService'], 'list').and.returnValue(of(rbdListResponse)); + + component.ngOnInit(); + form = component.nsForm; + formHelper = new FormHelper(form); + } + + it('should treat the same image name in different RADOS namespaces as independently available', () => { + // 'img-alpha' is recorded as used in the **default** namespace of pool 'rbd' + buildComponentWithStubs( + [makeUsedNs('rbd', 'img-alpha', '')], + [{ pool_name: 'rbd', value: MOCK_RBD_IMAGES_TENANT1 }] + ); + + formHelper.setValue('pool', 'rbd'); + formHelper.setValue('rados_namespace', 'tenant-1'); + + // img-alpha exists in tenant-1's image list AND is NOT used in tenant-1, + // so it must remain available for selection. + expect(component.rbdImages.map((i) => i.name)).toContain('img-alpha'); + }); + + it('should exclude images already used in the same pool + RADOS namespace', () => { + // 'img-alpha' is recorded as used in 'tenant-1' + buildComponentWithStubs( + [makeUsedNs('rbd', 'img-alpha', 'tenant-1')], + [{ pool_name: 'rbd', value: MOCK_RBD_IMAGES_TENANT1 }] + ); + + formHelper.setValue('pool', 'rbd'); + formHelper.setValue('rados_namespace', 'tenant-1'); + + expect(component.rbdImages.map((i) => i.name)).not.toContain('img-alpha'); + expect(component.rbdImages.map((i) => i.name)).toContain('img-gamma'); + }); + + it('should exclude used images in the default namespace when no RADOS namespace is selected', () => { + // 'img-alpha' is recorded as used in the default namespace (empty radosNs) + buildComponentWithStubs( + [makeUsedNs('rbd', 'img-alpha', '')], + [{ pool_name: 'rbd', value: MOCK_RBD_IMAGES_DEFAULT_NS }] + ); + + formHelper.setValue('pool', 'rbd'); + // rados_namespace stays null → scoped to the default namespace + + expect(component.rbdImages.map((i) => i.name)).not.toContain('img-alpha'); + expect(component.rbdImages.map((i) => i.name)).toContain('img-beta'); + }); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts index b5b8fb26647..04f3a53af91 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts @@ -10,6 +10,7 @@ import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { + RadosNamespace, NvmeofSubsystem, NvmeofSubsystemInitiator, NvmeofSubsystemNamespace @@ -48,6 +49,7 @@ export class NvmeofNamespacesFormComponent implements OnInit { subsystems: NvmeofSubsystem[] | null = null; rbdPools: Array = null; rbdImages: any[] = []; + radosNamespaces: RadosNamespace[] | null = null; initiatorCandidates: { content: string; selected: boolean }[] = []; nsid: string; @@ -146,17 +148,46 @@ export class NvmeofNamespacesFormComponent implements OnInit { } } - // Stores all RBD images fetched for the selected pool + // Stores all RBD images fetched for the selected pool + RADOS namespace. private allRbdImages: { name: string; size: number }[] = []; - // Maps pool name to a Set of used image names for O(1) lookup + // Maps "pool:radosNamespace" composite key to used image names. private usedRbdImages: Map> = new Map(); onPoolChange(): void { const pool = this.nsForm.getValue('pool'); if (!pool) return; + this.radosNamespaces = null; + this.nsForm.get('rados_namespace').setValue(null, { emitEvent: false }); + + this.rbdService.listNamespaces(pool).subscribe({ + next: (namespaces) => { + this.radosNamespaces = namespaces as RadosNamespace[]; + }, + error: () => { + this.radosNamespaces = []; + } + }); + + this.fetchImagesForCurrentSelection(pool, null); + } + + private onRadosNamespaceChange(): void { + const pool = this.nsForm.getValue('pool'); + if (!pool) return; + + const radosNs = this.nsForm.getValue('rados_namespace') as string | null; + this.fetchImagesForCurrentSelection(pool, radosNs); + } + + private fetchImagesForCurrentSelection(pool: string, radosNs: string | null): void { + const params: Record = { pool_name: pool, offset: '0', limit: '-1' }; + if (radosNs) { + params['namespace'] = radosNs; + } + this.rbdService - .list({ pool_name: pool, offset: '0', limit: '-1' }) + .list(params) .subscribe((pools: { pool_name: string; value: { name: string; size: number }[] }[]) => { const selectedPool = pools.find((p) => p.pool_name === pool); this.allRbdImages = selectedPool?.value ?? []; @@ -180,10 +211,11 @@ export class NvmeofNamespacesFormComponent implements OnInit { ? response : (response?.namespaces ?? []); this.usedRbdImages = namespaces.reduce((map, ns) => { - if (!map.has(ns.rbd_pool_name)) { - map.set(ns.rbd_pool_name, new Set()); + const key = this.usedImagesKey(ns.rbd_pool_name, ns.rados_namespace_name ?? ''); + if (!map.has(key)) { + map.set(key, new Set()); } - map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name); + map.get(key)!.add(ns.rbd_image_name); return map; }, new Map>()); this.filterImages(); @@ -219,17 +251,23 @@ export class NvmeofNamespacesFormComponent implements OnInit { this.rbdImages = []; return; } - const usedInPool = this.usedRbdImages.get(pool); - this.rbdImages = usedInPool - ? this.allRbdImages.filter((img) => !usedInPool.has(img.name)) + const radosNs = (this.nsForm.getValue('rados_namespace') as string | null) ?? ''; + const usedInScope = this.usedRbdImages.get(this.usedImagesKey(pool, radosNs)); + this.rbdImages = usedInScope + ? this.allRbdImages.filter((img) => !usedInScope.has(img.name)) : [...this.allRbdImages]; } + private usedImagesKey(pool: string, radosNs: string): string { + return `${pool}\x00${radosNs}`; + } + createForm() { this.nsForm = new CdFormGroup({ pool: new UntypedFormControl('', { validators: [Validators.required] }), + rados_namespace: new UntypedFormControl(null), subsystem: new UntypedFormControl('', { validators: [Validators.required] }), @@ -258,6 +296,10 @@ export class NvmeofNamespacesFormComponent implements OnInit { this.onPoolChange(); }); + this.nsForm.get('rados_namespace').valueChanges.subscribe(() => { + this.onRadosNamespaceChange(); + }); + this.nsForm.get('nsCount').valueChanges.subscribe((count: number) => { if (count > 1) { const creationControl = this.nsForm.get('rbd_image_creation'); @@ -324,6 +366,7 @@ export class NvmeofNamespacesFormComponent implements OnInit { noAutoVisible: boolean ): Observable>[] { const pool = this.nsForm.getValue('pool'); + const radosNs = this.nsForm.getValue('rados_namespace') as string | null; const requests: Observable>[] = []; const creationMode = this.nsForm.getValue('rbd_image_creation'); const isGatewayProvisioned = creationMode === 'gateway_provisioned'; @@ -340,6 +383,9 @@ export class NvmeofNamespacesFormComponent implements OnInit { no_auto_visible: noAutoVisible }; + if (radosNs) { + request.rados_namespace = radosNs; + } if (blockSize) { request.block_size = blockSize; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html index b38d58bec27..e130e98a7f4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html @@ -5,7 +5,7 @@ [data]="namespaces" [compactSearchField]="true" columnMode="flex" - (fetchData)="fetchData()" + (fetchData)="listNamespaces()" [columns]="namespacesColumns" identifier="unique_id" [forceIdentifier]="true" diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts index 48021924dd8..864b422518a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts @@ -154,7 +154,7 @@ describe('NvmeofNamespacesListComponent', () => { ); done(); }); - component.fetchData(); + component.listNamespaces(); }); it('should open delete modal with correct data', () => { @@ -192,16 +192,17 @@ describe('NvmeofNamespacesListComponent', () => { done(); }); - component.fetchData(); + component.listNamespaces(); }); it('should update group and trigger namespace fetch on group change', () => { - const fetchDataSpy = jest.spyOn(component, 'fetchData'); + const listNamespacesSpy = jest.spyOn(component, 'listNamespaces'); + listNamespacesSpy.mockClear(); component.onGroupChange('g1'); expect(component.group).toBe('g1'); - expect(fetchDataSpy).not.toHaveBeenCalled(); // onGroupChange calls namespaceSubject.next() directly + expect(listNamespacesSpy).toHaveBeenCalled(); }); it('should clear group on onGroupChange with null', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts index 427dd606894..4a8283eed55 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts @@ -81,6 +81,11 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { name: $localize`Pool`, prop: 'rbd_pool_name' }, + { + name: $localize`RADOS Namespace`, + prop: 'rados_namespace_name', + flexGrow: 1 + }, { name: $localize`Image`, prop: 'rbd_image_name' @@ -160,7 +165,7 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { ); this.nvmeofStateService.refresh$ .pipe(takeUntil(this.destroy$)) - .subscribe(() => this.fetchData()); + .subscribe(() => this.listNamespaces()); } private normalizeAndDedup( @@ -244,10 +249,6 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { this.namespaceSubject.next(); } - fetchData() { - this.listNamespaces(); - } - private setTableLoading(loading: boolean): void { if (this.table) { this.table.loadingIndicator = loading; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index fb3bb1c9ede..a6a03cc9812 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -11,11 +11,11 @@ import { NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof'; -import { HostService } from './host.service'; -import { OrchestratorService } from './orchestrator.service'; import { HostStatus } from '../enum/host-status.enum'; import { Host } from '../models/host.interface'; import { OrchestratorStatus } from '../models/orchestrator.interface'; +import { HostService } from './host.service'; +import { OrchestratorService } from './orchestrator.service'; export type SetupState = { hasGatewayGroups: boolean; @@ -46,6 +46,7 @@ export type ListenerRequest = NvmeofRequest & { export type NamespaceCreateRequest = NvmeofRequest & { rbd_image_name?: string; rbd_pool: string; + rados_namespace?: string; rbd_image_size?: number; no_auto_visible?: boolean; block_size?: number; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts index 3316570ebd3..810c4da720b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts @@ -53,6 +53,7 @@ export interface NvmeofSubsystemNamespace { bdev_name: string; rbd_image_name: string; rbd_pool_name: string; + rados_namespace_name?: string; // empty string or absent means default RADOS namespace load_balancing_group: number; rbd_image_size: number | string; block_size: number; @@ -63,6 +64,11 @@ export interface NvmeofSubsystemNamespace { subsystem_nqn?: string; // Field from JSON (mapped from ns_subsystem_nqn if needed) } +export type RadosNamespace = { + namespace: string; + num_images: number; +}; + export interface NvmeofGatewayGroup extends CephServiceSpec { name: string; gatewayCount: { diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 4e0d2259ca8..875962cb8ba 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -265,6 +265,13 @@ paths: name: pool_name schema: type: string + - allowEmptyValue: true + description: Optional RBD namespace. If provided, list images only from this + namespace within the selected pool(s). + in: query + name: namespace + schema: + type: string - default: 0 description: offset in: query