diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:54:28 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:54:28 +0000 |
commit | e6918187568dbd01842d8d1d2c808ce16a894239 (patch) | |
tree | 64f88b554b444a49f656b6c656111a145cbbaa28 /src/pybind/ceph_argparse.py | |
parent | Initial commit. (diff) | |
download | ceph-b26c4052f3542036551aa9dec9caa4226e456195.tar.xz ceph-b26c4052f3542036551aa9dec9caa4226e456195.zip |
Adding upstream version 18.2.2.upstream/18.2.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/pybind/ceph_argparse.py')
-rw-r--r-- | src/pybind/ceph_argparse.py | 1707 |
1 files changed, 1707 insertions, 0 deletions
diff --git a/src/pybind/ceph_argparse.py b/src/pybind/ceph_argparse.py new file mode 100644 index 000000000..b4aace3df --- /dev/null +++ b/src/pybind/ceph_argparse.py @@ -0,0 +1,1707 @@ +""" +Types and routines used by the ceph CLI as well as the RESTful +interface. These have to do with querying the daemons for +command-description information, validating user command input against +those descriptions, and submitting the command to the appropriate +daemon. + +Copyright (C) 2013 Inktank Storage, Inc. + +LGPL-2.1 or LGPL-3.0. See file COPYING. +""" +import copy +import enum +import math +import json +import os +import pprint +import re +import socket +import stat +import sys +import threading +import uuid + +from collections import abc +from typing import cast, Any, Callable, Dict, Generic, List, Optional, Sequence, Tuple, Union + +if sys.version_info >= (3, 8): + from typing import get_args, get_origin +else: + def get_args(tp): + if tp is Generic: + return tp + else: + return getattr(tp, '__args__', ()) + + def get_origin(tp): + return getattr(tp, '__origin__', None) + + +# Flags are from MonCommand.h +class Flag: + NOFORWARD = (1 << 0) + OBSOLETE = (1 << 1) + DEPRECATED = (1 << 2) + MGR = (1 << 3) + POLL = (1 << 4) + HIDDEN = (1 << 5) + + +KWARG_EQUALS = "--([^=]+)=(.+)" +KWARG_SPACE = "--([^=]+)" + +try: + basestring +except NameError: + basestring = str + + +class ArgumentError(Exception): + """ + Something wrong with arguments + """ + pass + + +class ArgumentNumber(ArgumentError): + """ + Wrong number of a repeated argument + """ + pass + + +class ArgumentFormat(ArgumentError): + """ + Argument value has wrong format + """ + pass + + +class ArgumentMissing(ArgumentError): + """ + Argument value missing in a command + """ + pass + + +class ArgumentValid(ArgumentError): + """ + Argument value is otherwise invalid (doesn't match choices, for instance) + """ + pass + + +class ArgumentTooFew(ArgumentError): + """ + Fewer arguments than descriptors in signature; may mean to continue + the search, so gets a special exception type + """ + + +class ArgumentPrefix(ArgumentError): + """ + Special for mismatched prefix; less severe, don't report by default + """ + pass + + +class JsonFormat(Exception): + """ + some syntactic or semantic issue with the JSON + """ + pass + + +class CephArgtype(object): + """ + Base class for all Ceph argument types + + Instantiating an object sets any validation parameters + (allowable strings, numeric ranges, etc.). The 'valid' + method validates a string against that initialized instance, + throwing ArgumentError if there's a problem. + """ + def __init__(self, **kwargs): + """ + set any per-instance validation parameters here + from kwargs (fixed string sets, integer ranges, etc) + """ + pass + + def valid(self, s, partial=False): + """ + Run validation against given string s (generally one word); + partial means to accept partial string matches (begins-with). + If cool, set self.val to the value that should be returned + (a copy of the input string, or a numeric or boolean interpretation + thereof, for example) + if not, throw ArgumentError(msg-as-to-why) + """ + self.val = s + + def __repr__(self): + """ + return string representation of description of type. Note, + this is not a representation of the actual value. Subclasses + probably also override __str__() to give a more user-friendly + 'name/type' description for use in command format help messages. + """ + return self.__class__.__name__ + + def __str__(self): + """ + where __repr__ (ideally) returns a string that could be used to + reproduce the object, __str__ returns one you'd like to see in + print messages. Use __str__ to format the argtype descriptor + as it would be useful in a command usage message. + """ + return '<{0}>'.format(self.__class__.__name__) + + def __call__(self, v): + return v + + def complete(self, s): + return [] + + @staticmethod + def _compound_type_to_argdesc(tp, attrs, positional): + # generate argdesc from Sequence[T], Tuple[T,..] and Optional[T] + orig_type = get_origin(tp) + type_args = get_args(tp) + if orig_type in (abc.Sequence, Sequence, List, list): + assert len(type_args) == 1 + attrs['n'] = 'N' + return CephArgtype.to_argdesc(type_args[0], attrs, positional=positional) + elif orig_type is Tuple: + assert len(type_args) >= 1 + inner_tp = type_args[0] + assert type_args.count(inner_tp) == len(type_args), \ + f'all elements in {tp} should be identical' + attrs['n'] = str(len(type_args)) + return CephArgtype.to_argdesc(inner_tp, attrs, positional=positional) + elif get_origin(tp) is Union: + # should be Union[t, NoneType] + assert len(type_args) == 2 and isinstance(None, type_args[1]) + return CephArgtype.to_argdesc(type_args[0], attrs, True, positional) + else: + raise ValueError(f"unknown type '{tp}': '{attrs}'") + + @staticmethod + def to_argdesc(tp, attrs, has_default=False, positional=True): + if has_default: + attrs['req'] = 'false' + if not positional: + attrs['positional'] = 'false' + CEPH_ARG_TYPES = { + str: CephString, + int: CephInt, + float: CephFloat, + bool: CephBool + } + try: + return CEPH_ARG_TYPES[tp]().argdesc(attrs) + except KeyError: + if isinstance(tp, CephArgtype): + return tp.argdesc(attrs) + elif isinstance(tp, type) and issubclass(tp, enum.Enum): + return CephChoices(tp=tp).argdesc(attrs) + else: + return CephArgtype._compound_type_to_argdesc(tp, attrs, positional) + + def argdesc(self, attrs): + attrs['type'] = type(self).__name__ + return ','.join(f'{k}={v}' for k, v in attrs.items()) + + @staticmethod + def _cast_to_compound_type(tp, v): + orig_type = get_origin(tp) + type_args = get_args(tp) + if orig_type in (abc.Sequence, Sequence, List, list): + if v is None: + return None + return [CephArgtype.cast_to(type_args[0], e) for e in v] + elif orig_type is Tuple: + return tuple(CephArgtype.cast_to(type_args[0], e) for e in v) + elif get_origin(tp) is Union: + # should be Union[t, NoneType] + assert len(type_args) == 2 and isinstance(None, type_args[1]) + return CephArgtype.cast_to(type_args[0], v) + else: + raise ValueError(f"unknown type '{tp}': '{v}'") + + @staticmethod + def cast_to(tp, v): + PYTHON_TYPES = ( + str, + int, + float, + bool + ) + if tp in PYTHON_TYPES: + return tp(v) + elif isinstance(tp, type) and issubclass(tp, enum.Enum): + return tp(v) + else: + return CephArgtype._cast_to_compound_type(tp, v) + + +class CephInt(CephArgtype): + """ + range-limited integers, [+|-][0-9]+ or 0x[0-9a-f]+ + range: list of 1 or 2 ints, [min] or [min,max] + """ + def __init__(self, range=''): + if range == '': + self.range = list() + else: + self.range = [int(x) for x in range.split('|')] + + def valid(self, s, partial=False): + try: + val = int(s, 0) + except ValueError: + raise ArgumentValid("{0} doesn't represent an int".format(s)) + if len(self.range) == 2: + if val < self.range[0] or val > self.range[1]: + raise ArgumentValid(f"{val} not in range {self.range}") + elif len(self.range) == 1: + if val < self.range[0]: + raise ArgumentValid(f"{val} not in range {self.range}") + self.val = val + + def __str__(self): + r = '' + if len(self.range) == 1: + r = '[{0}-]'.format(self.range[0]) + if len(self.range) == 2: + r = '[{0}-{1}]'.format(self.range[0], self.range[1]) + + return '<int{0}>'.format(r) + + def argdesc(self, attrs): + if self.range: + attrs['range'] = '|'.join(str(v) for v in self.range) + return super().argdesc(attrs) + + +class CephFloat(CephArgtype): + """ + range-limited float type + range: list of 1 or 2 floats, [min] or [min, max] + """ + def __init__(self, range=''): + if range == '': + self.range = list() + else: + self.range = [float(x) for x in range.split('|')] + + def valid(self, s, partial=False): + try: + val = float(s) + except ValueError: + raise ArgumentValid("{0} doesn't represent a float".format(s)) + if len(self.range) == 2: + if val < self.range[0] or val > self.range[1]: + raise ArgumentValid(f"{val} not in range {self.range}") + elif len(self.range) == 1: + if val < self.range[0]: + raise ArgumentValid(f"{val} not in range {self.range}") + self.val = val + + def __str__(self): + r = '' + if len(self.range) == 1: + r = '[{0}-]'.format(self.range[0]) + if len(self.range) == 2: + r = '[{0}-{1}]'.format(self.range[0], self.range[1]) + return '<float{0}>'.format(r) + + def argdesc(self, attrs): + if self.range: + attrs['range'] = '|'.join(str(v) for v in self.range) + return super().argdesc(attrs) + + +class CephString(CephArgtype): + """ + String; pretty generic. goodchars is a RE char class of valid chars + """ + def __init__(self, goodchars=''): + from string import printable + try: + re.compile(goodchars) + except re.error: + raise ValueError('CephString(): "{0}" is not a valid RE'. + format(goodchars)) + self.goodchars = goodchars + self.goodset = frozenset( + [c for c in printable if re.match(goodchars, c)] + ) + + def valid(self, s: str, partial: bool = False) -> None: + sset = set(s) + if self.goodset and not sset <= self.goodset: + raise ArgumentFormat("invalid chars {0} in {1}". + format(''.join(sset - self.goodset), s)) + self.val = s + + def __str__(self) -> str: + b = '' + if self.goodchars: + b += '(goodchars {0})'.format(self.goodchars) + return '<string{0}>'.format(b) + + def complete(self, s) -> List[str]: + if s == '': + return [] + else: + return [s] + + def argdesc(self, attrs): + if self.goodchars: + attrs['goodchars'] = self.goodchars + return super().argdesc(attrs) + + +class CephSocketpath(CephArgtype): + """ + Admin socket path; check that it's readable and S_ISSOCK + """ + def valid(self, s: str, partial: bool = False) -> None: + mode = os.stat(s).st_mode + if not stat.S_ISSOCK(mode): + raise ArgumentValid('socket path {0} is not a socket'.format(s)) + self.val = s + + def __str__(self) -> str: + return '<admin-socket-path>' + + +class CephIPAddr(CephArgtype): + """ + IP address (v4 or v6) with optional port + """ + def valid(self, s, partial=False): + # parse off port, use socket to validate addr + type = 6 + p: Optional[str] = None + if s.startswith('['): + type = 6 + elif s.find('.') != -1: + type = 4 + if type == 4: + port = s.find(':') + if port != -1: + a = s[:port] + p = s[port + 1:] + if int(p) > 65535: + raise ArgumentValid('{0}: invalid IPv4 port'.format(p)) + else: + a = s + p = None + try: + socket.inet_pton(socket.AF_INET, a) + except OSError: + raise ArgumentValid('{0}: invalid IPv4 address'.format(a)) + else: + # v6 + if s.startswith('['): + end = s.find(']') + if end == -1: + raise ArgumentFormat('{0} missing terminating ]'.format(s)) + if s[end + 1] == ':': + try: + p = s[end + 2] + except ValueError: + raise ArgumentValid('{0}: bad port number'.format(s)) + a = s[1:end] + else: + a = s + p = None + try: + socket.inet_pton(socket.AF_INET6, a) + except OSError: + raise ArgumentValid('{0} not valid IPv6 address'.format(s)) + if p is not None and int(p) > 65535: + raise ArgumentValid("{0} not a valid port number".format(p)) + self.val = s + self.addr = a + self.port = p + + def __str__(self): + return '<IPaddr[:port]>' + + +class CephEntityAddr(CephIPAddr): + """ + EntityAddress, that is, IP address[/nonce] + """ + def valid(self, s: str, partial: bool = False) -> None: + nonce = None + if '/' in s: + ip, nonce = s.split('/') + else: + ip = s + super(self.__class__, self).valid(ip) + if nonce: + nonce_int = None + try: + nonce_int = int(nonce) + except ValueError: + pass + if nonce_int is None or nonce_int < 0: + raise ArgumentValid( + '{0}: invalid entity, nonce {1} not integer > 0'. + format(s, nonce) + ) + self.val = s + + def __str__(self) -> str: + return '<EntityAddr>' + + +class CephPoolname(CephArgtype): + """ + Pool name; very little utility + """ + def __str__(self) -> str: + return '<poolname>' + + +class CephObjectname(CephArgtype): + """ + Object name. Maybe should be combined with Pool name as they're always + present in pairs, and then could be checked for presence + """ + def __str__(self) -> str: + return '<objectname>' + + +class CephPgid(CephArgtype): + """ + pgid, in form N.xxx (N = pool number, xxx = hex pgnum) + """ + def valid(self, s, partial=False): + if s.find('.') == -1: + raise ArgumentFormat('pgid has no .') + poolid_s, pgnum_s = s.split('.', 1) + try: + poolid = int(poolid_s) + except ValueError: + raise ArgumentFormat('pool {0} not integer'.format(poolid)) + if poolid < 0: + raise ArgumentFormat('pool {0} < 0'.format(poolid)) + try: + pgnum = int(pgnum_s, 16) + except ValueError: + raise ArgumentFormat('pgnum {0} not hex integer'.format(pgnum)) + self.val = s + + def __str__(self): + return '<pgid>' + + +class CephName(CephArgtype): + """ + Name (type.id) where: + type is osd|mon|client|mds + id is a base10 int, if type == osd, or a string otherwise + + Also accept '*' + """ + def __init__(self) -> None: + self.nametype: Optional[str] = None + self.nameid: Optional[str] = None + + def valid(self, s, partial=False): + if s == '*': + self.val = s + return + elif s == "mgr": + self.nametype = "mgr" + self.val = s + return + elif s == "mon": + self.nametype = "mon" + self.val = s + return + if s.find('.') == -1: + raise ArgumentFormat('CephName: no . in {0}'.format(s)) + else: + t, i = s.split('.', 1) + if t not in ('osd', 'mon', 'client', 'mds', 'mgr'): + raise ArgumentValid('unknown type ' + t) + if t == 'osd': + if i != '*': + try: + int(i) + except ValueError: + raise ArgumentFormat(f'osd id {i} not integer') + self.nametype = t + self.val = s + self.nameid = i + + def __str__(self): + return '<name (type.id)>' + + +class CephOsdName(CephArgtype): + """ + Like CephName, but specific to osds: allow <id> alone + + osd.<id>, or <id>, or *, where id is a base10 int + """ + def __init__(self): + self.nametype: Optional[str] = None + self.nameid: Optional[int] = None + + def valid(self, s, partial=False): + if s == '*': + self.val = s + return + if s.find('.') != -1: + t, i = s.split('.', 1) + if t != 'osd': + raise ArgumentValid('unknown type ' + t) + else: + t = 'osd' + i = s + try: + v = int(i) + except ValueError: + raise ArgumentFormat(f'osd id {i} not integer') + if v < 0: + raise ArgumentFormat(f'osd id {v} is less than 0') + self.nametype = t + self.nameid = v + self.val = v + + def __str__(self): + return '<osdname (id|osd.id)>' + + +class CephChoices(CephArgtype): + """ + Set of string literals; init with valid choices + """ + def __init__(self, strings='', tp=None, **kwargs): + self.strings = strings.split('|') + self.enum = tp + if self.enum is not None: + self.strings = list(e.value for e in self.enum) + + def valid(self, s, partial=False): + if not partial: + if s not in self.strings: + # show as __str__ does: {s1|s2..} + raise ArgumentValid("{0} not in {1}".format(s, self)) + self.val = s + return + + # partial + for t in self.strings: + if t.startswith(s): + self.val = s + return + raise ArgumentValid("{0} not in {1}". format(s, self)) + + def __str__(self): + if len(self.strings) == 1: + return '{0}'.format(self.strings[0]) + else: + return '{0}'.format('|'.join(self.strings)) + + def __call__(self, v): + if self.enum is None: + return v + else: + return self.enum[v] + + def complete(self, s): + all_elems = [token for token in self.strings if token.startswith(s)] + return all_elems + + def argdesc(self, attrs): + attrs['strings'] = '|'.join(self.strings) + return super().argdesc(attrs) + + +class CephBool(CephArgtype): + """ + A boolean argument, values may be case insensitive 'true', 'false', '0', + '1'. In keyword form, value may be left off (implies true). + """ + def __init__(self, strings='', **kwargs): + self.strings = strings.split('|') + + def valid(self, s, partial=False): + lower_case = s.lower() + if lower_case in ['true', '1']: + self.val = True + elif lower_case in ['false', '0']: + self.val = False + else: + raise ArgumentValid("{0} not one of 'true', 'false'".format(s)) + + def __str__(self): + return '<bool>' + + +class CephFilepath(CephArgtype): + """ + Openable file + """ + def valid(self, s, partial=False): + # set self.val if the specified path is readable or writable + s = os.path.abspath(s) + if not os.access(s, os.R_OK): + self._validate_writable_file(s) + self.val = s + + def _validate_writable_file(self, fname): + if os.path.exists(fname): + if os.path.isfile(fname): + if not os.access(fname, os.W_OK): + raise ArgumentValid('{0} is not writable'.format(fname)) + else: + raise ArgumentValid('{0} is not file'.format(fname)) + else: + dirname = os.path.dirname(fname) + if not os.access(dirname, os.W_OK): + raise ArgumentValid('cannot create file in {0}'.format(dirname)) + + def __str__(self): + return '<outfilename>' + + +class CephFragment(CephArgtype): + """ + 'Fragment' ??? XXX + """ + def valid(self, s, partial=False): + if s.find('/') == -1: + raise ArgumentFormat('{0}: no /'.format(s)) + val, bits = s.split('/') + # XXX is this right? + if not val.startswith('0x'): + raise ArgumentFormat("{0} not a hex integer".format(val)) + try: + int(val) + except ValueError: + raise ArgumentFormat('can\'t convert {0} to integer'.format(val)) + try: + int(bits) + except ValueError: + raise ArgumentFormat('can\'t convert {0} to integer'.format(bits)) + self.val = s + + def __str__(self): + return "<CephFS fragment ID (0xvvv/bbb)>" + + +class CephUUID(CephArgtype): + """ + CephUUID: pretty self-explanatory + """ + def valid(self, s: str, partial: bool = False) -> None: + try: + uuid.UUID(s) + except Exception as e: + raise ArgumentFormat('invalid UUID {0}: {1}'.format(s, e)) + self.val = s + + def __str__(self) -> str: + return '<uuid>' + + +class CephPrefix(CephArgtype): + """ + CephPrefix: magic type for "all the first n fixed strings" + """ + def __init__(self, prefix: str = '') -> None: + self.prefix = prefix + + def valid(self, s: str, partial: bool = False) -> None: + try: + s = str(s) + if isinstance(s, bytes): + # `prefix` can always be converted into unicode when being compared, + # but `s` could be anything passed by user. + s = s.decode('ascii') + except UnicodeEncodeError: + raise ArgumentPrefix(u"no match for {0}".format(s)) + except UnicodeDecodeError: + raise ArgumentPrefix("no match for {0}".format(s)) + + if partial: + if self.prefix.startswith(s): + self.val = s + return + else: + if s == self.prefix: + self.val = s + return + + raise ArgumentPrefix("no match for {0}".format(s)) + + def __str__(self) -> str: + return self.prefix + + def complete(self, s) -> List[str]: + if self.prefix.startswith(s): + return [self.prefix.rstrip(' ')] + else: + return [] + + +class argdesc(object): + """ + argdesc(typename, name='name', n=numallowed|N, + req=False, positional=True, + helptext=helptext, **kwargs (type-specific)) + + validation rules: + typename: type(**kwargs) will be constructed + later, type.valid(w) will be called with a word in that position + + name is used for parse errors and for constructing JSON output + n is a numeric literal or 'n|N', meaning "at least one, but maybe more" + req=False means the argument need not be present in the list + positional=False means the argument name must be specified, e.g. "--myoption value" + helptext is the associated help for the command + anything else are arguments to pass to the type constructor. + + self.instance is an instance of type t constructed with typeargs. + + valid() will later be called with input to validate against it, + and will store the validated value in self.instance.val for extraction. + """ + def __init__(self, t, name=None, n=1, req=True, positional=True, **kwargs) -> None: + if isinstance(t, basestring): + self.t = CephPrefix + self.typeargs = {'prefix': t} + self.req = True + self.positional = True + else: + self.t = t + self.typeargs = kwargs + self.req = req in (True, 'True', 'true') + self.positional = positional in (True, 'True', 'true') + if not positional: + assert not req + + self.name = name + self.N = (n in ['n', 'N']) + if self.N: + self.n = 1 + else: + self.n = int(n) + + self.numseen = 0 + + self.instance = self.t(**self.typeargs) + + def __repr__(self): + r = 'argdesc(' + str(self.t) + ', ' + internals = ['N', 'typeargs', 'instance', 't'] + for (k, v) in self.__dict__.items(): + if k.startswith('__') or k in internals: + pass + else: + # undo modification from __init__ + if k == 'n' and self.N: + v = 'N' + r += '{0}={1}, '.format(k, v) + for (k, v) in self.typeargs.items(): + r += '{0}={1}, '.format(k, v) + return r[:-2] + ')' + + def __str__(self): + if ((self.t == CephChoices and len(self.instance.strings) == 1) + or (self.t == CephPrefix)): + s = str(self.instance) + else: + s = '{0}({1})'.format(self.name, str(self.instance)) + if self.N: + s += '...' + if not self.req: + s = '[' + s + ']' + return s + + def helpstr(self): + """ + like str(), but omit parameter names (except for CephString, + which really needs them) + """ + if self.positional: + if self.t == CephBool: + chunk = "--{0}".format(self.name.replace("_", "-")) + elif self.t == CephPrefix: + chunk = str(self.instance) + elif self.t == CephChoices: + if self.name == 'format': + # this is for talking to legacy clusters only; new clusters + # should properly mark format args as non-positional. + chunk = f'--{self.name} {{{str(self.instance)}}}' + else: + chunk = f'<{self.name}:{self.instance}>' + elif self.t == CephOsdName: + # it just so happens all CephOsdName commands are named 'id' anyway, + # so <id|osd.id> is perfect. + chunk = '<id|osd.id>' + elif self.t == CephName: + # CephName commands similarly only have one arg of the + # type, so <type.id> is good. + chunk = '<type.id>' + elif self.t == CephInt: + chunk = '<{0}:int>'.format(self.name) + elif self.t == CephFloat: + chunk = '<{0}:float>'.format(self.name) + else: + chunk = '<{0}>'.format(self.name) + s = chunk + if self.N: + s += '...' + if not self.req: + s = '[' + s + ']' + else: + # non-positional + if self.t == CephBool: + chunk = "--{0}".format(self.name.replace("_", "-")) + elif self.t == CephPrefix: + chunk = str(self.instance) + elif self.t == CephChoices: + chunk = f'--{self.name} {{{str(self.instance)}}}' + elif self.t == CephOsdName: + chunk = f'--{self.name} <id|osd.id>' + elif self.t == CephName: + chunk = f'--{self.name} <type.id>' + elif self.t == CephInt: + chunk = f'--{self.name} <int>' + elif self.t == CephFloat: + chunk = f'--{self.name} <float>' + else: + chunk = f'--{self.name} <value>' + s = chunk + if self.N: + s += '...' + if not self.req: # req should *always* be false + s = '[' + s + ']' + + return s + + def complete(self, s): + return self.instance.complete(s) + + +def concise_sig(sig): + """ + Return string representation of sig useful for syntax reference in help + """ + return ' '.join([d.helpstr() for d in sig]) + + +def descsort_key(sh): + """ + sort descriptors by prefixes, defined as the concatenation of all simple + strings in the descriptor; this works out to just the leading strings. + """ + return concise_sig(sh['sig']) + + +def parse_funcsig(sig: Sequence[Union[str, Dict[str, Any]]]) -> List[argdesc]: + """ + parse a single descriptor (array of strings or dicts) into a + dict of function descriptor/validators (objects of CephXXX type) + + :returns: list of ``argdesc`` + """ + newsig = [] + argnum = 0 + for desc in sig: + argnum += 1 + if isinstance(desc, basestring): + t = CephPrefix + desc = {'type': t, 'name': 'prefix', 'prefix': desc} + else: + # not a simple string, must be dict + if 'type' not in desc: + s = 'JSON descriptor {0} has no type'.format(sig) + raise JsonFormat(s) + # look up type string in our globals() dict; if it's an + # object of type `type`, it must be a + # locally-defined class. otherwise, we haven't a clue. + if desc['type'] in globals(): + t = globals()[desc['type']] + if not isinstance(t, type): + s = 'unknown type {0}'.format(desc['type']) + raise JsonFormat(s) + else: + s = 'unknown type {0}'.format(desc['type']) + raise JsonFormat(s) + + kwargs = dict() + for key, val in desc.items(): + if key not in ['type', 'name', 'n', 'req', 'positional']: + kwargs[key] = val + newsig.append(argdesc(t, + name=desc.get('name', None), + n=desc.get('n', 1), + req=desc.get('req', True), + positional=desc.get('positional', True), + **kwargs)) + return newsig + + +def parse_json_funcsigs(s: str, + consumer: str) -> Dict[str, Dict[str, List[argdesc]]]: + """ + A function signature is mostly an array of argdesc; it's represented + in JSON as + { + "cmd001": {"sig":[ "type": type, "name": name, "n": num, "req":true|false <other param>], "help":helptext, "module":modulename, "perm":perms, "avail":availability} + . + . + . + ] + + A set of sigs is in an dict mapped by a unique number: + { + "cmd1": { + "sig": ["type.. ], "help":helptext... + } + "cmd2"{ + "sig": [.. ], "help":helptext... + } + } + + Parse the string s and return a dict of dicts, keyed by opcode; + each dict contains 'sig' with the array of descriptors, and 'help' + with the helptext, 'module' with the module name, 'perm' with a + string representing required permissions in that module to execute + this command (and also whether it is a read or write command from + the cluster state perspective), and 'avail' as a hint for + whether the command should be advertised by CLI, REST, or both. + If avail does not contain 'consumer', don't include the command + in the returned dict. + """ + try: + overall = json.loads(s) + except Exception as e: + print("Couldn't parse JSON {0}: {1}".format(s, e), file=sys.stderr) + raise e + sigdict = {} + for cmdtag, cmd in overall.items(): + if 'sig' not in cmd: + s = "JSON descriptor {0} has no 'sig'".format(cmdtag) + raise JsonFormat(s) + # check 'avail' and possibly ignore this command + if 'avail' in cmd: + if consumer not in cmd['avail']: + continue + # rewrite the 'sig' item with the argdesc-ized version, and... + cmd['sig'] = parse_funcsig(cmd['sig']) + # just take everything else as given + sigdict[cmdtag] = cmd + return sigdict + + +ArgValT = Union[bool, int, float, str, Tuple[str, str]] + +def validate_one(word: str, + desc, + is_kwarg: bool, + partial: bool = False) -> List[ArgValT]: + """ + validate_one(word, desc, is_kwarg, partial=False) + + validate word against the constructed instance of the type + in desc. May raise exception. If it returns false (and doesn't + raise an exception), desc.instance.val will + contain the validated value (in the appropriate type). + """ + vals = [] + # CephString option might contain "," in it + allow_csv = is_kwarg or desc.t is not CephString + if desc.N and allow_csv: + for part in word.split(','): + desc.instance.valid(part, partial) + vals.append(desc.instance.val) + else: + desc.instance.valid(word, partial) + vals.append(desc.instance.val) + desc.numseen += 1 + if desc.N: + desc.n = desc.numseen + 1 + return vals + + +def matchnum(args: List[str], + signature: List[argdesc], + partial: bool = False) -> int: + """ + matchnum(s, signature, partial=False) + + Returns number of arguments matched in s against signature. + Can be used to determine most-likely command for full or partial + matches (partial applies to string matches). + """ + words = args[:] + mysig = copy.deepcopy(signature) + matchcnt = 0 + for desc in mysig: + desc.numseen = 0 + while desc.numseen < desc.n: + # if there are no more arguments, return + if not words: + return matchcnt + word = words.pop(0) + + try: + # only allow partial matching if we're on the last supplied + # word; avoid matching foo bar and foot bar just because + # partial is set + validate_one(word, desc, False, partial and (len(words) == 0)) + valid = True + except ArgumentError: + # matchnum doesn't care about type of error + valid = False + + if not valid: + if not desc.req: + # this wasn't required, so word may match the next desc + words.insert(0, word) + break + else: + # it was required, and didn't match, return + return matchcnt + if desc.req: + matchcnt += 1 + return matchcnt + + +ValidatedArg = Union[bool, int, float, str, + Tuple[str, str], + Sequence[str]] +ValidatedArgs = Dict[str, ValidatedArg] + + +def store_arg(desc: argdesc, args: Sequence[ValidatedArg], d: ValidatedArgs): + ''' + Store argument described by, and held in, thanks to valid(), + desc into the dictionary d, keyed by desc.name. Three cases: + + 1) desc.N is set: use args for arg value in "d", desc.instance.val + only contains the last parsed arg in the "args" list + 2) prefix: multiple args are joined with ' ' into one d{} item + 3) single prefix or other arg: store as simple value + + Used in validate() below. + ''' + if desc.N: + # value should be a list + if desc.name in d: + d[desc.name] += args + else: + d[desc.name] = args + elif (desc.t == CephPrefix) and (desc.name in d): + # prefixes' values should be a space-joined concatenation + d[desc.name] += ' ' + desc.instance.val + else: + # if first CephPrefix or any other type, just set it + d[desc.name] = desc.instance.val + + +def validate(args: List[str], + signature: Sequence[argdesc], + flags: int = 0, + partial: Optional[bool] = False) -> ValidatedArgs: + """ + validate(args, signature, flags=0, partial=False) + + args is a list of strings representing a possible + command input following format of signature. Runs a validation; no + exception means it's OK. Return a dict containing all arguments keyed + by their descriptor name, with duplicate args per name accumulated + into a list (or space-separated value for CephPrefix). + + Mismatches of prefix are non-fatal, as this probably just means the + search hasn't hit the correct command. Mismatches of non-prefix + arguments are treated as fatal, and an exception raised. + + This matching is modified if partial is set: allow partial matching + (with partial dict returned); in this case, there are no exceptions + raised. + """ + + myargs = copy.deepcopy(args) + mysig = copy.deepcopy(signature) + reqsiglen = len([desc for desc in mysig if desc.req]) + matchcnt = 0 + d: ValidatedArgs = dict() + save_exception = None + + arg_descs_by_name: Dict[str, argdesc] = \ + dict((desc.name, desc) for desc in mysig if desc.t != CephPrefix) + + # Special case: detect "injectargs" (legacy way of modifying daemon + # configs) and permit "--" string arguments if so. + injectargs = myargs and myargs[0] == "injectargs" + + # Make a pass through all arguments + for desc in mysig: + desc.numseen = 0 + + while desc.numseen < desc.n: + if myargs: + myarg: Optional[str] = myargs.pop(0) + else: + myarg = None + + # no arg, but not required? Continue consuming mysig + # in case there are later required args + if myarg in (None, []): + if not desc.req: + break + # did we already get this argument (as a named arg, earlier?) + if desc.name in d: + break + + # A keyword argument? + if myarg: + # argdesc for the keyword argument, if we find one + kwarg_desc = None + + # Try both styles of keyword argument + kwarg_match = re.match(KWARG_EQUALS, myarg) + if kwarg_match: + # We have a "--foo=bar" style argument + kwarg_k, kwarg_v = kwarg_match.groups() + + # Either "--foo-bar" or "--foo_bar" style is accepted + kwarg_k = kwarg_k.replace('-', '_') + + kwarg_desc = arg_descs_by_name.get(kwarg_k, None) + else: + # Maybe this is a "--foo bar" or "--bool" style argument + key_match = re.match(KWARG_SPACE, myarg) + if key_match: + kwarg_k = key_match.group(1) + + # Permit --foo-bar=123 form or --foo_bar=123 form, + # assuming all command definitions use foo_bar argument + # naming style + kwarg_k = kwarg_k.replace('-', '_') + + kwarg_desc = arg_descs_by_name.get(kwarg_k, None) + if kwarg_desc: + if kwarg_desc.t == CephBool: + kwarg_v = 'true' + elif len(myargs): # Some trailing arguments exist + kwarg_v = myargs.pop(0) + else: + # Forget it, this is not a valid kwarg + kwarg_desc = None + + if kwarg_desc: + args = validate_one(kwarg_v, kwarg_desc, True) + matchcnt += 1 + store_arg(kwarg_desc, args, d) + continue + + if not desc.positional: + # No more positional args! + raise ArgumentValid(f"Unexpected argument '{myarg}'") + + # Don't handle something as a positional argument if it + # has a leading "--" unless it's a CephChoices (used for + # "--yes-i-really-mean-it") + if myarg and myarg.startswith("--"): + # Special cases for instances of confirmation flags + # that were defined as CephString/CephChoices instead of CephBool + # in pre-nautilus versions of Ceph daemons. + is_value = desc.t == CephChoices \ + or myarg == "--yes-i-really-mean-it" \ + or myarg == "--yes-i-really-really-mean-it" \ + or myarg == "--yes-i-really-really-mean-it-not-faking" \ + or myarg == "--force" \ + or injectargs + + if not is_value: + # Didn't get caught by kwarg handling, but has a "--", so + # we must assume it's something invalid, to avoid naively + # passing through mis-typed options as the values of + # positional arguments. + raise ArgumentValid("Unexpected argument '{0}'".format( + myarg)) + + # out of arguments for a required param? + # Either return (if partial validation) or raise + if myarg in (None, []) and desc.req: + if desc.N and desc.numseen < 1: + # wanted N, didn't even get 1 + if partial: + return d + raise ArgumentNumber( + 'saw {0} of {1}, expected at least 1'. + format(desc.numseen, desc) + ) + elif not desc.N and desc.numseen < desc.n: + # wanted n, got too few + if partial: + return d + # special-case the "0 expected 1" case + if desc.numseen == 0 and desc.n == 1: + raise ArgumentMissing( + 'missing required parameter {0}'.format(desc) + ) + raise ArgumentNumber( + 'saw {0} of {1}, expected {2}'. + format(desc.numseen, desc, desc.n) + ) + break + + # Have an arg; validate it + assert myarg is not None + try: + args = validate_one(myarg, desc, False) + except ArgumentError as e: + # argument mismatch + if not desc.req: + # if not required, just push back; it might match + # the next arg + save_exception = [myarg, e] + myargs.insert(0, myarg) + break + else: + # hm, it was required, so time to return/raise + if partial: + return d + raise + + # Whew, valid arg acquired. Store in dict + matchcnt += 1 + store_arg(desc, args, d) + # Clear prior exception + save_exception = None + + # Done with entire list of argdescs + if matchcnt < reqsiglen: + raise ArgumentTooFew("not enough arguments given") + + if myargs and not partial: + if save_exception: + print(save_exception[0], 'not valid: ', save_exception[1], file=sys.stderr) + raise ArgumentError("unused arguments: " + str(myargs)) + + if flags & Flag.MGR: + d['target'] = ('mon-mgr', '') + + if flags & Flag.POLL: + d['poll'] = True + + # Finally, success + return d + + +def validate_command(sigdict: Dict[str, Dict[str, Any]], + args: List[str], + verbose: Optional[bool] = False) -> ValidatedArgs: + """ + Parse positional arguments into a parameter dict, according to + the command descriptions. + + Writes advice about nearly-matching commands ``sys.stderr`` if + the arguments do not match any command. + + :param sigdict: A command description dictionary, as returned + from Ceph daemons by the get_command_descriptions + command. + :param args: List of strings, should match one of the command + signatures in ``sigdict`` + + :returns: A dict of parsed parameters (including ``prefix``), + or an empty dict if the args did not match any signature + """ + if verbose: + print("validate_command: " + " ".join(args), file=sys.stderr) + found: Optional[Dict[str, Any]] = None + valid_dict = {} + + # look for best match, accumulate possibles in bestcmds + # (so we can maybe give a more-useful error message) + best_match_cnt = 0.0 + bestcmds: List[Dict[str, Any]] = [] + for cmd in sigdict.values(): + flags = cmd.get('flags', 0) + if flags & Flag.OBSOLETE: + continue + sig = cmd['sig'] + matched: float = matchnum(args, sig, partial=True) + if (matched >= math.floor(best_match_cnt) and + matched == matchnum(args, sig, partial=False)): + # prefer those fully matched over partial patch + matched += 0.5 + if matched < best_match_cnt: + continue + if verbose: + print("better match: {0} > {1}: {2} ".format( + matched, best_match_cnt, concise_sig(sig) + ), file=sys.stderr) + if matched > best_match_cnt: + best_match_cnt = matched + bestcmds = [cmd] + else: + bestcmds.append(cmd) + + # Sort bestcmds by number of req args so we can try shortest first + # (relies on a cmdsig being key,val where val is a list of len 1) + + def grade(cmd): + # prefer optional arguments over required ones + sigs = cmd['sig'] + return sum(map(lambda sig: sig.req, sigs)) + + bestcmds_sorted = sorted(bestcmds, key=grade) + if verbose: + print("bestcmds_sorted: ", file=sys.stderr) + pprint.PrettyPrinter(stream=sys.stderr).pprint(bestcmds_sorted) + + ex: Optional[ArgumentError] = None + # for everything in bestcmds, look for a true match + for cmd in bestcmds_sorted: + sig = cmd['sig'] + try: + valid_dict = validate(args, sig, flags=cmd.get('flags', 0)) + found = cmd + break + except ArgumentPrefix: + # ignore prefix mismatches; we just haven't found + # the right command yet + pass + except ArgumentMissing as e: + ex = e + if len(bestcmds) == 1: + found = cmd + break + except ArgumentTooFew: + # It looked like this matched the beginning, but it + # didn't have enough args supplied. If we're out of + # cmdsigs we'll fall out unfound; if we're not, maybe + # the next one matches completely. Whine, but pass. + if verbose: + print('Not enough args supplied for ', + concise_sig(sig), file=sys.stderr) + except ArgumentError as e: + ex = e + # Solid mismatch on an arg (type, range, etc.) + # Stop now, because we have the right command but + # some other input is invalid + found = cmd + break + + if found: + if not valid_dict: + print("Invalid command:", ex, file=sys.stderr) + print(concise_sig(sig), ': ', cmd['help'], file=sys.stderr) + else: + bestcmds = [c for c in bestcmds + if not c.get('flags', 0) & (Flag.DEPRECATED | Flag.HIDDEN)] + bestcmds = bestcmds[:10] # top 10 + print('no valid command found; {0} closest matches:'.format(len(bestcmds)), + file=sys.stderr) + for cmd in bestcmds: + print(concise_sig(cmd['sig']), file=sys.stderr) + return valid_dict + + +def find_cmd_target(childargs: List[str]) -> Tuple[str, Optional[str]]: + """ + Using a minimal validation, figure out whether the command + should be sent to a monitor or an osd. We do this before even + asking for the 'real' set of command signatures, so we can ask the + right daemon. + Returns ('osd', osdid), ('pg', pgid), ('mgr', '') or ('mon', '') + """ + sig = parse_funcsig(['tell', {'name': 'target', 'type': 'CephName'}]) + try: + valid_dict = validate(childargs, sig, partial=True) + except ArgumentError: + pass + else: + if len(valid_dict) == 2: + # revalidate to isolate type and id + name = CephName() + # if this fails, something is horribly wrong, as it just + # validated successfully above + name.valid(valid_dict['target']) + assert name.nametype is not None + return name.nametype, name.nameid + + sig = parse_funcsig(['tell', {'name': 'pgid', 'type': 'CephPgid'}]) + try: + valid_dict = validate(childargs, sig, partial=True) + except ArgumentError: + pass + else: + if len(valid_dict) == 2: + # pg doesn't need revalidation; the string is fine + pgid = valid_dict['pgid'] + assert isinstance(pgid, str) + return 'pg', pgid + + # If we reached this far it must mean that so far we've been unable to + # obtain a proper target from childargs. This may mean that we are not + # dealing with a 'tell' command, or that the specified target is invalid. + # If the latter, we likely were unable to catch it because we were not + # really looking for it: first we tried to parse a 'CephName' (osd, mon, + # mds, followed by and id); given our failure to parse, we tried to parse + # a 'CephPgid' instead (e.g., 0.4a). Considering we got this far though + # we were unable to do so. + # + # We will now check if this is a tell and, if so, forcefully validate the + # target as a 'CephName'. This must be so because otherwise we will end + # up sending garbage to a monitor, which is the default target when a + # target is not explicitly specified. + # e.g., + # 'ceph status' -> target is any one monitor + # 'ceph tell mon.* status -> target is all monitors + # 'ceph tell foo status -> target is invalid! + if len(childargs) > 1 and childargs[0] == 'tell': + name = CephName() + # CephName.valid() raises on validation error; find_cmd_target()'s + # caller should handle them + name.valid(childargs[1]) + assert name.nametype is not None + assert name.nameid is not None + return name.nametype, name.nameid + + sig = parse_funcsig(['pg', {'name': 'pgid', 'type': 'CephPgid'}]) + try: + valid_dict = validate(childargs, sig, partial=True) + except ArgumentError: + pass + else: + if len(valid_dict) == 2: + pgid = valid_dict['pgid'] + assert isinstance(pgid, str) + return 'pg', pgid + + return 'mon', '' + + +class RadosThread(threading.Thread): + def __init__(self, func, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.func = func + self.exception = None + threading.Thread.__init__(self) + + def run(self): + try: + self.retval = self.func(*self.args, **self.kwargs) + except Exception as e: + self.exception = e + + +def run_in_thread(func: Callable[[Any, Any], Tuple[int, bytes, str]], + *args: Any, **kwargs: Any) -> Tuple[int, bytes, str]: + timeout = kwargs.pop('timeout', 0) + if timeout == 0 or timeout is None: + # python threading module will just get blocked if timeout is `None`, + # otherwise it will keep polling until timeout or thread stops. + # timeout in integer when converting it to nanoseconds, but since + # python3 uses `int64_t` for the deadline before timeout expires, + # we have to use a safe value which does not overflow after being + # added to current time in microseconds. + timeout = 24 * 60 * 60 + t = RadosThread(func, *args, **kwargs) + + # allow the main thread to exit (presumably, avoid a join() on this + # subthread) before this thread terminates. This allows SIGINT + # exit of a blocked call. See below. + t.daemon = True + + t.start() + t.join(timeout=timeout) + # ..but allow SIGINT to terminate the waiting. Note: this + # relies on the Linux kernel behavior of delivering the signal + # to the main thread in preference to any subthread (all that's + # strictly guaranteed is that *some* thread that has the signal + # unblocked will receive it). But there doesn't seem to be + # any interface to create a thread with SIGINT blocked. + if t.is_alive(): + raise Exception("timed out") + elif t.exception: + raise t.exception + else: + return t.retval + + +def send_command_retry(*args: Any, **kwargs: Any) -> Tuple[int, bytes, str]: + while True: + try: + return send_command(*args, **kwargs) + except Exception as e: + # If our librados instance has not reached state 'connected' + # yet, we'll see an exception like this and retry + if ('get_command_descriptions' in str(e) and + 'object in state configuring' in str(e)): + continue + else: + raise + + +def send_command(cluster, + target: Tuple[str, Optional[str]] = ('mon', ''), + cmd: Optional[str] = None, + inbuf: Optional[bytes] = b'', + timeout: Optional[int] = 0, + verbose: Optional[bool] = False) -> Tuple[int, bytes, str]: + """ + Send a command to a daemon using librados's + mon_command, osd_command, mgr_command, or pg_command. Any bulk input data + comes in inbuf. + + Returns (ret, outbuf, outs); ret is the return code, outbuf is + the outbl "bulk useful output" buffer, and outs is any status + or error message (intended for stderr). + + If target is osd.N, send command to that osd (except for pgid cmds) + """ + try: + if target[0] == 'osd': + osdid = target[1] + assert osdid is not None + + if verbose: + print('submit {0} to osd.{1}'.format(cmd, osdid), + file=sys.stderr) + ret, outbuf, outs = run_in_thread( + cluster.osd_command, int(osdid), cmd, inbuf, timeout=timeout) + + elif target[0] == 'mgr': + name = '' # non-None empty string means "current active mgr" + if len(target) > 1 and target[1] is not None: + name = target[1] + if verbose: + print('submit {0} to {1} name {2}'.format(cmd, target[0], name), + file=sys.stderr) + ret, outbuf, outs = run_in_thread( + cluster.mgr_command, cmd, inbuf, timeout=timeout, target=name) + + elif target[0] == 'mon-mgr': + if verbose: + print('submit {0} to {1}'.format(cmd, target[0]), + file=sys.stderr) + ret, outbuf, outs = run_in_thread( + cluster.mgr_command, cmd, inbuf, timeout=timeout) + + elif target[0] == 'pg': + pgid = target[1] + # pgid will already be in the command for the pg <pgid> + # form, but for tell <pgid>, we need to put it in + if cmd: + cmddict = json.loads(cmd) + cmddict['pgid'] = pgid + else: + cmddict = dict(pgid=pgid) + cmd = json.dumps(cmddict) + if verbose: + print('submit {0} for pgid {1}'.format(cmd, pgid), + file=sys.stderr) + ret, outbuf, outs = run_in_thread( + cluster.pg_command, pgid, cmd, inbuf, timeout=timeout) + + elif target[0] == 'mon': + if verbose: + print('{0} to {1}'.format(cmd, target[0]), + file=sys.stderr) + if len(target) < 2 or target[1] == '': + ret, outbuf, outs = run_in_thread( + cluster.mon_command, cmd, inbuf, timeout=timeout) + else: + ret, outbuf, outs = run_in_thread( + cluster.mon_command, cmd, inbuf, timeout=timeout, target=target[1]) + elif target[0] == 'mds': + mds_spec = target[1] + + if verbose: + print('submit {0} to mds.{1}'.format(cmd, mds_spec), + file=sys.stderr) + + try: + from cephfs import LibCephFS + except ImportError: + raise RuntimeError("CephFS unavailable, have you installed libcephfs?") + + filesystem = LibCephFS(rados_inst=cluster) + filesystem.init() + ret, outbuf, outs = \ + filesystem.mds_command(mds_spec, cmd, inbuf) + filesystem.shutdown() + else: + raise ArgumentValid("Bad target type '{0}'".format(target[0])) + + except Exception as e: + if not isinstance(e, ArgumentError): + raise RuntimeError('"{0}": exception {1}'.format(cmd, e)) + else: + raise + + return ret, outbuf, outs + + +def json_command(cluster, + target: Tuple[str, Optional[str]] = ('mon', ''), + prefix: Optional[str] = None, + argdict: Optional[ValidatedArgs] = None, + inbuf: Optional[bytes] = b'', + timeout: Optional[int] = 0, + verbose: Optional[bool] = False) -> Tuple[int, bytes, str]: + """ + Serialize a command and up a JSON command and send it with send_command() above. + Prefix may be supplied separately or in argdict. Any bulk input + data comes in inbuf. + + If target is osd.N, send command to that osd (except for pgid cmds) + + :param cluster: ``rados.Rados`` instance + :param prefix: String to inject into command arguments as 'prefix' + :param argdict: Command arguments + """ + cmddict: ValidatedArgs = {} + if prefix: + cmddict.update({'prefix': prefix}) + + if argdict: + cmddict.update(argdict) + if 'target' in argdict: + target = cast(Tuple[str, str], argdict['target']) + + try: + if target[0] == 'osd': + osdtarg = CephName() + osdtarget = '{0}.{1}'.format(*target) + # prefer target from cmddict if present and valid + if 'target' in cmddict: + osdtarget = cast(str, cmddict.pop('target')) + try: + osdtarg.valid(osdtarget) + target = ('osd', osdtarg.nameid) + except: + # use the target we were originally given + pass + ret, outbuf, outs = send_command_retry(cluster, + target, json.dumps(cmddict), + inbuf, timeout, verbose) + + except Exception as e: + if not isinstance(e, ArgumentError): + raise RuntimeError('"{0}": exception {1}'.format(argdict, e)) + else: + raise + + return ret, outbuf, outs |