]>
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", | |
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 | ||
54 | vtysh_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 | ||
63 | if sys.stderr.isatty(): | |
64 | _fmt_red = "\033[31m" | |
65 | _fmt_green = "\033[32m" | |
66 | _fmt_clear = "\033[m" | |
67 | else: | |
68 | _fmt_red = _fmt_green = _fmt_clear = "" | |
69 | ||
70 | ||
71 | def 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 | ||
81 | class 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 | ||
120 | class 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 | ||
370 | def 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 | ||
387 | if __name__ == "__main__": | |
388 | main() |