From: Pedro Gonzalez Gomez Date: Tue, 10 Feb 2026 16:18:36 +0000 (+0100) Subject: mgr/dashboard: add SMB share QoS rate limiting X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=07a7f6f52703f5aefd62da5aeec7fc58c6ef20ea;p=ceph.git mgr/dashboard: add SMB share QoS rate limiting Fixes: https://tracker.ceph.com/issues/74856 Signed-off-by: Pedro Gonzalez Gomez --- diff --git a/src/pybind/mgr/dashboard/controllers/smb.py b/src/pybind/mgr/dashboard/controllers/smb.py index 88f471c76eb..8926dedd5b8 100644 --- a/src/pybind/mgr/dashboard/controllers/smb.py +++ b/src/pybind/mgr/dashboard/controllers/smb.py @@ -3,14 +3,14 @@ import json import logging from functools import wraps -from typing import List +from typing import List, Optional from smb.enums import Intent from smb.proto import Simplified from smb.resources import Cluster, JoinAuth, Share, UsersAndGroups from dashboard.controllers._docs import EndpointDoc -from dashboard.controllers._permissions import CreatePermission, DeletePermission +from dashboard.controllers._permissions import CreatePermission, DeletePermission, UpdatePermission from dashboard.exceptions import DashboardException from .. import mgr @@ -61,7 +61,13 @@ SHARE_SCHEMA = { "provider": (str, "Provider of the CephFS share, e.g., 'samba-vfs'"), "subvolumegroup": (str, "Subvolume Group in CephFS file system"), "subvolume": (str, "Subvolume within the CephFS file system"), - }, "Configuration for the CephFS share") + }, "Configuration for the CephFS share"), + "read_iops_limit": (int, "QoS: max read IOPS (0=disabled)"), + "write_iops_limit": (int, "QoS: max write IOPS (0=disabled)"), + "read_bw_limit": (int, "QoS: max read bandwidth B/s (0=disabled)"), + "write_bw_limit": (int, "QoS: max write bandwidth B/s (0=disabled)"), + "read_delay_max": (int, "QoS: max read delay in seconds (0-300)"), + "write_delay_max": (int, "QoS: max write delay in seconds (0-300)"), } JOIN_AUTH_SCHEMA = { @@ -272,7 +278,48 @@ class SMBShare(RESTController): return mgr.remote('smb', 'show', [f'{self._resource}.{cluster_id}.{share_id}']) @raise_on_failure - @DeletePermission + @UpdatePermission + @Endpoint(method='PUT', path='/qos') + @EndpointDoc( + "Update SMB share QoS rate limiting", + parameters={ + 'cluster_id': (str, 'Unique identifier for the cluster'), + 'share_id': (str, 'Unique identifier for the share'), + 'read_iops_limit': (int, 'Max read IOPS (0=disabled, 0-1000000)'), + 'write_iops_limit': (int, 'Max write IOPS (0=disabled, 0-1000000)'), + 'read_bw_limit': (int, 'Max read bandwidth B/s (0=disabled)'), + 'write_bw_limit': (int, 'Max write bandwidth B/s (0=disabled)'), + 'read_delay_max': (int, 'Max read delay in seconds (0-300)'), + 'write_delay_max': (int, 'Max write delay in seconds (0-300)'), + }) + def update_qos( + self, + cluster_id: str, + share_id: str, + read_iops_limit: Optional[int] = None, + write_iops_limit: Optional[int] = None, + read_bw_limit: Optional[int] = None, + write_bw_limit: Optional[int] = None, + read_delay_max: Optional[int] = None, + write_delay_max: Optional[int] = None, + ): + """ + Update QoS rate limit parameters for an SMB share. + Omitted parameters are left unchanged. Use 0 to disable a limit. + """ + return mgr.remote( + 'smb', + 'share_update_qos', + cluster_id, + share_id, + read_iops_limit, + write_iops_limit, + read_bw_limit, + write_bw_limit, + read_delay_max, + write_delay_max + ).to_simplified() + @EndpointDoc("Remove an smb share", parameters={ 'cluster_id': (str, 'Unique identifier for the cluster'), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.html index 311609a371e..a9e3a662469 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.html @@ -225,6 +225,112 @@ If selected no clients are permitted to write to the share. + + +
Rate limiting
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ { ToastrModule.forRoot(), GridModule, InputModule, + NumberModule, SelectModule, ComboBoxModule, CheckboxModule @@ -89,9 +91,53 @@ describe('SmbShareFormComponent', () => { prefixedPath: '/volumes/fs1/group1/subvol1', inputPath: '/', browseable: true, - readonly: false + readonly: false, + read_iops_limit: 0, + write_iops_limit: 0, + read_bw_limit: 0, + read_bw_limit_unit: 'MiB', + write_bw_limit: 0, + write_bw_limit_unit: 'MiB', + read_delay_max: 30, + write_delay_max: 30 }); component.submitAction(); expect(component).toBeTruthy(); }); + + describe('QoS', () => { + it('should have QoS form controls with default values', () => { + component.ngOnInit(); + expect(component.smbShareForm.get('read_iops_limit')).toBeTruthy(); + expect(component.smbShareForm.get('write_iops_limit')).toBeTruthy(); + expect(component.smbShareForm.get('read_bw_limit')).toBeTruthy(); + expect(component.smbShareForm.get('read_bw_limit_unit')).toBeTruthy(); + expect(component.smbShareForm.get('write_bw_limit')).toBeTruthy(); + expect(component.smbShareForm.get('write_bw_limit_unit')).toBeTruthy(); + expect(component.smbShareForm.get('read_delay_max')).toBeTruthy(); + expect(component.smbShareForm.get('write_delay_max')).toBeTruthy(); + expect(component.smbShareForm.get('read_iops_limit').value).toBe(0); + expect(component.smbShareForm.get('write_delay_max').value).toBe(30); + }); + + it('should include QoS fields in buildRequest when set', () => { + component.clusterId = 'cluster1'; + component.smbShareForm.patchValue({ + share_id: 'share1', + name: 'My Share', + volume: 'fs1', + inputPath: '/', + read_iops_limit: 1000, + write_iops_limit: 500, + read_bw_limit: 100, + read_bw_limit_unit: 'MiB', + write_delay_max: 60 + }); + const request = component.buildRequest(); + expect(request.share_resource.cephfs.qos.read_iops_limit).toBe(1000); + expect(request.share_resource.cephfs.qos.write_iops_limit).toBe(500); + expect(request.share_resource.cephfs.qos.read_bw_limit).toBe(100 * 1024 * 1024); + expect(request.share_resource.cephfs.qos.write_delay_max).toBe(60); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.ts index 52a41601a1f..530bb654e54 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.ts @@ -18,11 +18,29 @@ import { CephfsSubvolume } from '~/app/shared/models/cephfs-subvolume.model'; import { SmbService } from '~/app/shared/api/smb.service'; import { NfsService } from '~/app/shared/api/nfs.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service'; import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service'; import { CLUSTER_PATH } from '../smb-cluster-list/smb-cluster-list.component'; import { SHARE_PATH } from '../smb-share-list/smb-share-list.component'; +const QOS_IOPS_MAX = 1_000_000; +const QOS_BW_MAX_BYTES = 2 ** 40; +const UNIT_TO_BYTES: Record = { + KiB: 2 ** 10, + MiB: 2 ** 20, + GiB: 2 ** 30, + TiB: 2 ** 40 +}; +const QOS_DELAY_MAX = 300; +const QOS_DELAY_DEFAULT = 30; +const QOS_BW_UNITS = ['KiB', 'MiB', 'GiB', 'TiB']; + +function getBwMaxForUnit(unit: string): number { + return Math.floor(QOS_BW_MAX_BYTES / UNIT_TO_BYTES[unit]); +} + @Component({ selector: 'cd-smb-share-form', templateUrl: './smb-share-form.component.html', @@ -40,6 +58,9 @@ export class SmbShareFormComponent extends CdForm implements OnInit { isEdit = false; share_id: string; shareResponse: SMBShare; + qosBwUnits = QOS_BW_UNITS; + readBwMax = getBwMaxForUnit(QOS_BW_UNITS[1]); + writeBwMax = getBwMaxForUnit(QOS_BW_UNITS[1]); constructor( private formBuilder: CdFormBuilder, @@ -50,7 +71,9 @@ export class SmbShareFormComponent extends CdForm implements OnInit { private subvolService: CephfsSubvolumeService, private taskWrapperService: TaskWrapperService, private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private formatter: FormatterService, + private dimlessBinaryPipe: DimlessBinaryPipe ) { super(); this.resource = $localize`Share`; @@ -69,26 +92,43 @@ export class SmbShareFormComponent extends CdForm implements OnInit { if (this.isEdit) { this.smbService.getShare(this.clusterId, this.share_id).subscribe((resp: SMBShare) => { this.shareResponse = resp; - this.smbShareForm.get('share_id').setValue(this.shareResponse.share_id); + const cephfs = this.shareResponse?.cephfs; + const qos = cephfs?.qos; + + this.smbShareForm.patchValue({ + share_id: this.shareResponse.share_id, + name: this.shareResponse.name, + volume: cephfs?.volume, + subvolume_group: cephfs?.subvolumegroup, + subvolume: cephfs?.subvolume, + inputPath: cephfs?.path, + readonly: this.shareResponse.readonly ?? false, + browseable: this.shareResponse.browseable ?? true, + read_iops_limit: qos?.read_iops_limit, + write_iops_limit: qos?.write_iops_limit, + read_delay_max: qos?.read_delay_max, + write_delay_max: qos?.write_delay_max + }); this.smbShareForm.get('share_id').disable(); - this.smbShareForm.get('name').setValue(this.shareResponse.name); this.smbShareForm.get('name').disable(); - this.smbShareForm.get('volume').setValue(this.shareResponse?.cephfs?.volume); - this.smbShareForm - .get('subvolume_group') - .setValue(this.shareResponse?.cephfs?.subvolumegroup); - this.smbShareForm.get('subvolume').setValue(this.shareResponse?.cephfs?.subvolume); - this.smbShareForm.get('inputPath').setValue(this.shareResponse?.cephfs?.path); - if (this.shareResponse.readonly) { - this.smbShareForm.get('readonly').setValue(this.shareResponse.readonly); - } - if (this.shareResponse.browseable) { - this.smbShareForm.get('browseable').setValue(this.shareResponse.browseable); - } + this.setBwLimitFromBytes('read_bw_limit', qos?.read_bw_limit); + this.setBwLimitFromBytes('write_bw_limit', qos?.write_bw_limit); - this.getSubVolGrp(this.shareResponse?.cephfs?.volume); + this.getSubVolGrp(cephfs?.volume); }); } + this.smbShareForm.get('read_bw_limit_unit').valueChanges.subscribe((unit: string) => { + this.readBwMax = getBwMaxForUnit(unit); + const ctrl = this.smbShareForm.get('read_bw_limit'); + ctrl.setValidators([Validators.min(0), Validators.max(this.readBwMax)]); + ctrl.updateValueAndValidity(); + }); + this.smbShareForm.get('write_bw_limit_unit').valueChanges.subscribe((unit: string) => { + this.writeBwMax = getBwMaxForUnit(unit); + const ctrl = this.smbShareForm.get('write_bw_limit'); + ctrl.setValidators([Validators.min(0), Validators.max(this.writeBwMax)]); + ctrl.updateValueAndValidity(); + }); this.smbShareForm.get('share_id')?.valueChanges.subscribe((value) => { const shareName = this.smbShareForm.get('name'); if (shareName && !shareName.dirty) { @@ -98,6 +138,12 @@ export class SmbShareFormComponent extends CdForm implements OnInit { this.loadingReady(); } + private setBwLimitFromBytes(prefix: string, bytes: number) { + const parts = this.dimlessBinaryPipe.transform(bytes).split(' '); + this.smbShareForm.get(prefix).setValue(parts[0] || 0); + this.smbShareForm.get(prefix + '_unit').setValue(parts[1] || QOS_BW_UNITS[1]); + } + createForm() { this.smbShareForm = this.formBuilder.group({ share_id: new FormControl('', { @@ -114,7 +160,21 @@ export class SmbShareFormComponent extends CdForm implements OnInit { validators: [Validators.required] }), browseable: new FormControl(true), - readonly: new FormControl(false) + readonly: new FormControl(false), + read_iops_limit: new FormControl(0, [Validators.min(0), Validators.max(QOS_IOPS_MAX)]), + write_iops_limit: new FormControl(0, [Validators.min(0), Validators.max(QOS_IOPS_MAX)]), + read_bw_limit: new FormControl(0, [Validators.min(0), Validators.max(this.readBwMax)]), + read_bw_limit_unit: new FormControl(QOS_BW_UNITS[1]), + write_bw_limit: new FormControl(0, [Validators.min(0), Validators.max(this.writeBwMax)]), + write_bw_limit_unit: new FormControl(QOS_BW_UNITS[1]), + read_delay_max: new FormControl(QOS_DELAY_DEFAULT, [ + Validators.min(0), + Validators.max(QOS_DELAY_MAX) + ]), + write_delay_max: new FormControl(QOS_DELAY_DEFAULT, [ + Validators.min(0), + Validators.max(QOS_DELAY_MAX) + ]) }); } @@ -219,7 +279,23 @@ export class SmbShareFormComponent extends CdForm implements OnInit { path: correctedPath, subvolumegroup: rawFormValue.subvolume_group, subvolume: rawFormValue.subvolume, - provider: PROVIDER + provider: PROVIDER, + qos: { + read_iops_limit: rawFormValue.read_iops_limit, + write_iops_limit: rawFormValue.write_iops_limit, + read_bw_limit: this.formatter.toBytes( + String(this.smbShareForm.get('read_bw_limit').value) + + ' ' + + this.smbShareForm.get('read_bw_limit_unit').value + ), + write_bw_limit: this.formatter.toBytes( + String(this.smbShareForm.get('write_bw_limit').value) + + ' ' + + this.smbShareForm.get('write_bw_limit_unit').value + ), + read_delay_max: rawFormValue.read_delay_max, + write_delay_max: rawFormValue.write_delay_max + } }, browseable: rawFormValue.browseable, readonly: rawFormValue.readonly diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.html index 0363545f229..709d88db126 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.html @@ -21,3 +21,21 @@ + + + R: {{ value?.read_iops_limit ? (value.read_iops_limit | number) : '-' }} + / W: {{ value?.write_iops_limit ? (value.write_iops_limit | number) : '-' }} + + + + R: {{ value?.read_bw_limit ? (value.read_bw_limit | dimlessBinary) : '-' }} + / W: {{ value?.write_bw_limit ? (value.write_bw_limit | dimlessBinary) : '-' }} + + + + R: {{ value?.read_delay_max ? value.read_delay_max + 's' : '-' }} + / W: {{ value?.write_delay_max ? value.write_delay_max + 's' : '-' }} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.ts index 0e910620cf3..7a799b0ca4b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Observable, BehaviorSubject, of } from 'rxjs'; import { switchMap, catchError } from 'rxjs/operators'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; @@ -32,6 +32,12 @@ export class SmbShareListComponent implements OnInit { clusterId: string; @ViewChild('table', { static: true }) table: TableComponent; + @ViewChild('iopsLimitTpl', { static: true }) + iopsLimitTpl: TemplateRef; + @ViewChild('bwLimitTpl', { static: true }) + bwLimitTpl: TemplateRef; + @ViewChild('delayMaxTpl', { static: true }) + delayMaxTpl: TemplateRef; columns: CdTableColumn[]; permission: Permission; selection = new CdTableSelection(); @@ -89,6 +95,24 @@ export class SmbShareListComponent implements OnInit { name: $localize`Provider`, prop: 'cephfs.provider', flexGrow: 2 + }, + { + name: $localize`IOPS Limit`, + prop: 'cephfs.qos', + cellTemplate: this.iopsLimitTpl, + flexGrow: 2 + }, + { + name: $localize`Bandwidth Limit`, + prop: 'cephfs.qos', + cellTemplate: this.bwLimitTpl, + flexGrow: 2 + }, + { + name: $localize`Delay Max`, + prop: 'cephfs.qos', + cellTemplate: this.delayMaxTpl, + flexGrow: 2 } ]; this.tableActions = [ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts index 99244e9ec1e..25c09205efe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts @@ -28,6 +28,7 @@ interface SMBCephfs { subvolumegroup?: string; subvolume?: string; provider?: string; + qos?: SMBShareQoS; } interface SMBShareLoginControl { @@ -77,6 +78,15 @@ export const PLACEMENT = { label: 'label' }; +export interface SMBShareQoS { + read_iops_limit?: number; + write_iops_limit?: number; + read_bw_limit?: number; + write_bw_limit?: number; + read_delay_max?: number; + write_delay_max?: number; +} + export interface SMBShare { resource_type: string; cluster_id: string; @@ -90,20 +100,6 @@ export interface SMBShare { login_control?: SMBShareLoginControl; } -interface SMBCephfs { - volume: string; - path: string; - subvolumegroup?: string; - subvolume?: string; - provider?: string; -} - -interface SMBShareLoginControl { - name: string; - access: 'read' | 'read-write' | 'none' | 'admin'; - category?: typeof USER | 'group'; -} - export interface SMBJoinAuth { resource_type: string; auth_id: string; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 45fe3a93e4a..61fc91830ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -125,6 +125,7 @@ import CheckMarkOutline16 from '@carbon/icons/es/checkmark--outline/16'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { PageHeaderComponent } from './page-header/page-header.component'; import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component'; +import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.component'; @NgModule({ imports: [ @@ -223,7 +224,8 @@ import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.componen TearsheetComponent, TearsheetStepComponent, PageHeaderComponent, - SidebarLayoutComponent + SidebarLayoutComponent, + NumberWithUnitComponent ], providers: [provideCharts(withDefaultRegisterables())], exports: [ @@ -270,7 +272,8 @@ import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.componen TearsheetComponent, TearsheetStepComponent, PageHeaderComponent, - SidebarLayoutComponent + SidebarLayoutComponent, + NumberWithUnitComponent ] }) export class ComponentsModule { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.html new file mode 100644 index 00000000000..768f87e5cde --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.html @@ -0,0 +1,30 @@ +
+
+ + +
+
+ + @for (unit of units; track unit) { + + } + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.ts new file mode 100644 index 00000000000..5bc42d1c2e4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.ts @@ -0,0 +1,60 @@ +import { Component, Input, Optional, TemplateRef } from '@angular/core'; +import { ControlContainer, FormGroup } from '@angular/forms'; + +@Component({ + selector: 'cd-number-with-unit', + templateUrl: './number-with-unit.component.html', + styleUrls: ['./number-with-unit.component.scss'], + standalone: false +}) +export class NumberWithUnitComponent { + @Input() + form: FormGroup; + + @Input() + valueControlName: string; + + /** Resolved form: parent form from ControlContainer when inside a form, otherwise @Input() form. */ + get formGroup(): FormGroup { + const parent = this.controlContainer?.control as FormGroup | undefined; + return parent ?? this.form; + } + + constructor(@Optional() private controlContainer: ControlContainer) {} + + @Input() + unitControlName: string; + + @Input() + label: string; + + @Input() + helperText: string; + + @Input() + units: string[] = ['KiB', 'MiB', 'GiB', 'TiB']; + + @Input() + min: number = 0; + + @Input() + max: number | undefined; + + @Input() + unitLabel: string = $localize`Unit`; + + @Input() + invalidText: string | TemplateRef = $localize`The value should be greater or equal to 0`; + + get valueControl() { + return this.formGroup?.get(this.valueControlName); + } + + get unitControl() { + return this.formGroup?.get(this.unitControlName); + } + + get invalidTextIsTemplate(): boolean { + return this.invalidText instanceof TemplateRef; + } +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 5e1fc41fb3a..7da76e63ed7 100755 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -23506,6 +23506,15 @@ paths: name: description: Name of the share type: string + read_bw_limit: + description: 'QoS: max read bandwidth B/s (0=disabled)' + type: integer + read_delay_max: + description: 'QoS: max read delay in seconds (0-300)' + type: integer + read_iops_limit: + description: 'QoS: max read IOPS (0=disabled)' + type: integer readonly: description: Indicates if the share is read-only type: boolean @@ -23515,6 +23524,15 @@ paths: share_id: description: Unique identifier for the share type: string + write_bw_limit: + description: 'QoS: max write bandwidth B/s (0=disabled)' + type: integer + write_delay_max: + description: 'QoS: max write delay in seconds (0-300)' + type: integer + write_iops_limit: + description: 'QoS: max write IOPS (0=disabled)' + type: integer required: &id158 - resource_type - cluster_id @@ -23524,6 +23542,12 @@ paths: - readonly - browseable - cephfs + - read_iops_limit + - write_iops_limit + - read_bw_limit + - write_bw_limit + - read_delay_max + - write_delay_max type: object application/vnd.ceph.api.v1.0+json: schema: @@ -23561,6 +23585,15 @@ paths: name: description: Name of the share type: string + read_bw_limit: + description: 'QoS: max read bandwidth B/s (0=disabled)' + type: integer + read_delay_max: + description: 'QoS: max read delay in seconds (0-300)' + type: integer + read_iops_limit: + description: 'QoS: max read IOPS (0=disabled)' + type: integer readonly: description: Indicates if the share is read-only type: boolean @@ -23570,6 +23603,15 @@ paths: share_id: description: Unique identifier for the share type: string + write_bw_limit: + description: 'QoS: max write bandwidth B/s (0=disabled)' + type: integer + write_delay_max: + description: 'QoS: max write delay in seconds (0-300)' + type: integer + write_iops_limit: + description: 'QoS: max write IOPS (0=disabled)' + type: integer required: *id158 type: object description: OK @@ -23655,6 +23697,15 @@ paths: name: description: Name of the share type: string + read_bw_limit: + description: 'QoS: max read bandwidth B/s (0=disabled)' + type: integer + read_delay_max: + description: 'QoS: max read delay in seconds (0-300)' + type: integer + read_iops_limit: + description: 'QoS: max read IOPS (0=disabled)' + type: integer readonly: description: Indicates if the share is read-only type: boolean @@ -23664,6 +23715,15 @@ paths: share_id: description: Unique identifier for the share type: string + write_bw_limit: + description: 'QoS: max write bandwidth B/s (0=disabled)' + type: integer + write_delay_max: + description: 'QoS: max write delay in seconds (0-300)' + type: integer + write_iops_limit: + description: 'QoS: max write IOPS (0=disabled)' + type: integer required: &id160 - resource_type - cluster_id @@ -23673,6 +23733,12 @@ paths: - readonly - browseable - cephfs + - read_iops_limit + - write_iops_limit + - read_bw_limit + - write_bw_limit + - read_delay_max + - write_delay_max type: object state: description: The current state of the resource, e.g., @@ -23738,6 +23804,15 @@ paths: name: description: Name of the share type: string + read_bw_limit: + description: 'QoS: max read bandwidth B/s (0=disabled)' + type: integer + read_delay_max: + description: 'QoS: max read delay in seconds (0-300)' + type: integer + read_iops_limit: + description: 'QoS: max read IOPS (0=disabled)' + type: integer readonly: description: Indicates if the share is read-only type: boolean @@ -23747,6 +23822,15 @@ paths: share_id: description: Unique identifier for the share type: string + write_bw_limit: + description: 'QoS: max write bandwidth B/s (0=disabled)' + type: integer + write_delay_max: + description: 'QoS: max write delay in seconds (0-300)' + type: integer + write_iops_limit: + description: 'QoS: max write IOPS (0=disabled)' + type: integer required: *id160 type: object state: @@ -23788,6 +23872,78 @@ paths: summary: Create smb share tags: - SMB + /api/smb/share/qos: + put: + description: "\n Update QoS rate limit parameters for an SMB share.\n\ + \ Omitted parameters are left unchanged. Use 0 to disable a limit.\n\ + \ " + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + cluster_id: + description: Unique identifier for the cluster + type: string + read_bw_limit: + description: Max read bandwidth B/s (0=disabled) + type: integer + read_delay_max: + description: Max read delay in seconds (0-300) + type: integer + read_iops_limit: + description: Max read IOPS (0=disabled, 0-1000000) + type: integer + share_id: + description: Unique identifier for the share + type: string + write_bw_limit: + description: Max write bandwidth B/s (0=disabled) + type: integer + write_delay_max: + description: Max write delay in seconds (0-300) + type: integer + write_iops_limit: + description: Max write IOPS (0=disabled, 0-1000000) + type: integer + required: + - cluster_id + - share_id + type: object + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource updated. + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + 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: [] + summary: Update SMB share QoS rate limiting + tags: + - SMB /api/smb/share/{cluster_id}/{share_id}: delete: description: "\n Remove an smb share from a given cluster\n\n \ @@ -23900,6 +24056,15 @@ paths: name: description: Name of the share type: string + read_bw_limit: + description: 'QoS: max read bandwidth B/s (0=disabled)' + type: integer + read_delay_max: + description: 'QoS: max read delay in seconds (0-300)' + type: integer + read_iops_limit: + description: 'QoS: max read IOPS (0=disabled)' + type: integer readonly: description: Indicates if the share is read-only type: boolean @@ -23909,6 +24074,15 @@ paths: share_id: description: Unique identifier for the share type: string + write_bw_limit: + description: 'QoS: max write bandwidth B/s (0=disabled)' + type: integer + write_delay_max: + description: 'QoS: max write delay in seconds (0-300)' + type: integer + write_iops_limit: + description: 'QoS: max write IOPS (0=disabled)' + type: integer required: &id164 - resource_type - cluster_id @@ -23918,6 +24092,12 @@ paths: - readonly - browseable - cephfs + - read_iops_limit + - write_iops_limit + - read_bw_limit + - write_bw_limit + - read_delay_max + - write_delay_max type: object application/vnd.ceph.api.v1.0+json: schema: @@ -23955,6 +24135,15 @@ paths: name: description: Name of the share type: string + read_bw_limit: + description: 'QoS: max read bandwidth B/s (0=disabled)' + type: integer + read_delay_max: + description: 'QoS: max read delay in seconds (0-300)' + type: integer + read_iops_limit: + description: 'QoS: max read IOPS (0=disabled)' + type: integer readonly: description: Indicates if the share is read-only type: boolean @@ -23964,6 +24153,15 @@ paths: share_id: description: Unique identifier for the share type: string + write_bw_limit: + description: 'QoS: max write bandwidth B/s (0=disabled)' + type: integer + write_delay_max: + description: 'QoS: max write delay in seconds (0-300)' + type: integer + write_iops_limit: + description: 'QoS: max write IOPS (0=disabled)' + type: integer required: *id164 type: object description: OK diff --git a/src/pybind/mgr/dashboard/tests/test_smb.py b/src/pybind/mgr/dashboard/tests/test_smb.py index 28a0db031a9..40b5749d1f9 100644 --- a/src/pybind/mgr/dashboard/tests/test_smb.py +++ b/src/pybind/mgr/dashboard/tests/test_smb.py @@ -220,6 +220,53 @@ class SMBShareTest(ControllerTestCase): self.assertStatus(204) mgr.remote.assert_called_once_with('smb', 'apply_resources', json.dumps(_res_simplified)) + def test_get_share_with_qos(self): + import copy + share_with_qos = copy.deepcopy(self._shares['resources'][0]) + share_with_qos['cephfs']['qos'] = { + 'read_iops_limit': 1000, + 'write_iops_limit': 500, + 'read_bw_limit': 100000, + 'write_bw_limit': 100000, + 'read_delay_max': 30, + 'write_delay_max': 60, + } + mgr.remote = Mock(return_value=share_with_qos) + + self._get(f'{self._endpoint}/smbCluster1/share1') + self.assertStatus(200) + self.assertJsonBody(share_with_qos) + mgr.remote.assert_called_once_with( + 'smb', 'show', ['ceph.smb.share.smbCluster1.share1'] + ) + + def test_update_qos(self): + cluster_id = 'smbCluster1' + share_id = 'share1' + + mock_result = Mock() + mock_result.to_simplified.return_value = { + 'resource_type': 'ceph.smb.share', + 'cluster_id': cluster_id, + 'share_id': share_id, + } + mgr.remote = Mock(return_value=mock_result) + + qos_body = { + 'cluster_id': cluster_id, + 'share_id': share_id, + 'read_iops_limit': 1000, + 'write_iops_limit': 500, + } + self._put(f'{self._endpoint}/qos', qos_body) + self.assertStatus(200) + + mgr.remote.assert_called_once_with( + 'smb', 'share_update_qos', + cluster_id, share_id, + 1000, 500, None, None, None, None + ) + class SMBJoinAuthTest(ControllerTestCase): _endpoint = '/api/smb/joinauth'