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