File size: 13,709 Bytes
1bd64c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
#!/usr/bin/env python3
"""
Parse the FRR Master CLI Command Reference (06_Documentation/FRR_Master_CLI_Command_Reference.json)
into the tool's row schema {os, role, vendor, cat, title, cmd, desc, live, in_docs}.

Input shape (per-row):
  {
    "command":        "router bgp AS-NUMBER view NAME",
    "daemon":         "bgpd",
    "description":    "Make a new BGP view...",
    "is_documented":  true,
    "is_running":     false,
    "source_origin":  "bgpd.rst"  # or "Docker Active Kernel"
  }

The master file is a superset: official FRR docs UNION commands actually observed
running on the local Docker FRR routers (the geshlab clab). We surface that
provenance via two extra row fields:

  * `live`     β€” true iff this exact command was observed on a real FRR router
                 (is_running == true). Drives the "● live" badge in the UI.
  * `in_docs`  β€” true iff this command is present in the official FRR RST docs
                 (is_documented == true).

Output: scripts/frr.json β€” same envelope as encor.json / junos.json / arista.json
plus the two FRR-specific provenance flags (other vendors' rows simply lack them
and the UI treats them as missing).

Then `parse.py` merges it into ../commands.json on next run.

No third-party deps; Python 3 stdlib only.
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import sys
from collections import Counter

# --- Paths -------------------------------------------------------------------
# Paths are parameterized so the pipeline is reproducible on any machine.
# Defaults derive from __file__ (output) and an overridable docs base (input),
# preserving the original behaviour when run from the repo on the author's box.

HERE = pathlib.Path(__file__).resolve().parent
REPO = HERE.parent

# Base dir holding the upstream FRR reference JSONs. Override with the
# CLI_WORK_DOCS env var or the --input flag to run off another machine.
DOCS_BASE = pathlib.Path(
    os.environ.get(
        "CLI_WORK_DOCS",
        "/Users/georgigaydarov/02_Projects/Network_Automation/VSS_Code_Georgi/"
        "06_Documentation",
    )
)
DEFAULT_INPUT = DOCS_BASE / "FRR_Master_CLI_Command_Reference.json"
# Older / smaller file kept as a documented fallback if the master one is missing.
LEGACY_INPUT = DOCS_BASE / "FRR_CLI_Command_Reference.json"
# Output lives alongside the other parser outputs in scripts/ (repo-relative).
DEFAULT_OUTPUT = HERE / "frr.json"

# --- Daemon β†’ category map ---------------------------------------------------
# Categories deliberately align with existing categories used by other parsers
# so the Compare view's conceptKey() can line up FRR `router bgp` with Cisco
# `router bgp` and Junos `set protocols bgp`.

DAEMON_TO_CAT: dict[str, str] = {
    "bgpd":            "BGP",
    "bgp-linkstate":   "BGP",            # variant; stays in BGP bucket
    "bmp":             "BGP",            # BMP is a BGP monitoring feature
    "ospfd":           "OSPF",
    "ospf6d":          "OSPFv3",
    "isisd":           "ISIS",
    "fabricd":         "OpenFabric",
    "pim":             "Multicast",
    "pimv6":           "Multicast",
    "ripd":            "RIP",
    "ripngd":          "RIPng",
    "ldpd":            "MPLS",
    "babeld":          "Babel",
    "eigrpd":          "EIGRP",
    "bfd":             "BFD",
    "vrrp":            "VRRP",
    "vnc":             "VNC",
    "nhrpd":           "NHRP",
    "pathd":           "Segment Routing",
    "pbr":             "PBR",
    "rpki":            "RPKI",
    "sharp":           "Troubleshooting",
    "staticd":         "Routing",
    "routemap":        "Routing",        # route-maps are routing policy
    "filter":          "AAA",            # access-lists / prefix-lists policy
    "zebra":           "Routing",
    "ipv6":            "Interfaces",     # ipv6 nd / interface-level
    "basic":           "System",
    "vtysh":           "System",
    "mgmtd":           "System",
    "extlog":          "System",
}

# --- Placeholder substitution to make `cmd` paste-runnable -------------------
# `title` keeps the FRR signature with placeholders intact. `cmd` is a filled
# example so the row's Copy button gives the user something runnable.

# Order matters: longer/more specific patterns first.
_PLACEHOLDER_PASSES: list[tuple[re.Pattern, str]] = [
    # IPv4 prefix
    (re.compile(r"\bA\.B\.C\.D/M\b"),            "10.0.0.0/24"),
    (re.compile(r"\bA\.B\.C\.D\b"),              "10.0.0.1"),
    # IPv6
    (re.compile(r"\bX:X::X:X/M\b"),              "2001:db8::/64"),
    (re.compile(r"\bX:X::X:X\b"),                "2001:db8::1"),
    # MAC
    (re.compile(r"\bH\.H\.H\b"),                 "0050.5681.1234"),
    (re.compile(r"\bHH:HH:HH:HH:HH:HH\b"),       "00:50:56:81:12:34"),
    # Common literal placeholders
    (re.compile(r"\bAS-NUMBER\b"),               "65001"),
    (re.compile(r"\bASN\b"),                     "65001"),
    (re.compile(r"\bROUTER-ID\b"),               "1.1.1.1"),
    (re.compile(r"\bIFNAME\b"),                  "eth0"),
    (re.compile(r"\bINTERFACE\b"),               "eth0"),
    (re.compile(r"\bVRF-?NAME\b", re.IGNORECASE),"VRF1"),
    (re.compile(r"\bVIEW-?NAME\b", re.IGNORECASE),"VIEW1"),
    (re.compile(r"\bRD\b"),                      "65001:1"),
    (re.compile(r"\bRT\b"),                      "65001:1"),
    (re.compile(r"\bCOMMUNITY\b"),               "65001:100"),
    (re.compile(r"\bLINE\b"),                    "DESCRIPTION"),
    (re.compile(r"\bWORD\b"),                    "MYWORD"),
    (re.compile(r"\bNAME\b"),                    "MYNAME"),
    # Numeric range like (1-600) or (0-4294967295) β†’ middle-ish/first sensible
    (re.compile(r"\((\d+)-(\d+)\)"),
        lambda m: str(min(int(m.group(2)), max(int(m.group(1)), 10)))),
    # Choice groups: `[a | b | c]` β†’ `a` (no brackets); `<a | b>` β†’ `a`
    (re.compile(r"\[([^\[\]|]+?)(?:\s*\|\s*[^\[\]]+)?\]"),
        lambda m: m.group(1).strip()),
    (re.compile(r"<([^<>|]+?)(?:\s*\|\s*[^<>]+)?>"),
        lambda m: m.group(1).strip()),
]


def fill_placeholders(cmd: str) -> str:
    """Best-effort substitute placeholders so the cmd is paste-runnable."""
    out = cmd
    for pat, repl in _PLACEHOLDER_PASSES:
        out = pat.sub(repl, out)
    return out.strip()


# --- Description tidying -----------------------------------------------------

_RST_DIRECTIVE = re.compile(
    r"\n\s*\.\.\s+(code-block|sourcecode|note|warning|tip|seealso)::.*?(?=\n\S|\Z)",
    re.DOTALL,
)
_BACKTICK_DOUBLE = re.compile(r"``([^`]+)``")
_BACKTICK_SINGLE = re.compile(r"`([^`]+)`")


def first_sentence(desc: str, cap: int = 220) -> str:
    """Strip RST artifacts and return first 1–2 sentences, capped at `cap`."""
    if not desc:
        return ""
    s = _RST_DIRECTIVE.sub("", desc)
    s = _BACKTICK_DOUBLE.sub(r"\1", s)
    s = _BACKTICK_SINGLE.sub(r"\1", s)
    s = re.sub(r"\s+", " ", s).strip()
    # First sentence β€” first period followed by space/end. Keep up to 2 if short.
    parts = re.split(r"(?<=[.!?])\s+", s)
    out = parts[0]
    if len(out) < 80 and len(parts) > 1:
        out = (out + " " + parts[1]).strip()
    if len(out) > cap:
        out = out[: cap - 1].rstrip() + "…"
    return out


# --- Role inference ----------------------------------------------------------
# FRR is a routing stack used on Linux hosts / clab / SONiC / Cumulus / VyOS-FRR.
# All commands map to role="router" β€” the only role FRR ships in.

def role_for(_daemon: str) -> str:
    return "router"


def cat_for(daemon: str) -> str:
    d = (daemon or "").lower().strip()
    if d in DAEMON_TO_CAT:
        return DAEMON_TO_CAT[d]
    # Fallback by fuzzy match on the daemon name
    if "bgp"   in d: return "BGP"
    if "ospf6" in d: return "OSPFv3"
    if "ospf"  in d: return "OSPF"
    if "isis"  in d: return "ISIS"
    if "rip"   in d: return "RIP"
    if "pim"   in d or "mld" in d or "igmp" in d: return "Multicast"
    if "ldp"   in d or "mpls" in d: return "MPLS"
    if "bfd"   in d: return "BFD"
    if "vrrp"  in d: return "VRRP"
    if "vrf"   in d: return "Routing"
    return "System"


# --- Main --------------------------------------------------------------------

def main(
    input_path: pathlib.Path = DEFAULT_INPUT,
    output_path: pathlib.Path = DEFAULT_OUTPUT,
) -> int:
    # Pick master file if available, otherwise fall back to the older single-source one.
    if not input_path.exists():
        if LEGACY_INPUT.exists():
            print(f"note: master not found, falling back to {LEGACY_INPUT.name}", file=sys.stderr)
            input_path = LEGACY_INPUT
        else:
            print(f"error: FRR source not found: {input_path}", file=sys.stderr)
            return 1

    src = json.loads(input_path.read_text(encoding="utf-8"))
    if not isinstance(src, list):
        print("error: expected top-level list", file=sys.stderr)
        return 1

    rows: list[dict] = []
    seen: set[str] = set()
    daemon_counter: Counter = Counter()
    cat_counter: Counter = Counter()
    prov_counter: Counter = Counter()  # (live, in_docs) β†’ n

    for entry in src:
        cmd_sig = (entry.get("command") or "").strip()
        if not cmd_sig:
            continue
        daemon = (entry.get("daemon") or "").strip().lower()
        desc = entry.get("description") or ""
        # Master file uses is_documented / is_running / source_origin.
        # Legacy file has neither β€” treat as documented, not live.
        in_docs = bool(entry.get("is_documented", "source_file" in entry))
        is_running = bool(entry.get("is_running", False))
        source_origin = entry.get("source_origin") or entry.get("source_file") or ""

        cat = cat_for(daemon)
        title = cmd_sig                       # keep placeholders for the title
        cmd = fill_placeholders(cmd_sig)      # paste-runnable version

        # Build a useful desc. The master file replaces all undocumented-running
        # entries with a single boilerplate string β€” collapse those to a more
        # informative provenance breadcrumb.
        is_boilerplate = desc.strip().startswith("Undocumented operational")
        if is_boilerplate:
            short_desc = ""  # we'll synthesise below
        else:
            short_desc = first_sentence(desc)

        # Provenance prefix: clarify whether this is a doc command, a live one,
        # or both. Engineers reading the card should know if the snippet was
        # verified against a real router or only from the RST docs.
        prov_bits: list[str] = [cat]
        if is_running and in_docs:
            prov_bits.append("βœ“ doc + live (Docker FRR)")
        elif is_running:
            prov_bits.append("● live on Docker FRR (undocumented)")
        # documented-only rows don't need a marker β€” that's the default
        if daemon and not short_desc:
            prov_bits.append(f"daemon: {daemon}")
        prov_prefix = " Β· ".join(prov_bits)
        if short_desc:
            short_desc = f"{prov_prefix} β€” {short_desc}"
        else:
            short_desc = prov_prefix

        key = re.sub(r"\s+", " ", cmd).lower()
        if key in seen:
            continue
        seen.add(key)

        row = {
            "os": "frr",
            "role": role_for(daemon),
            "vendor": "FRR",
            "cat": cat,
            "title": title,
            "cmd": cmd,
            "desc": short_desc,
        }
        # FRR-only provenance flags β€” other vendors' rows simply omit these.
        if is_running:
            row["live"] = True
        if in_docs:
            row["in_docs"] = True
        rows.append(row)
        daemon_counter[daemon] += 1
        cat_counter[cat] += 1
        prov_counter[(is_running, in_docs)] += 1

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(rows, indent=1, ensure_ascii=False))
    print(f"wrote {len(rows)} FRR rows β†’ {output_path}")
    print(f"\nProvenance (running, in_docs) β†’ count:")
    for k, n in prov_counter.most_common():
        running, docd = k
        label = (
            "doc + live (verified)" if running and docd else
            "live only (undocumented)" if running else
            "doc only"
        )
        print(f"  live={int(running):d}  doc={int(docd):d}  {n:5d}  {label}")
    print("\nTop daemons:")
    for d, n in daemon_counter.most_common(15):
        print(f"  {d:20s} {n}")
    print("\nBy category:")
    for c, n in cat_counter.most_common():
        print(f"  {c:20s} {n}")

    # Show 3 sample rows
    print("\nSample rows:")
    for r in rows[:3]:
        print(json.dumps(r, indent=2, ensure_ascii=False))
    return 0


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Parse the FRR Master CLI Command Reference into the tool's row "
            "schema. Paths default to repo-relative output and an overridable "
            "docs base (CLI_WORK_DOCS env var) so the pipeline is reproducible."
        )
    )
    parser.add_argument(
        "--input",
        type=pathlib.Path,
        default=DEFAULT_INPUT,
        help=(
            "Path to the FRR reference JSON (default: %(default)s). "
            "Falls back to FRR_CLI_Command_Reference.json in the same dir if "
            "this file is missing."
        ),
    )
    parser.add_argument(
        "--output",
        type=pathlib.Path,
        default=DEFAULT_OUTPUT,
        help="Path to write the parsed frr.json (default: %(default)s).",
    )
    return parser.parse_args(argv)


if __name__ == "__main__":
    args = parse_args()
    sys.exit(main(args.input, args.output))