cli.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. #!/usr/bin/env python3
  2. # SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
  3. """
  4. YNL cli tool
  5. """
  6. import argparse
  7. import json
  8. import os
  9. import pathlib
  10. import pprint
  11. import shutil
  12. import sys
  13. import textwrap
  14. # pylint: disable=no-name-in-module,wrong-import-position
  15. sys.path.append(pathlib.Path(__file__).resolve().parent.as_posix())
  16. from lib import YnlFamily, Netlink, NlError, SpecFamily, SpecException, YnlException
  17. SYS_SCHEMA_DIR='/usr/share/ynl'
  18. RELATIVE_SCHEMA_DIR='../../../../Documentation/netlink'
  19. # pylint: disable=too-few-public-methods,too-many-locals
  20. class Colors:
  21. """ANSI color and font modifier codes"""
  22. RESET = '\033[0m'
  23. BOLD = '\033[1m'
  24. ITALICS = '\033[3m'
  25. UNDERLINE = '\033[4m'
  26. INVERT = '\033[7m'
  27. def color(text, modifiers):
  28. """Add color to text if output is a TTY
  29. Returns:
  30. Colored text if stdout is a TTY, otherwise plain text
  31. """
  32. if sys.stdout.isatty():
  33. # Join the colors if they are a list, if it's a string this a noop
  34. modifiers = "".join(modifiers)
  35. return f"{modifiers}{text}{Colors.RESET}"
  36. return text
  37. def term_width():
  38. """ Get terminal width in columns (80 if stdout is not a terminal) """
  39. return shutil.get_terminal_size().columns
  40. def schema_dir():
  41. """
  42. Return the effective schema directory, preferring in-tree before
  43. system schema directory.
  44. """
  45. script_dir = os.path.dirname(os.path.abspath(__file__))
  46. schema_dir_ = os.path.abspath(f"{script_dir}/{RELATIVE_SCHEMA_DIR}")
  47. if not os.path.isdir(schema_dir_):
  48. schema_dir_ = SYS_SCHEMA_DIR
  49. if not os.path.isdir(schema_dir_):
  50. raise YnlException(f"Schema directory {schema_dir_} does not exist")
  51. return schema_dir_
  52. def spec_dir():
  53. """
  54. Return the effective spec directory, relative to the effective
  55. schema directory.
  56. """
  57. spec_dir_ = schema_dir() + '/specs'
  58. if not os.path.isdir(spec_dir_):
  59. raise YnlException(f"Spec directory {spec_dir_} does not exist")
  60. return spec_dir_
  61. class YnlEncoder(json.JSONEncoder):
  62. """A custom encoder for emitting JSON with ynl-specific instance types"""
  63. def default(self, o):
  64. if isinstance(o, bytes):
  65. return bytes.hex(o)
  66. if isinstance(o, set):
  67. return list(o)
  68. return json.JSONEncoder.default(self, o)
  69. def print_attr_list(ynl, attr_names, attr_set, indent=2):
  70. """Print a list of attributes with their types and documentation."""
  71. prefix = ' ' * indent
  72. for attr_name in attr_names:
  73. if attr_name in attr_set.attrs:
  74. attr = attr_set.attrs[attr_name]
  75. attr_info = f'{prefix}- {color(attr_name, Colors.BOLD)}: {attr.type}'
  76. if 'enum' in attr.yaml:
  77. enum_name = attr.yaml['enum']
  78. attr_info += f" (enum: {enum_name})"
  79. # Print enum values if available
  80. if enum_name in ynl.consts:
  81. const = ynl.consts[enum_name]
  82. enum_values = list(const.entries.keys())
  83. type_fmted = color(const.type.capitalize(), Colors.ITALICS)
  84. attr_info += f"\n{prefix} {type_fmted}: {', '.join(enum_values)}"
  85. # Show nested attributes reference and recursively display them
  86. nested_set_name = None
  87. if attr.type == 'nest' and 'nested-attributes' in attr.yaml:
  88. nested_set_name = attr.yaml['nested-attributes']
  89. attr_info += f" -> {nested_set_name}"
  90. if attr.yaml.get('doc'):
  91. doc_prefix = prefix + ' ' * 4
  92. doc_text = textwrap.fill(attr.yaml['doc'], width=term_width(),
  93. initial_indent=doc_prefix,
  94. subsequent_indent=doc_prefix)
  95. attr_info += f"\n{doc_text}"
  96. print(attr_info)
  97. # Recursively show nested attributes
  98. if nested_set_name in ynl.attr_sets:
  99. nested_set = ynl.attr_sets[nested_set_name]
  100. # Filter out 'unspec' and other unused attrs
  101. nested_names = [n for n in nested_set.attrs.keys()
  102. if nested_set.attrs[n].type != 'unused']
  103. if nested_names:
  104. print_attr_list(ynl, nested_names, nested_set, indent + 4)
  105. def print_mode_attrs(ynl, mode, mode_spec, attr_set, consistent_dd_reply=None):
  106. """Print a given mode (do/dump/event/notify)."""
  107. mode_title = mode.capitalize()
  108. if 'request' in mode_spec and 'attributes' in mode_spec['request']:
  109. print(f'\n{mode_title} request attributes:')
  110. print_attr_list(ynl, mode_spec['request']['attributes'], attr_set)
  111. if 'reply' in mode_spec and 'attributes' in mode_spec['reply']:
  112. if consistent_dd_reply and mode == "do":
  113. title = None # Dump handling will print in combined format
  114. elif consistent_dd_reply and mode == "dump":
  115. title = 'Do and Dump'
  116. else:
  117. title = f'{mode_title}'
  118. if title:
  119. print(f'\n{title} reply attributes:')
  120. print_attr_list(ynl, mode_spec['reply']['attributes'], attr_set)
  121. def do_doc(ynl, op):
  122. """Handle --list-attrs $op, print the attr information to stdout"""
  123. print(f'Operation: {color(op.name, Colors.BOLD)}')
  124. print(op.yaml['doc'])
  125. consistent_dd_reply = False
  126. if 'do' in op.yaml and 'dump' in op.yaml and 'reply' in op.yaml['do'] and \
  127. op.yaml['do']['reply'] == op.yaml['dump'].get('reply'):
  128. consistent_dd_reply = True
  129. for mode in ['do', 'dump']:
  130. if mode in op.yaml:
  131. print_mode_attrs(ynl, mode, op.yaml[mode], op.attr_set,
  132. consistent_dd_reply=consistent_dd_reply)
  133. if 'attributes' in op.yaml.get('event', {}):
  134. print('\nEvent attributes:')
  135. print_attr_list(ynl, op.yaml['event']['attributes'], op.attr_set)
  136. if 'notify' in op.yaml:
  137. mode_spec = op.yaml['notify']
  138. ref_spec = ynl.msgs.get(mode_spec).yaml.get('do')
  139. if not ref_spec:
  140. ref_spec = ynl.msgs.get(mode_spec).yaml.get('dump')
  141. if ref_spec:
  142. print('\nNotification attributes:')
  143. print_attr_list(ynl, ref_spec['reply']['attributes'], op.attr_set)
  144. if 'mcgrp' in op.yaml:
  145. print(f"\nMulticast group: {op.yaml['mcgrp']}")
  146. # pylint: disable=too-many-locals,too-many-branches,too-many-statements
  147. def main():
  148. """YNL cli tool"""
  149. description = """
  150. YNL CLI utility - a general purpose netlink utility that uses YAML
  151. specs to drive protocol encoding and decoding.
  152. """
  153. epilog = """
  154. The --multi option can be repeated to include several do operations
  155. in the same netlink payload.
  156. """
  157. parser = argparse.ArgumentParser(description=description,
  158. epilog=epilog, add_help=False)
  159. gen_group = parser.add_argument_group('General options')
  160. gen_group.add_argument('-h', '--help', action='help',
  161. help='show this help message and exit')
  162. spec_group = parser.add_argument_group('Netlink family selection')
  163. spec_sel = spec_group.add_mutually_exclusive_group(required=True)
  164. spec_sel.add_argument('--list-families', action='store_true',
  165. help=('list Netlink families supported by YNL '
  166. '(which have a spec available in the standard '
  167. 'system path)'))
  168. spec_sel.add_argument('--family', dest='family', type=str,
  169. help='name of the Netlink FAMILY to use')
  170. spec_sel.add_argument('--spec', dest='spec', type=str,
  171. help='full file path to the YAML spec file')
  172. ops_group = parser.add_argument_group('Operations')
  173. ops = ops_group.add_mutually_exclusive_group()
  174. ops.add_argument('--do', dest='do', metavar='DO-OPERATION', type=str)
  175. ops.add_argument('--dump', dest='dump', metavar='DUMP-OPERATION', type=str)
  176. ops.add_argument('--multi', dest='multi', nargs=2, action='append',
  177. metavar=('DO-OPERATION', 'JSON_TEXT'), type=str,
  178. help="Multi-message operation sequence (for nftables)")
  179. ops.add_argument('--list-ops', action='store_true',
  180. help="List available --do and --dump operations")
  181. ops.add_argument('--list-msgs', action='store_true',
  182. help="List all messages of the family (incl. notifications)")
  183. ops.add_argument('--list-attrs', '--doc', dest='list_attrs', metavar='MSG',
  184. type=str, help='List attributes for a message / operation')
  185. ops.add_argument('--validate', action='store_true',
  186. help="Validate the spec against schema and exit")
  187. io_group = parser.add_argument_group('Input / Output')
  188. io_group.add_argument('--json', dest='json_text', type=str,
  189. help=('Specify attributes of the message to send '
  190. 'to the kernel in JSON format. Can be left out '
  191. 'if the message is expected to be empty.'))
  192. io_group.add_argument('--output-json', action='store_true',
  193. help='Format output as JSON')
  194. ntf_group = parser.add_argument_group('Notifications')
  195. ntf_group.add_argument('--subscribe', dest='ntf', type=str)
  196. ntf_group.add_argument('--duration', dest='duration', type=int,
  197. help='when subscribed, watch for DURATION seconds')
  198. ntf_group.add_argument('--sleep', dest='duration', type=int,
  199. help='alias for duration')
  200. nlflags = parser.add_argument_group('Netlink message flags (NLM_F_*)',
  201. ('Extra flags to set in nlmsg_flags of '
  202. 'the request, used mostly by older '
  203. 'Classic Netlink families.'))
  204. nlflags.add_argument('--replace', dest='flags', action='append_const',
  205. const=Netlink.NLM_F_REPLACE)
  206. nlflags.add_argument('--excl', dest='flags', action='append_const',
  207. const=Netlink.NLM_F_EXCL)
  208. nlflags.add_argument('--create', dest='flags', action='append_const',
  209. const=Netlink.NLM_F_CREATE)
  210. nlflags.add_argument('--append', dest='flags', action='append_const',
  211. const=Netlink.NLM_F_APPEND)
  212. schema_group = parser.add_argument_group('Development options')
  213. schema_group.add_argument('--schema', dest='schema', type=str,
  214. help="JSON schema to validate the spec")
  215. schema_group.add_argument('--no-schema', action='store_true')
  216. dbg_group = parser.add_argument_group('Debug options')
  217. dbg_group.add_argument('--dbg-small-recv', default=0, const=4000,
  218. action='store', nargs='?', type=int, metavar='INT',
  219. help="Length of buffers used for recv()")
  220. dbg_group.add_argument('--process-unknown', action=argparse.BooleanOptionalAction)
  221. args = parser.parse_args()
  222. def output(msg):
  223. if args.output_json:
  224. print(json.dumps(msg, cls=YnlEncoder))
  225. else:
  226. pprint.pprint(msg, width=term_width(), compact=True)
  227. if args.list_families:
  228. for filename in sorted(os.listdir(spec_dir())):
  229. if filename.endswith('.yaml'):
  230. print(filename.removesuffix('.yaml'))
  231. return
  232. if args.no_schema:
  233. args.schema = ''
  234. attrs = {}
  235. if args.json_text:
  236. attrs = json.loads(args.json_text)
  237. if args.family:
  238. spec = f"{spec_dir()}/{args.family}.yaml"
  239. else:
  240. spec = args.spec
  241. if not os.path.isfile(spec):
  242. raise YnlException(f"Spec file {spec} does not exist")
  243. if args.validate:
  244. try:
  245. SpecFamily(spec, args.schema)
  246. except SpecException as error:
  247. print(error)
  248. sys.exit(1)
  249. return
  250. if args.family: # set behaviour when using installed specs
  251. if args.schema is None and spec.startswith(SYS_SCHEMA_DIR):
  252. args.schema = '' # disable schema validation when installed
  253. if args.process_unknown is None:
  254. args.process_unknown = True
  255. ynl = YnlFamily(spec, args.schema, args.process_unknown,
  256. recv_size=args.dbg_small_recv)
  257. if args.dbg_small_recv:
  258. ynl.set_recv_dbg(True)
  259. if args.ntf:
  260. ynl.ntf_subscribe(args.ntf)
  261. if args.list_ops:
  262. for op_name, op in ynl.ops.items():
  263. print(op_name, " [", ", ".join(op.modes), "]")
  264. if args.list_msgs:
  265. for op_name, op in ynl.msgs.items():
  266. print(op_name, " [", ", ".join(op.modes), "]")
  267. if args.list_attrs:
  268. op = ynl.msgs.get(args.list_attrs)
  269. if not op:
  270. print(f'Operation {args.list_attrs} not found')
  271. sys.exit(1)
  272. do_doc(ynl, op)
  273. try:
  274. if args.do:
  275. reply = ynl.do(args.do, attrs, args.flags)
  276. output(reply)
  277. if args.dump:
  278. reply = ynl.dump(args.dump, attrs)
  279. output(reply)
  280. if args.multi:
  281. ops = [ (item[0], json.loads(item[1]), args.flags or []) for item in args.multi ]
  282. reply = ynl.do_multi(ops)
  283. output(reply)
  284. if args.ntf:
  285. for msg in ynl.poll_ntf(duration=args.duration):
  286. output(msg)
  287. except NlError as e:
  288. print(e)
  289. sys.exit(1)
  290. except KeyboardInterrupt:
  291. pass
  292. except BrokenPipeError:
  293. pass
  294. if __name__ == "__main__":
  295. main()