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