]>
Commit | Line | Data |
---|---|---|
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 | """ | |
7 | Generate vtysh_cmd.c from frr .xref file(s). | |
8 | ||
9 | This can run either standalone or as part of xrelfo. The latter saves a | |
10 | non-negligible amount of time (0.5s on average systems, more on e.g. slow ARMs) | |
11 | since serializing and deserializing JSON is a significant bottleneck in this. | |
12 | """ | |
13 | ||
14 | import sys | |
15 | import os | |
16 | import re | |
17 | import pathlib | |
18 | import argparse | |
19 | from collections import defaultdict | |
20 | import difflib | |
21 | ||
89cb86ae DL |
22 | import json |
23 | ||
24 | try: | |
25 | import ujson as json # type: ignore | |
26 | except ImportError: | |
27 | pass | |
28 | ||
29 | frr_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 | ||
34 | daemon_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 | ||
52 | vtysh_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 | ||
61 | if sys.stderr.isatty(): | |
62 | _fmt_red = "\033[31m" | |
63 | _fmt_green = "\033[32m" | |
64 | _fmt_clear = "\033[m" | |
65 | else: | |
66 | _fmt_red = _fmt_green = _fmt_clear = "" | |
67 | ||
68 | ||
69 | def 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 | ||
79 | class 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 | ||
118 | class 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 | ||
368 | def 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 | ||
385 | if __name__ == "__main__": | |
386 | main() |