]>
Commit | Line | Data |
---|---|---|
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 | """ | |
20 | Generate vtysh_cmd.c from frr .xref file(s). | |
21 | ||
22 | This can run either standalone or as part of xrelfo. The latter saves a | |
23 | non-negligible amount of time (0.5s on average systems, more on e.g. slow ARMs) | |
24 | since serializing and deserializing JSON is a significant bottleneck in this. | |
25 | """ | |
26 | ||
27 | import sys | |
28 | import os | |
29 | import re | |
30 | import pathlib | |
31 | import argparse | |
32 | from collections import defaultdict | |
33 | import difflib | |
34 | ||
35 | import typing | |
36 | from typing import ( | |
37 | Dict, | |
38 | List, | |
39 | ) | |
40 | ||
41 | import json | |
42 | ||
43 | try: | |
44 | import ujson as json # type: ignore | |
45 | except ImportError: | |
46 | pass | |
47 | ||
48 | frr_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 | ||
53 | daemon_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 | ||
71 | vtysh_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 | ||
80 | if sys.stderr.isatty(): | |
81 | _fmt_red = "\033[31m" | |
82 | _fmt_green = "\033[32m" | |
83 | _fmt_clear = "\033[m" | |
84 | else: | |
85 | _fmt_red = _fmt_green = _fmt_clear = "" | |
86 | ||
87 | ||
88 | def 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 | ||
98 | class 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 | ||
137 | class 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 | ||
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() |