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):
@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()
import { FormHelper, Mocks } from '~/testing/unit-test-helper';
import { FormatterService } from '~/app/shared/services/formatter.service';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
import { of, Observable } from 'rxjs';
import { PoolService } from '~/app/shared/api/pool.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NumberModule, RadioModule, ComboBoxModule, SelectModule } from 'carbon-components-angular';
import { ActivatedRoute, Router } from '@angular/router';
+import { RadosNamespace } from '~/app/shared/models/nvmeof';
import { ActivatedRouteStub } from '~/testing/activated-route-stub';
Mocks.getPool('rbd', 2),
Mocks.getPool('pool-2', 3)
];
+
class MockPoolsService {
getList() {
return of(MOCK_POOLS);
createUrl: 'https://192.168.100.100:8443/#/block/nvmeof/subsystems/(modal:create)?group=default'
};
+const MOCK_RADOS_NAMESPACES: RadosNamespace[] = [
+ { namespace: 'tenant-1', num_images: 2 },
+ { namespace: 'tenant-2', num_images: 0 }
+];
+
+const MOCK_RBD_IMAGES_DEFAULT_NS = [
+ { name: 'img-alpha', size: 1073741824 },
+ { name: 'img-beta', size: 2147483648 }
+];
+
+const MOCK_RBD_IMAGES_TENANT1 = [
+ { name: 'img-alpha', size: 1073741824 }, // same name as in default ns — must stay selectable
+ { name: 'img-gamma', size: 536870912 }
+];
+
+/**
+ * Builds a minimal namespace shape for fetchUsedImages() tests.
+ */
+function makeUsedNs(pool: string, imageName: string, radosNs: string = '') {
+ return {
+ nsid: 1,
+ uuid: 'uuid',
+ bdev_name: 'bdev',
+ rbd_image_name: imageName,
+ rbd_pool_name: pool,
+ rados_namespace_name: radosNs,
+ load_balancing_group: 1,
+ rbd_image_size: 1073741824,
+ block_size: 512,
+ rw_ios_per_second: 0,
+ rw_mbytes_per_second: 0,
+ r_mbytes_per_second: 0,
+ w_mbytes_per_second: 0
+ };
+}
+
describe('NvmeofNamespacesFormComponent', () => {
let component: NvmeofNamespacesFormComponent;
let fixture: ComponentFixture<NvmeofNamespacesFormComponent>;
let nvmeofService: NvmeofService;
+ let rbdService: RbdService;
let router: Router;
let form: CdFormGroup;
let formHelper: FormHelper;
SelectModule
]
}).compileComponents();
+
fixture = TestBed.createComponent(NvmeofNamespacesFormComponent);
component = fixture.componentInstance;
});
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'));
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<typeof makeUsedNs>[],
+ 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');
+ });
+ });
});
});
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { FinishedTask } from '~/app/shared/models/finished-task';
import {
+ RadosNamespace,
NvmeofSubsystem,
NvmeofSubsystemInitiator,
NvmeofSubsystemNamespace
subsystems: NvmeofSubsystem[] | null = null;
rbdPools: Array<Pool> = null;
rbdImages: any[] = [];
+ radosNamespaces: RadosNamespace[] | null = null;
initiatorCandidates: { content: string; selected: boolean }[] = [];
nsid: string;
}
}
- // 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<string, Set<string>> = 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<string, string> = { 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 ?? [];
? response
: (response?.namespaces ?? []);
this.usedRbdImages = namespaces.reduce((map, ns) => {
- if (!map.has(ns.rbd_pool_name)) {
- map.set(ns.rbd_pool_name, new Set<string>());
+ const key = this.usedImagesKey(ns.rbd_pool_name, ns.rados_namespace_name ?? '');
+ if (!map.has(key)) {
+ map.set(key, new Set<string>());
}
- map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
+ map.get(key)!.add(ns.rbd_image_name);
return map;
}, new Map<string, Set<string>>());
this.filterImages();
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]
}),
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');
noAutoVisible: boolean
): Observable<HttpResponse<Object>>[] {
const pool = this.nsForm.getValue('pool');
+ const radosNs = this.nsForm.getValue('rados_namespace') as string | null;
const requests: Observable<HttpResponse<Object>>[] = [];
const creationMode = this.nsForm.getValue('rbd_image_creation');
const isGatewayProvisioned = creationMode === 'gateway_provisioned';
no_auto_visible: noAutoVisible
};
+ if (radosNs) {
+ request.rados_namespace = radosNs;
+ }
if (blockSize) {
request.block_size = blockSize;
}