Skip to content

Commit 10326dc

Browse files
authored
Merge pull request ceph#61786 from Hezko/ns-create-size-fix
mgr/dashboard: Ns create size fix
2 parents 8239a86 + 21e41fb commit 10326dc

File tree

5 files changed

+276
-5
lines changed

5 files changed

+276
-5
lines changed

src/pybind/ceph_argparse.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
1010
LGPL-2.1 or LGPL-3.0. See file COPYING.
1111
"""
12+
from abc import ABC, abstractmethod
1213
import copy
1314
import enum
1415
import math
16+
import itertools
1517
import json
1618
import os
1719
import pprint
@@ -23,7 +25,11 @@
2325
import uuid
2426

2527
from collections import abc
26-
from typing import cast, Any, Callable, Dict, Generic, List, Optional, Sequence, Tuple, Union
28+
from typing import cast, Any, Callable, Dict, Generic, List, Optional, Sequence, Tuple, Union, \
29+
Annotated, Generic, TypeVar
30+
31+
S = TypeVar('S') # Input type
32+
T = TypeVar('T') # Output type
2733

2834
if sys.version_info >= (3, 8):
2935
from typing import get_args, get_origin
@@ -113,6 +119,20 @@ class JsonFormat(Exception):
113119
pass
114120

115121

122+
class Converter(ABC, Generic[S, T]):
123+
@abstractmethod
124+
def convert(self, value: S) -> T: pass
125+
126+
127+
def _get_annotation_metadata(tp):
128+
if get_origin(tp) is Annotated:
129+
annotated_args = get_args(tp)
130+
try:
131+
return annotated_args[1]
132+
except IndexError:
133+
return None
134+
135+
116136
class CephArgtype(object):
117137
"""
118138
Base class for all Ceph argument types
@@ -193,6 +213,10 @@ def to_argdesc(tp, attrs, has_default=False, positional=True):
193213
attrs['req'] = 'false'
194214
if not positional:
195215
attrs['positional'] = 'false'
216+
if annotation := _get_annotation_metadata(tp):
217+
if isinstance(annotation, CephArgtype):
218+
return annotation.argdesc(attrs)
219+
196220
CEPH_ARG_TYPES = {
197221
str: CephString,
198222
int: CephInt,
@@ -232,6 +256,10 @@ def _cast_to_compound_type(tp, v):
232256

233257
@staticmethod
234258
def cast_to(tp, v):
259+
if annotation := _get_annotation_metadata(tp):
260+
if isinstance(annotation, Converter):
261+
return annotation.convert(v)
262+
235263
PYTHON_TYPES = (
236264
str,
237265
int,
@@ -364,6 +392,72 @@ def argdesc(self, attrs):
364392
return super().argdesc(attrs)
365393

366394

395+
class CephSizeBytes(CephArgtype, Converter[str, int]):
396+
"""
397+
Size in bytes. e.g. 1024, 1KB, 100MB, etc..
398+
"""
399+
MULTIPLES = ['', "K", "M", "G", "T", "P"]
400+
UNITS = {
401+
f"{prefix}{suffix}": 1024 ** mult
402+
for mult, prefix in enumerate(MULTIPLES)
403+
for suffix in ['', 'B', 'iB']
404+
if not (prefix == '' and suffix == 'iB')
405+
}
406+
407+
def __init__(self, units_types: set = None):
408+
self.units = set(CephSizeBytes.UNITS.keys())
409+
if units_types :
410+
self.units &= units_types
411+
self.re_exp = re.compile(f"r'^\d+({'|'.join(self.units)})?$'")
412+
self.number_re_exp = re.compile(r'^\d+')
413+
self.num_and_unit_re_exp = re.compile(r'(\d+)([A-Za-z]*)$')
414+
415+
416+
def valid(self, s: str, partial: bool = False) -> None:
417+
if not s:
418+
raise ArgumentValid("Size string not provided.")
419+
420+
number_str = ''.join(itertools.takewhile(str.isdigit, s))
421+
if not number_str:
422+
raise ArgumentFormat("Size must start with a positive number.")
423+
424+
unit = s[len(number_str):]
425+
if unit and not unit.isalpha():
426+
raise ArgumentFormat("Invalid format. Expected format: <number>[KB|MB|GB] (e.g., 100MB, 10KB, 1000).")
427+
428+
if unit and unit not in self.units:
429+
raise ArgumentValid(f'{unit} is not a valid size unit. Supported units: {self.units}')
430+
self.val = s
431+
432+
def __str__(self) -> str:
433+
b = '|'.join(self.units)
434+
return '<sizebytes({0})>'.format(b)
435+
436+
def argdesc(self, attrs):
437+
return super().argdesc(attrs)
438+
439+
@staticmethod
440+
def _convert_to_bytes(size: Union[int, str], default_unit=None):
441+
if isinstance(size, int):
442+
number = size
443+
size = str(size)
444+
else:
445+
num_str = ''.join(filter(str.isdigit, size))
446+
number = int(num_str)
447+
unit_str = ''.join(filter(str.isalpha, size))
448+
if not unit_str:
449+
if not default_unit:
450+
raise ValueError("No size unit was provided")
451+
unit_str = default_unit
452+
453+
if unit_str in CephSizeBytes.UNITS:
454+
return number * CephSizeBytes.UNITS[unit_str]
455+
raise ValueError(f"Invalid unit: {unit_str}")
456+
457+
def convert(self, value: str) -> int:
458+
return CephSizeBytes._convert_to_bytes(value, default_unit="B")
459+
460+
367461
class CephSocketpath(CephArgtype):
368462
"""
369463
Admin socket path; check that it's readable and S_ISSOCK

src/pybind/mgr/dashboard/controllers/nvmeof.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# -*- coding: utf-8 -*-
22
import logging
3-
from typing import Any, Dict, Optional
3+
from typing import Annotated, Any, Dict, Optional
44

55
import cherrypy
6+
from ceph_argparse import CephSizeBytes
67
from orchestrator import OrchestratorError
78

89
from .. import mgr
@@ -385,8 +386,8 @@ def create(
385386
rbd_image_name: str,
386387
rbd_pool: str = "rbd",
387388
create_image: Optional[bool] = True,
388-
size: Optional[int] = 1024,
389-
rbd_image_size: Optional[int] = None,
389+
size: Optional[int] = None,
390+
rbd_image_size: Optional[Annotated[int, CephSizeBytes()]] = None,
390391
trash_image: Optional[bool] = False,
391392
block_size: int = 512,
392393
load_balancing_group: Optional[int] = None,

src/pybind/mgr/dashboard/openapi.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9111,7 +9111,6 @@ paths:
91119111
description: RBD pool name
91129112
type: string
91139113
size:
9114-
default: 1024
91159114
description: RBD image size
91169115
type: integer
91179116
traddr:
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import enum
2+
import pytest
3+
from ceph_argparse import CephSizeBytes, CephArgtype
4+
5+
class TestCephArgtypeArgdesc:
6+
7+
def test_to_argdesc_with_default(self):
8+
attrs = {}
9+
result = CephArgtype.to_argdesc(str, attrs, has_default=True)
10+
assert result == "req=false,type=CephString"
11+
12+
def test_to_argdesc_without_default(self):
13+
attrs = {}
14+
result = CephArgtype.to_argdesc(str, attrs, has_default=False)
15+
assert result == "type=CephString"
16+
17+
def test_to_argdesc_positional_false(self):
18+
attrs = {}
19+
result = CephArgtype.to_argdesc(str, attrs, positional=False)
20+
assert result == "positional=false,type=CephString"
21+
22+
def test_to_argdesc_str(self):
23+
attrs = {}
24+
result = CephArgtype.to_argdesc(str, attrs)
25+
assert result == "type=CephString"
26+
27+
def test_to_argdesc_int(self):
28+
attrs = {}
29+
result = CephArgtype.to_argdesc(int, attrs)
30+
assert result == "type=CephInt"
31+
32+
def test_to_argdesc_float(self):
33+
attrs = {}
34+
result = CephArgtype.to_argdesc(float, attrs)
35+
assert result == "type=CephFloat"
36+
37+
def test_to_argdesc_bool(self):
38+
attrs = {}
39+
result = CephArgtype.to_argdesc(bool, attrs)
40+
assert result == "type=CephBool"
41+
42+
def test_to_argdesc_invalid_type(self):
43+
attrs = {}
44+
# Simulate an invalid type that isn't in CEPH_ARG_TYPES
45+
with pytest.raises(ValueError):
46+
CephArgtype.to_argdesc(object, attrs)
47+
48+
def test_to_argdesc_with_enum(self):
49+
import enum
50+
class MyEnum(enum.Enum):
51+
A = "one"
52+
B = "two"
53+
54+
attrs = {}
55+
result = CephArgtype.to_argdesc(MyEnum, attrs)
56+
assert result == "strings=one|two,type=CephChoices"
57+
58+
59+
class TestCephArgtypeCastTo:
60+
def test_cast_to_with_str(self):
61+
result = CephArgtype.cast_to(str, 123)
62+
assert result == "123"
63+
64+
def test_cast_to_with_int(self):
65+
result = CephArgtype.cast_to(int, "123")
66+
assert result == 123
67+
68+
def test_cast_to_with_float(self):
69+
result = CephArgtype.cast_to(float, "123.45")
70+
assert result == 123.45
71+
72+
def test_cast_to_with_bool(self):
73+
result = CephArgtype.cast_to(bool, "True")
74+
assert result is True
75+
76+
def test_cast_to_with_enum(self):
77+
class MyEnum(enum.Enum):
78+
A = "one"
79+
B = "two"
80+
81+
result = CephArgtype.cast_to(MyEnum, "one")
82+
assert result == MyEnum.A
83+
84+
def test_cast_to_with_unknown_type(self):
85+
class UnknownType:
86+
pass
87+
88+
with pytest.raises(ValueError):
89+
CephArgtype.cast_to(UnknownType, "value")
90+
91+
def test_cast_to_invalid_value_for_type(self):
92+
with pytest.raises(ValueError):
93+
CephArgtype.cast_to(int, "invalid_integer")
94+
95+
96+
class TestConvertToBytes:
97+
def test_with_kb(self):
98+
assert CephSizeBytes._convert_to_bytes(f"100KB") == 102400
99+
assert CephSizeBytes._convert_to_bytes(f"100K") == 102400
100+
101+
def test_with_mb(self):
102+
assert CephSizeBytes._convert_to_bytes(f"2MB") == 2 * 1024 ** 2
103+
assert CephSizeBytes._convert_to_bytes(f"2M") == 2 * 1024 ** 2
104+
105+
def test_with_gb(self):
106+
assert CephSizeBytes._convert_to_bytes(f"1GB") == 1024 ** 3
107+
assert CephSizeBytes._convert_to_bytes(f"1G") == 1024 ** 3
108+
109+
def test_with_tb(self):
110+
assert CephSizeBytes._convert_to_bytes(f"1TB") == 1024 ** 4
111+
assert CephSizeBytes._convert_to_bytes(f"1T") == 1024 ** 4
112+
113+
def test_with_pb(self):
114+
assert CephSizeBytes._convert_to_bytes(f"1PB") == 1024 ** 5
115+
assert CephSizeBytes._convert_to_bytes(f"1P") == 1024 ** 5
116+
117+
def test_with_integer(self):
118+
assert CephSizeBytes._convert_to_bytes(50, default_unit="B") == 50
119+
120+
def test_invalid_unit(self):
121+
with pytest.raises(ValueError):
122+
CephSizeBytes._convert_to_bytes("50XYZ")
123+
124+
def test_b(self):
125+
assert CephSizeBytes._convert_to_bytes(f"500B") == 500
126+
127+
def test_with_large_number(self):
128+
assert CephSizeBytes._convert_to_bytes(f"1000GB") == 1000 * 1024 ** 3
129+
130+
def test_no_number(self):
131+
with pytest.raises(ValueError):
132+
CephSizeBytes._convert_to_bytes("GB")
133+
134+
def test_no_unit_with_default_unit_gb(self):
135+
assert CephSizeBytes._convert_to_bytes("500", default_unit="GB") == 500 * 1024 ** 3
136+
137+
def test_no_unit_with_no_default_unit_raises(self):
138+
with pytest.raises(ValueError):
139+
CephSizeBytes._convert_to_bytes("500")
140+
141+
def test_unit_in_input_overrides_default(self):
142+
assert CephSizeBytes._convert_to_bytes("50", default_unit="KB") == 50 * 1024
143+
assert CephSizeBytes._convert_to_bytes("50KB", default_unit="KB") == 50 * 1024
144+
assert CephSizeBytes._convert_to_bytes("50MB", default_unit="KB") == 50 * 1024 ** 2
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from unittest.mock import MagicMock
2+
import json
3+
import pytest
4+
from typing import Annotated
5+
6+
from mgr_module import CLICommand
7+
from ceph_argparse import CephSizeBytes
8+
9+
@pytest.fixture(scope="class", name="command_with_size_annotation_name")
10+
def fixture_command_with_size_annotation_name():
11+
return "test annotated size command"
12+
13+
14+
@pytest.fixture(scope="class", name="command_with_size_annotation")
15+
def fixture_command_with_size_annotation(command_with_size_annotation_name):
16+
@CLICommand(command_with_size_annotation_name)
17+
def func(_, param: Annotated[int, CephSizeBytes()]): # noqa # pylint: disable=unused-variable
18+
return {'a': '1', 'param': param}
19+
yield func
20+
del CLICommand.COMMANDS[command_with_size_annotation_name]
21+
assert command_with_size_annotation_name not in CLICommand.COMMANDS
22+
23+
24+
class TestConvertAnnotatedType:
25+
def test_command_convert_annotated_parameter(self, command_with_size_annotation, command_with_size_annotation_name):
26+
result = CLICommand.COMMANDS[command_with_size_annotation_name].call(MagicMock(), {"param": f"{5 * 1024 ** 2}"})
27+
assert result['param'] == 5 * 1024 ** 2
28+
29+
result = CLICommand.COMMANDS[command_with_size_annotation_name].call(MagicMock(), {"param": f"{5 * 1024}KB"})
30+
assert result['param'] == 5 * 1024 ** 2
31+
32+
result = CLICommand.COMMANDS[command_with_size_annotation_name].call(MagicMock(), {"param": f"5MB"})
33+
assert result['param'] == 5 * 1024 ** 2

0 commit comments

Comments
 (0)