netviz GitHub

Bootstrapping from a live network with netviz import

netviz init is for a network you are about to build. netviz import is for the one you already have: it turns machine-readable output collected from real devices into a starting inventory, so the first tree is a diff away from correct instead of a weekend of transcription. This page is the task — what to collect, how to name it, what netviz concludes from it and what it deliberately leaves for you. The option-by-option reference is netviz import.

No network access, ever. netviz opens no socket, reads no credential and runs nothing on a device. You run the collection command — the exact lines are below — and hand netviz what it printed, from a file or from a pipe. The command works fine on a laptop with no route to the network it is documenting, and it keeps netviz out of the business of holding switch passwords.


Contents


Why bootstrap at all

Every other netviz command assumes the YAML tree exists, and typing the first one out by hand is the largest barrier the project has: a forty-port switch is forty interfaces nobody wants to transcribe, and the transcription is wrong by the time it is finished. The devices already know most of it. LLDP knows which port faces which neighbour, ip knows one host's interfaces down to the MTU, and the cabling is usually in a spreadsheet somewhere. import reads those three things and writes the tree, so the work left is correcting a draft rather than producing one.

The tree is written in the layout init produces — devices/<name>.yaml, one document per device, plus cables/links.yaml — with a yaml-language-server modeline on every file, so the result opens in an editor with completion already working. See inventory layout for what the loader makes of that tree, and getting started for the rest of the first-inventory path.


The workflow

Collect, import into an empty directory, format, validate, fix the gaps, commit.

1. Collect. Run the commands in the next section on each device and keep the output in one directory, one file per host named after it.

2. Import into a directory of its own. import refuses to overwrite anything it did not write, so pointing -o at an empty directory keeps the first attempt cheap to throw away:

$ netviz import -o net --exclude 'veth*' collected/*.json collected/patch-panel.csv
4 notes about what was not imported:
  srv-hyper.addr.json: 'lo' is the kernel loopback; it terminates no cable and holds only host-scope addresses, so it was not imported
  srv-hyper.addr.json: 'wg0' is a wireguard tunnel; netviz models a tunnel as its own document naming both ends (docs/schema.md §14) and 'ip' shows only this end, so it was not imported
  ...

wrote 9 files to net:
  devices/ap-lobby.yaml
  devices/pc-alice.yaml
  ...
  cables/links.yaml
  schema/netviz.schema.json

imported 7 devices and 7 cables from 4 inputs

warnings (15):
  ...
I002, W101, W105 are expected of an imported tree: a port whose neighbour was
never captured terminates no cable, and a device only a neighbour named has no
configuration of its own. Capture the missing hosts and re-run, or fill the gaps
in by hand — they are not errors in what was imported.

3. Format it. What import writes is valid but not canonical: it quotes every string that came off a device and leaves MACs plain, where the canonical form does the opposite. One netviz fmt settles that, and it keeps every comment the importer wrote.

netviz -i net fmt
netviz -i net validate

4. Fix the gaps. The report from step 2 already names them. Correct every kind: that came out as the neutral default, fix the fibre runs, extend the trunk lists, name the VLANs — and capture the hosts the tree only knows by hearsay, then import them into a fresh directory and diff.

5. Commit. The generated YAML is meant to be edited and committed, not regenerated. From here on the inventory is the source of truth and the captures are history.


Collecting the input

Run these on each device and keep the output in one directory, one file per host named after it. Nothing else is needed.

Dialect What to run What it gives
lldp lldpctl -f json > "$(hostname -s).lldp.json"
or lldpcli -f json show neighbors > …
Both ends of every link with a neighbour: device names and the port pair, which is exactly a cable. Also the neighbour's kind, from its advertised system capabilities.
iproute ip -j addr show > "$(hostname -s).addr.json" One host in full: interfaces, MAC addresses, MTUs, admin state, IPv4/IPv6 addresses, and — via linkinfo — bridges, bonds and VLAN sub-interfaces. ip -j link show also works and is a subset; pass both and they merge.
csv whatever produces device,port,device,port rows The cabling you already have written down. Optional fifth and sixth columns are medium and label. A header row is detected and skipped.

On a Cisco or Juniper box lldpctl is not available, but the neighbours are: show lldp neighbors detail and its JSON forms are not read by this command — turn them into the four-column CSV instead, which is a one-line awk and is what the CSV dialect exists for.

You do not have to say which file is which. --from defaults to auto, which sniffs each input on its own, so one run may mix all three dialects — that is what makes netviz import collected/* work on a directory holding an LLDP capture, two ip captures and a patch list.

Why CSV and not NetJSON

NetJSON's NetworkGraph describes nodes and links, but a link has no notion of a port: its ends are node ids. Importing it would have to drop the interface pair — the one thing that makes a netviz cable a cable rather than a line on a picture — or invent interface names to hang the link on, which is precisely what this command refuses to do. It would also cost several hundred lines of shape-guessing across the NetworkGraph, NetworkCollection and DeviceConfiguration variants. Four columns carry exactly what a cable needs, and where you do have NetJSON, one jq produces them:

$ jq -r '.links[] | [.source, "?", .target, "?"] | @csv' topology.json > links.csv

(then replace the ?s with the ports, which is the information NetJSON does not hold.)


Naming the host a capture came from

An lldpctl or ip capture describes one host and never says which. netviz takes the name from the first of these that applies:

  1. NAME=path on the argument — netviz import sw-core=neighbors.json. The leading segment counts as a name only when it is already a legal element name, so a path that happens to hold an = is still read as a path;
  2. --host NAME, which applies to every input of that run, so --host pc1 link.json addr.json means the obvious thing. A --host that is not a legal element name is refused rather than rewritten;
  3. the file name up to its first dot — sw-core-01.lldp.jsonsw-core-01. Everything from the first dot is treated as a suffix chain, so the usual collection convention needs no flag at all.

A name that came from a file name is recorded as such in the generated document, because it is the one field the capture did not supply:

# the device name came from the file name 'srv-hyper.addr.json'; the capture itself
# does not say which host it was taken on — pass --host to state it

A CSV needs none of this: every row names both of its devices. And a capture that reaches netviz with no name at all — piped in as - without --host — is refused, with the three ways to fix it.


What each dialect reads

Two of the three dialects describe one host from its own point of view; the third describes links between hosts. Between them they cover the two halves of an inventory: devices/ and cables/.

lldp

lldpctl -f json and lldpcli -f json show neighbors. An LLDP neighbour record is very nearly a cable already: it names the local port, the neighbour's system name and the neighbour's port, which is exactly the pair of device:interface endpoints a cable joins.

Only ports with a neighbour are visible to LLDP, so every device an LLDP capture produces carries a comment saying that a port with no neighbour is missing from it.

iproute

ip -j link show and ip -j addr show, the only dialect that describes a device's configuration rather than its neighbours. One capture yields interface names, MAC addresses, MTUs, admin state, addresses and — through linkinfo — the three stacking constructs netviz models:

linkinfo netviz where the relationship comes from
info_kind: bridge type: bridge members, from the master field of every enslaved link
info_kind: bond, team type: lag the same
info_kind: vlan type: vlan parent from link, the VID from info_data.id

veth, dummy, macvlan, macvtap and ipvlan links are written as type: ethernet with a comment saying which kernel type they really were: as far as this model is concerned they carry frames, hold addresses and can be bridged.

ip -j addr show is a superset of ip -j link show, so passing both files for one host is not merely allowed but the expected thing to do. They merge, first observation winning and later ones filling gaps, and the argument order does not matter. ip -j prints a bare JSON array; a capture pasted into a wrapper object by a collection script — an Ansible stdout field, a {"links": […]} envelope — is unwrapped rather than refused.

Admin state is the UP flag — the administrative state, which is what enabled means — and not operstate, which is the carrier and is not modelled. Only an observed down port is written out, since the schema default is enabled: true.

--exclude PATTERN drops interfaces whose name matches a glob, repeatably. It applies to this dialect, where veth* and docker* are rarely part of a physical topology.

csv

device,port,device,port[,medium[,label]], one row per cable:

# comments and blank lines are ignored
device,port,device,port[,medium[,label]]
sw-core,Gi0/1,pc-alice,eno1
sw-core,Te1/1,sw-edge,Te0/1,fiber,A-014

A header row is detected and skipped — both port columns have to carry a recognised label, so a switch whose ports are genuinely called port1 is not mistaken for one. medium must be copper, fiber or wireless; when the column is absent the cable reads copper and says the value was filled in. Both devices are created if they are not already in the draft, so a CSV alone produces a complete, loadable inventory rather than a set of dangling references.

A cabling list is short and hand-maintained, so a mistake in it stops the run rather than dropping a link quietly: a row with fewer than four or more than six fields, an unknown medium, or a name with no usable character in it is refused with the row number.

$ netviz import --dry-run --from csv patch-panel.csv
...
# ===== cables/links.yaml =====
...
# listed in patch-panel.csv, row 4
apiVersion: netviz.dev/v1alpha1
kind: cable
metadata:
  name: cbl-srv-hyper-eno1-sw-core-01-GigabitEthernet1-0-1
spec:
  endpoints:
    - srv-hyper:eno1
    - sw-core-01:GigabitEthernet1/0/1
  medium: copper
  label: A-014
...
imported 4 devices and 4 cables from 1 input
...

(tests/fixtures/import/ holds the captures netviz's own tests import, so every example on this page can be run as it stands.)


What it will and will not write

The generated YAML is meant to be edited and committed, not regenerated, so it is formatted for a reader: fields in the order of docs/schema.md, a header explaining where the file came from, and a comment beside anything netviz concluded rather than read. Interfaces are written physical ports first and stacked interfaces after them, so a bridge reads below the ports it is built from.

Observed, inferred, absent

Nothing is invented. A field no capture covers is absent — there is no placeholder vendor:, no example address, no description: TODO. Where the kind of a device cannot be determined the document says kind: computer and says why, rather than promoting a box that happens to forward packets into a router:

# inferred: nothing in the captured output states what this device is; 'computer'
# is netviz's neutral default — correct it by hand
kind: computer

Four things are concluded rather than observed, and each is commented in place:

Three smaller conclusions are commented in the same way. A name a device reported that is not a legal netviz identifier is rewritten deterministically and the original recorded beside it — LLDP happily reports Port 1 or a chassis name with a trailing dot, and rejecting those inputs would defeat the point of the command. An address the kernel says came from DHCP or SLAAC is written as configuration with a comment telling you to confirm that it is fixed. A sub-interface ip reports as something other than 802.1Q is flagged rather than silently treated as 802.1Q.

Cable names are derived from the endpoints: the single link between two devices is cbl-pc-alice-sw-core-01, and where a pair is joined by more than one cable every cable of that pair takes the long form with both ports in it, so no existing name changes when a later capture finds a sibling.

What is left out, and said so

Four things are deliberately left out, each reported on stderr rather than dropped silently: the kernel loopback, link- and host-scope addresses (fe80::, 127.0.0.1 — facts about a running kernel, not configuration), the MAC a bridge, bond or VLAN sub-interface borrows from what is underneath it, and tunnel interfaces, since netviz models a tunnel as its own document naming both ends (docs/schema.md §14) and ip shows only one end.

Writing a derived MAC out would state as configuration something the kernel chose, and would trip E003 on the most ordinary Linux host there is.

Four further things cannot become a document and are reported for the same reason:

A link ip reports whose type maps onto no netviz interface type is named in the report with the type it actually was, rather than being turned into an ethernet port on a guess.


Re-running an import

An import is re-run repeatedly while the capture set grows, so the command is built for it. Re-running on the same captures produces the same bytes: names are sanitised deterministically, cables are named from the sorted endpoint pair, and devices and interfaces are written in a fixed order, so a diff after a new capture is about the network rather than about the emitter.

What it will not do is merge into documents you have edited. Without --force, every file already in the output tree is a clash, and all of them are named at once rather than one per run; with --force those files are replaced wholesale, hand edits included. So once you have started editing, import fresh captures into an empty directory and merge the diff yourself. --dry-run prints the tree to stdout and writes nothing, which is the cheap way to see what a new capture set would produce.

--schema (the default) points each document at schema/netviz.schema.json with a modeline, writing the schema when the tree does not already hold one; --no-schema leaves the editor unwired. Two devices whose names differ only in case get two files, the second suffixed, because they are two elements to netviz and one file name on macOS and Windows.

One capture from one host is kilobytes, so an input over 32 MiB is refused: at that size it is a tarball or a log, not a command's output. A directory named as an input is refused too, with the glob to use instead.


The findings afterwards are expected

An imported inventory is partial by construction: LLDP shows only the ports that have a neighbour, ip shows one host, and a device nobody captured exists only because a neighbour named it. import runs the validator over what it wrote and names the rules that follow from that as expected rather than wrong:

Rule Why an import trips it
I002 A port whose neighbour was never captured terminates no cable.
W101 An interface LLDP named has no address, because LLDP does not report one.
W103 A device only a neighbour named has no cable of its own yet.
W105 One captured host is the only element addressed in its subnet.
W109 A device stub has no port a cable could land on.
W113 A VLAN observed on a port of another device is not declared there.
W121 Two capture islands are not yet joined by a captured link.

They are the gaps to fill, by capturing the missing hosts and re-running, or by editing. Validation explains each rule and how to suppress one you have decided to live with.

Anything reported as an error is not expected, and import exits 1 when the tree it wrote does not validate. The files are still written, so you can see what went wrong — but check them before building on them.


Limits


See also