Skip to content
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

Merged
merged 1 commit into from
Jan 2, 2024
Merged

New NVMeOf CLI #352

merged 1 commit into from
Jan 2, 2024

Conversation

gbregman
Copy link
Collaborator

Fixes #151

Originally this was PR #349

@gbregman gbregman mentioned this pull request Dec 21, 2023
@gbregman gbregman force-pushed the devel branch 29 times, most recently from 097833d to a474cf5 Compare December 27, 2023 10:50
Fixes ceph#151

Signed-off-by: Gil Bregman <gbregman@il.ibm.com>
@gbregman gbregman requested a review from baum January 1, 2024 19:10
@gbregman gbregman self-assigned this Jan 1, 2024
@gbregman gbregman merged commit 57e23c9 into ceph:devel Jan 2, 2024
14 checks passed
@@ -1,5 +1,5 @@
# Globals
VERSION="0.0.6"
VERSION="0.0.7"
Copy link
Member

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')
Copy link
Member

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:

Suggested change
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'

Comment on lines +264 to +276
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)
Copy link
Member

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.

Comment on lines +282 to +293
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}"
Copy link
Member

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.

Copy link
Collaborator Author

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! 👍🏼

Comment on lines +1770 to +1784
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)
Copy link
Member

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?

Copy link
Collaborator Author

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.

Copy link
Member

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 from sys.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 ?

Copy link
Collaborator Author

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.

Comment on lines +1471 to +1493
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:
Copy link
Member

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)
Copy link
Member

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

cli: syntax improvements
3 participants