]> git.proxmox.com Git - mirror_frr.git/blame - python/xref2vtysh.py
mgmtd: nb library for client front-end code
[mirror_frr.git] / python / xref2vtysh.py
CommitLineData
acddc0ed 1# SPDX-License-Identifier: GPL-2.0-or-later
89cb86ae
DL
2# FRR xref vtysh command extraction
3#
4# Copyright (C) 2022 David Lamparter for NetDEF, Inc.
89cb86ae
DL
5
6"""
7Generate vtysh_cmd.c from frr .xref file(s).
8
9This can run either standalone or as part of xrelfo. The latter saves a
10non-negligible amount of time (0.5s on average systems, more on e.g. slow ARMs)
11since serializing and deserializing JSON is a significant bottleneck in this.
12"""
13
14import sys
15import os
16import re
17import pathlib
18import argparse
19from collections import defaultdict
20import difflib
21
89cb86ae
DL
22import json
23
24try:
25 import ujson as json # type: ignore
26except ImportError:
27 pass
28
29frr_top_src = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
31# vtysh needs to know which daemon(s) to send commands to. For lib/, this is
32# not quite obvious...
33
34daemon_flags = {
35 "lib/agentx.c": "VTYSH_ISISD|VTYSH_RIPD|VTYSH_OSPFD|VTYSH_OSPF6D|VTYSH_BGPD|VTYSH_ZEBRA",
36 "lib/filter.c": "VTYSH_ACL",
37 "lib/filter_cli.c": "VTYSH_ACL",
38 "lib/if.c": "VTYSH_INTERFACE",
39 "lib/keychain.c": "VTYSH_RIPD|VTYSH_EIGRPD|VTYSH_OSPF6D",
40 "lib/lib_vty.c": "VTYSH_ALL",
41 "lib/log_vty.c": "VTYSH_ALL",
42 "lib/nexthop_group.c": "VTYSH_NH_GROUP",
43 "lib/resolver.c": "VTYSH_NHRPD|VTYSH_BGPD",
44 "lib/routemap.c": "VTYSH_RMAP",
45 "lib/routemap_cli.c": "VTYSH_RMAP",
46 "lib/spf_backoff.c": "VTYSH_ISISD",
47 "lib/thread.c": "VTYSH_ALL",
48 "lib/vrf.c": "VTYSH_VRF",
49 "lib/vty.c": "VTYSH_ALL",
50}
51
52vtysh_cmd_head = """/* autogenerated file, DO NOT EDIT! */
53#include <zebra.h>
54
55#include "command.h"
56#include "linklist.h"
57
58#include "vtysh/vtysh.h"
59"""
60
61if sys.stderr.isatty():
62 _fmt_red = "\033[31m"
63 _fmt_green = "\033[32m"
64 _fmt_clear = "\033[m"
65else:
66 _fmt_red = _fmt_green = _fmt_clear = ""
67
68
69def c_escape(text: str) -> str:
70 """
71 Escape string for output into C source code.
72
73 Handles only what's needed here. CLI strings and help text don't contain
74 weird special characters.
75 """
76 return text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
77
78
79class NodeDict(defaultdict):
80 """
81 CLI node ID (integer) -> dict of commands in that node.
82 """
83
3dd40da3 84 nodenames = {} # Dict[int, str]
89cb86ae
DL
85
86 def __init__(self):
87 super().__init__(dict)
88
89 def items_named(self):
90 for k, v in self.items():
91 yield self.nodename(k), v
92
93 @classmethod
94 def nodename(cls, nodeid: int) -> str:
95 return cls.nodenames.get(nodeid, str(nodeid))
96
97 @classmethod
98 def load_nodenames(cls):
99 with open(os.path.join(frr_top_src, "lib", "command.h"), "r") as fd:
100 command_h = fd.read()
101
102 nodes = re.search(r"enum\s+node_type\s+\{(.*?)\}", command_h, re.S)
103 if nodes is None:
104 raise RuntimeError(
105 "regex failed to match on lib/command.h (to get CLI node names)"
106 )
107
108 text = nodes.group(1)
109 text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
110 text = re.sub(r"//.*?$", "", text, flags=re.M)
111 text = text.replace(",", " ")
112 text = text.split()
113
114 for i, name in enumerate(text):
115 cls.nodenames[i] = name
116
117
118class CommandEntry:
119 """
120 CLI command definition.
121
122 - one DEFUN creates at most one of these, even if the same command is
123 installed in multiple CLI nodes (e.g. BGP address-family nodes)
124 - for each CLI node, commands with the same CLI string are merged. This
125 is *almost* irrelevant - ospfd & ospf6d define some identical commands
126 in the route-map node. Those must be merged for things to work
127 correctly.
128 """
129
3dd40da3 130 all_defs = [] # List[CommandEntry]
89cb86ae
DL
131 warn_counter = 0
132
133 def __init__(self, origin, name, spec):
134 self.origin = origin
135 self.name = name
136 self._spec = spec
137 self._registered = False
138
139 self.cmd = spec["string"]
140 self._cmd_normalized = self.normalize_cmd(self.cmd)
141
142 self.hidden = "hidden" in spec.get("attrs", [])
143 self.daemons = self._get_daemons()
144
145 self.doclines = self._spec["doc"].splitlines(keepends=True)
146 if not self.doclines[-1].endswith("\n"):
147 self.warn_loc("docstring does not end with \\n")
148
149 def warn_loc(self, wtext, nodename=None):
150 """
151 Print warning with parseable (compiler style) location
152
153 Matching the way compilers emit file/lineno means editors/IDE can
154 identify / jump to the error location.
155 """
156
157 if nodename:
158 prefix = ": [%s] %s:" % (nodename, self.name)
159 else:
160 prefix = ": %s:" % (self.name,)
161
162 for line in wtext.rstrip("\n").split("\n"):
163 sys.stderr.write(
164 "%s:%d%s %s\n"
165 % (
166 self._spec["defun"]["file"],
167 self._spec["defun"]["line"],
168 prefix,
169 line,
170 )
171 )
172 prefix = "- "
173
174 CommandEntry.warn_counter += 1
175
176 def _get_daemons(self):
177 path = pathlib.Path(self.origin)
178 if path.name == "vtysh":
179 return {}
180
181 defun_file = os.path.relpath(self._spec["defun"]["file"], frr_top_src)
182 defun_path = pathlib.Path(defun_file)
183
184 if defun_path.parts[0] != "lib":
185 if "." not in path.name:
186 # daemons don't have dots in their filename
187 return {"VTYSH_" + path.name.upper()}
188
189 # loadable modules - use directory name to determine daemon
190 return {"VTYSH_" + path.parts[-2].upper()}
191
192 if defun_file in daemon_flags:
193 return {daemon_flags[defun_file]}
194
195 v6_cmd = "ipv6" in self.name
196 if defun_file == "lib/plist.c":
197 if v6_cmd:
198 return {
199 "VTYSH_RIPNGD|VTYSH_OSPF6D|VTYSH_BGPD|VTYSH_ZEBRA|VTYSH_PIM6D|VTYSH_BABELD|VTYSH_ISISD|VTYSH_FABRICD"
200 }
201 else:
202 return {
203 "VTYSH_RIPD|VTYSH_OSPFD|VTYSH_BGPD|VTYSH_ZEBRA|VTYSH_PIMD|VTYSH_EIGRPD|VTYSH_BABELD|VTYSH_ISISD|VTYSH_FABRICD"
204 }
205
206 if defun_file == "lib/if_rmap.c":
207 if v6_cmd:
208 return {"VTYSH_RIPNGD"}
209 else:
210 return {"VTYSH_RIPD"}
211
212 return {}
213
214 def __repr__(self):
3dd40da3 215 return "<CommandEntry %s: %r>" % (self.name, self.cmd)
89cb86ae
DL
216
217 def register(self):
218 """Track DEFUNs so each is only output once."""
219 if not self._registered:
220 self.all_defs.append(self)
221 self._registered = True
222 return self
223
224 def merge(self, other, nodename):
225 if self._cmd_normalized != other._cmd_normalized:
226 self.warn_loc(
3dd40da3 227 "command definition mismatch, first definied as:\n%r" % (self.cmd,),
89cb86ae
DL
228 nodename=nodename,
229 )
3dd40da3 230 other.warn_loc("later defined as:\n%r" % (other.cmd,), nodename=nodename)
89cb86ae
DL
231
232 if self._spec["doc"] != other._spec["doc"]:
233 self.warn_loc(
3dd40da3 234 "help string mismatch, first defined here (-)", nodename=nodename
89cb86ae
DL
235 )
236 other.warn_loc(
3dd40da3
DL
237 "later defined here (+)\nnote: both commands define %r in same node (%s)"
238 % (self.cmd, nodename),
89cb86ae
DL
239 nodename=nodename,
240 )
241
242 d = difflib.Differ()
243 for diffline in d.compare(self.doclines, other.doclines):
244 if diffline.startswith(" "):
245 continue
246 if diffline.startswith("+ "):
247 diffline = _fmt_green + diffline
248 elif diffline.startswith("- "):
249 diffline = _fmt_red + diffline
250 sys.stderr.write("\t" + diffline.rstrip("\n") + _fmt_clear + "\n")
251
252 if self.hidden != other.hidden:
253 self.warn_loc(
3dd40da3
DL
254 "hidden flag mismatch, first %r here" % (self.hidden,),
255 nodename=nodename,
89cb86ae
DL
256 )
257 other.warn_loc(
3dd40da3
DL
258 "later %r here (+)\nnote: both commands define %r in same node (%s)"
259 % (other.hidden, self.cmd, nodename),
89cb86ae
DL
260 nodename=nodename,
261 )
262
263 # ensure name is deterministic regardless of input DEFUN order
264 self.name = min([self.name, other.name], key=lambda i: (len(i), i))
265 self.daemons.update(other.daemons)
266
267 def get_def(self):
268 doc = "\n".join(['\t"%s"' % c_escape(line) for line in self.doclines])
269 defsh = "DEFSH_HIDDEN" if self.hidden else "DEFSH"
270
271 # make daemon list deterministic
272 daemons = set()
273 for daemon in self.daemons:
274 daemons.update(daemon.split("|"))
275 daemon_str = "|".join(sorted(daemons))
276
3dd40da3
DL
277 return """
278%s (%s, %s_vtysh,
279\t"%s",
280%s)
281""" % (
282 defsh,
283 daemon_str,
284 self.name,
285 c_escape(self.cmd),
286 doc,
287 )
89cb86ae
DL
288
289 # accept slightly different command definitions that result in the same command
290 re_collapse_ws = re.compile(r"\s+")
291 re_remove_varnames = re.compile(r"\$[a-z][a-z0-9_]*")
292
293 @classmethod
294 def normalize_cmd(cls, cmd):
295 cmd = cmd.strip()
296 cmd = cls.re_collapse_ws.sub(" ", cmd)
297 cmd = cls.re_remove_varnames.sub("", cmd)
298 return cmd
299
300 @classmethod
301 def process(cls, nodes, name, origin, spec):
302 if "nosh" in spec.get("attrs", []):
303 return
304 if origin == "vtysh/vtysh":
305 return
306
307 if origin == "isisd/fabricd":
308 # dirty workaround :(
309 name = "fabricd_" + name
310
311 entry = cls(origin, name, spec)
312 if not entry.daemons:
313 return
314
315 for nodedata in spec.get("nodes", []):
316 node = nodes[nodedata["node"]]
317 if entry._cmd_normalized not in node:
318 node[entry._cmd_normalized] = entry.register()
319 else:
320 node[entry._cmd_normalized].merge(
321 entry, nodes.nodename(nodedata["node"])
322 )
323
324 @classmethod
325 def load(cls, xref):
326 nodes = NodeDict()
327
fc52ca1e 328 mgmtname = "mgmtd/libmgmt_be_nb.la"
89cb86ae 329 for cmd_name, origins in xref.get("cli", {}).items():
fc52ca1e
CH
330 # If mgmtd has a yang version of a CLI command, make it the only daemon
331 # to handle it. For now, daemons can still be compiling their cmds into the
332 # binaries to allow for running standalone with CLI config files. When they
333 # do this they will also be present in the xref file, but we want to ignore
334 # those in vtysh.
335 if "yang" in origins.get(mgmtname, {}).get("attrs", []):
336 CommandEntry.process(nodes, cmd_name, mgmtname, origins[mgmtname])
337 continue
338
89cb86ae
DL
339 for origin, spec in origins.items():
340 CommandEntry.process(nodes, cmd_name, origin, spec)
341 return nodes
342
343 @classmethod
344 def output_defs(cls, ofd):
345 for entry in sorted(cls.all_defs, key=lambda i: i.name):
346 ofd.write(entry.get_def())
347
348 @classmethod
349 def output_install(cls, ofd, nodes):
350 ofd.write("\nvoid vtysh_init_cmd(void)\n{\n")
351
352 for name, items in sorted(nodes.items_named()):
353 for item in sorted(items.values(), key=lambda i: i.name):
3dd40da3 354 ofd.write("\tinstall_element(%s, &%s_vtysh);\n" % (name, item.name))
89cb86ae
DL
355
356 ofd.write("}\n")
357
358 @classmethod
359 def run(cls, xref, ofd):
360 ofd.write(vtysh_cmd_head)
361
362 NodeDict.load_nodenames()
363 nodes = cls.load(xref)
364 cls.output_defs(ofd)
365 cls.output_install(ofd, nodes)
366
367
368def main():
369 argp = argparse.ArgumentParser(description="FRR xref to vtysh defs")
370 argp.add_argument(
371 "xreffile", metavar="XREFFILE", type=str, help=".xref file to read"
372 )
373 argp.add_argument("-Werror", action="store_const", const=True)
374 args = argp.parse_args()
375
376 with open(args.xreffile, "r") as fd:
377 data = json.load(fd)
378
379 CommandEntry.run(data, sys.stdout)
380
381 if args.Werror and CommandEntry.warn_counter:
382 sys.exit(1)
383
384
385if __name__ == "__main__":
386 main()