-
Notifications
You must be signed in to change notification settings - Fork 48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New NVMeOf CLI #352
New NVMeOf CLI #352
Conversation
097833d
to
a474cf5
Compare
630d2f1
to
0e64c56
Compare
Fixes ceph#151 Signed-off-by: Gil Bregman <gbregman@il.ibm.com>
@@ -1,5 +1,5 @@ | |||
# Globals | |||
VERSION="0.0.6" | |||
VERSION="0.0.7" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the amount of (breaking) changes this PR brought (CLI and server API), I'd have increased the major version number (1.0.0).
run: | | ||
shopt -s expand_aliases | ||
eval $(make alias) | ||
ha_status=$(cephnvmf --output stdio --format json subsystem list | grep "enable_ha" | grep "true" | tr -d '\n\r') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
jq
is a great tool to parse JSON data. Something like this:
ha_status=$(cephnvmf --output stdio --format json subsystem list | grep "enable_ha" | grep "true" | tr -d '\n\r') | |
ha_status=$(cephnvmf --output stdio --format json subsystem list | jq '.enable_ha' |
def parse_version_string(self, version): | ||
if not version: | ||
return None | ||
try: | ||
vlist = version.split(".") | ||
if len(vlist) != 3: | ||
raise Exception | ||
v1 = int(vlist[0]) | ||
v2 = int(vlist[1]) | ||
v3 = int(vlist[2]) | ||
except Exception: | ||
return None | ||
return (v1, v2, v3) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pkg_resources.parse_version
would do the trick but Python 3.12 deprecated pkg_resouces
, so we could use packaging.version.parse
instead.
if gw_info.status == 0: | ||
base_ver = self.parse_version_string(BASE_GATEWAY_VERSION) | ||
assert base_ver != None | ||
gw_ver = self.parse_version_string(gw_info.version) | ||
if gw_ver == None: | ||
gw_info.status = errno.EINVAL | ||
gw_info.bool_status = False | ||
gw_info.error_message = f"Can't parse gateway version \"{gw_info.version}\"." | ||
elif gw_ver < base_ver: | ||
gw_info.status = errno.EINVAL | ||
gw_info.bool_status = False | ||
gw_info.error_message = f"Can't work with gateway version older than {BASE_GATEWAY_VERSION}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO it should be the server who states whether the client version is compatible with it, rather than the other way around.
That said, gw_ver < base_ver
looks like a very gentle version support. I'd limit this to a minor version comparison, while different major versions is not supported (this PR is a very good example of a situation where a newer client cannot communicate with an older GW server.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@epuertat there is also some code in grpc.py to return an error in case the CLI version is older than the gateway's one. But, we needed a way to give an error in case we use a gateway version older than 0.0.7 as the GRPC protocol is not compatible. As in such a case we can't rely on the server side, being older we need some code in the CLI side. I made sure that the "gw info" request remains compatible with the old versions so we could have that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok! 👍🏼
def main_test(args): | ||
if not args: | ||
return None | ||
try: | ||
i = args.index("--format") | ||
del args[i:i + 2] | ||
except Exception: | ||
pass | ||
args = ["--format", "python"] + args | ||
client = GatewayClient() | ||
parsed_args = client.cli.parser.parse_args(args) | ||
if parsed_args.subcommand is None: | ||
return None | ||
|
||
return main_common(client, parsed_args) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dead code? Aren't we running a linter or something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@epuertat this is used in the test_cli.py code. I needed a way to just get the python object into a python script without getting a json string and parsing it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general we shouldn't put test code in production artifacts. Python, unlike other languages, is flexible enough not to need test code/branching inside production code.
In any case, it looks like it has to do with the output format and that "hack" of --format=python
to allow by-passing Python objects for testing purposes (is there any other reason?).
I checked and the args.format ==
expression appears 169 times in the cli.py
file:
if args.format == "text" or args.format == "plain":
if ret.status == 0:
if args.nsid:
ns_id_str = f"{args.nsid}"
elif args.uuid:
ns_id_str = f"with UUID {args.uuid}"
else:
assert False
out_func(f"Changing load balancing group of namespace {ns_id_str} in {args.subsystem} to {args.load_balancing_group}: Successful")
else:
err_func(f"{ret.error_message}")
elif args.format == "json" or args.format == "yaml":
ret_str = json_format.MessageToJson(
ret,
indent=4,
including_default_value_fields=True,
preserving_proto_field_name=True)
if args.format == "json":
out_func(f"{ret_str}")
elif args.format == "yaml":
obj = json.loads(ret_str)
out_func(yaml.dump(obj))
elif args.format == "python":
return ret
else:
assert False
First of all, I'd follow as much as possible the Unix approach: whenever a command worked as expected, no output is shown.
That aside, rather than doing this checking inside every command, you could use a declarative approach and a Formatter
-like (singleton) class/decorator, where you just return the object, and describe how you wanted it to be formatted:
@cli.formatter(
text=lambda x: f"Version is {x}")
)
def version(self, args):
...
return ret
The formatter
class would:
- read the
--format
arg directly fromsys.argv
or explicitly at instantiation. - format the output by running the input callables
text
,json
,yaml
, ... - if not provided, inspect the passed object for magic methods like
__str__
,__json__
,__yaml__
, and invoke them, - otherwise, would implement default methods for serializing to
text
,json
,yaml
, etc.
Something like that would reduce the code here and might create a more consistent output format. For the format=python
case, just mocking the Formatter
in the test files would be enough.
What do you think, @gbregman ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@epuertat I followed the guidelines from the design documents. I'm sure that my Python code could be improved as I'm not that fluent in Python.
def ns_get_io_stats(self, args): | ||
"""Get namespace IO statistics.""" | ||
|
||
out_func, err_func = self.get_output_functions(args) | ||
if not args.nsid and not args.uuid: | ||
self.cli.parser.error("At least one of --nsid or --uuid arguments is mandatory for get_io_stats command") | ||
if args.nsid and args.nsid < 0: | ||
self.cli.parser.error("nsid value must be positive") | ||
if args.size != None: | ||
self.cli.parser.error("--size argument is not allowed for get_io_stats command") | ||
if args.block_size != None: | ||
self.cli.parser.error("--block-size argument is not allowed for get_io_stats command") | ||
if args.rbd_pool != None: | ||
self.cli.parser.error("--rbd-pool argument is not allowed for get_io_stats command") | ||
if args.rbd_image != None: | ||
self.cli.parser.error("--rbd-image argument is not allowed for get_io_stats command") | ||
if args.load_balancing_group != None: | ||
self.cli.parser.error("--load-balancing-group argument is not allowed for get_io_stats command") | ||
if args.rw_ios_per_second != None: | ||
self.cli.parser.error("--rw-ios-per-second argument is not allowed for get_io_stats command") | ||
if args.rw_megabytes_per_second != None: | ||
self.cli.parser.error("--rw-megabytes-per-second argument is not allowed for get_io_stats command") | ||
if args.r_megabytes_per_second != None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First of all, I don't like too much the way the @cli.cmd
decorator works, as it obfuscates the actual parameters accepted by a method, and complicates debugging.
I've checked the most popular Python cli parsing tools, and looks like the closest to this would be click
:
@click.group
def ns():
pass
@ns.command('add')
@click.option('--rbd-pool', default="rbd", help="RBD pool name")
@click.argument('subsystem', help="Subsystem NQN")
def ns_add(rbd_pool, subsystem):
...
@ns.command('list')
@click.option('--rbd-pool', default="rbd", help="RBD pool name")
def ns_list(rbd_pool)
...
|
||
return main_common(client, parsed_args) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit concerned by the fact that the cli file has quadrupled its size... I understand that there's a lot of code to unwrap and format the data for human-readable output, but it results in a tightly coupling with the server, and that always complicates cross version support.
As I suggested before, I'd try to reduce this file size by using some other CLI parsing tool, like click
(I'm almost sure that alone might halve the length of this file) and maybe using some templating engine for textual outputs (jinja2
, humanize
for the unit conversion, or tabulate
as already used here).
Fixes #151
Originally this was PR #349