Operational artefacts with netviz export
An inventory is a declaration of what the network is. netviz render turns
that into a picture. netviz export turns it into files other tools consume:
The same file that draws the diagram writes the hosts file, the zone, the Ansible inventory, the monitoring targets, the cabling pull-list and the power load schedule.
netviz export FORMAT [-o FILE] [--out DIR] [--manifest FILE] [FILTERS] [FORMAT OPTIONS]
Nothing is probed and no device is contacted. Every artefact is a pure function of the resolved inventory and the same graph a rendering is drawn from, so an export cannot disagree with a diagram of the same tree.
Contents
- The eight formats that describe the network
hostsdns-zoneansible-inventoryprometheus-sdcable-listroutespowerdrawio- Device configuration: the seven dialects
- What every format guarantees
- Names, and how they are folded
- The skip manifest
- What each format drops
- Scoping an export
- Options
- Exit codes
The eight formats that describe the network
| Format | Artefact | Default extension |
|---|---|---|
hosts |
An /etc/hosts fragment, one line per address |
.hosts |
dns-zone |
RFC 1035 forward zone and the matching reverse zones | .zone |
ansible-inventory |
Ansible's JSON inventory schema | .json |
prometheus-sd |
Prometheus file_sd targets |
.json |
cable-list |
A CSV or Markdown pull-list, one row per physical run | .csv |
routes |
An iproute2 script of the static routes each device declares | .sh |
power |
A load schedule, one row per power feed | .csv |
drawio |
An mxGraph diagram draw.io opens, edits and hands back | .drawio |
With no -o, the artefact goes to stdout and everything else — progress
notes, validation findings and the skip manifest — goes to
stderr, so netviz export prometheus-sd | jq does what it looks like.
Six further formats are not descriptions of the network but the configuration a device would run from: Device configuration: the seven dialects.
hosts
$ netviz -i examples/home-lab export hosts
# Generated by 'netviz export hosts' -- do not edit.
# netviz 0.0.3. Re-run the command to regenerate from the inventory.
# 7 element(s), 15 address(es).
# Loopback and link-local addresses and unnumbered interfaces are left out;
# the manifest on stderr says which elements produced nothing and why.
# Each element is published under its qualified name and, as an alias,
# its own name: 'sw-01.access.north.sites sw-01'.
#
192.0.2.1 rtr-home.routers rtr-home
192.168.10.1 rtr-home.routers rtr-home
192.168.10.2 sw-home.switches sw-home
192.168.10.3 ap-home.wireless ap-home
192.168.10.10 srv-nas.hosts srv-nas
192.168.10.20 pc-desk.hosts pc-desk
192.168.10.30 adp-usb-eth.hosts adp-usb-eth
192.168.10.40 phone.hosts phone
203.0.113.2 rtr-home.routers rtr-home
2001:db8::1 rtr-home.routers rtr-home
2001:db8:10::1 rtr-home.routers rtr-home
2001:db8:10::10 srv-nas.hosts srv-nas
2001:db8:10::20 pc-desk.hosts pc-desk
2001:db8:10::30 adp-usb-eth.hosts adp-usb-eth
2001:db8:10::40 phone.hosts phone
{
"apiVersion": "netviz.dev/v1alpha1",
"kind": "ExportManifest",
"format": "hosts",
"counts": {
"considered": 8,
"emitted": 7,
"skipped": 2,
"rewritten": 0
},
"skipped": [
{
"subject": "hosts/laptop",
"reason": "not-routable",
"detail": "every configured address is loopback or link-local, so none of them identifies the element on this network"
},
{
"subject": "hosts/laptop:lo",
"reason": "unnumbered",
"detail": "2 address(es), all loopback or link-local"
}
],
"rewritten": []
}
exported hosts: 7 of 8 emitted, 2 skipped (not-routable 1, unnumbered 1)
One line per address, not per element: a router with four addresses gets
four lines, which is what makes ping rtr-home reach it on whichever family is
up. Each line carries the qualified name first — the hosts(5) convention puts
the canonical name before its aliases — and the element's own name second.
Where two elements share an address (which E004 reports and --force still
exports), both names land on one line: a resolver reading two lines for one
address uses the first and ignores the second without saying so.
Ordering is by address family and then by numeric value, so 10.1.2.10 follows
10.1.2.9 rather than preceding it.
dns-zone
$ netviz -i examples/patch-room export dns-zone --origin lab.example.com --zones forward
...
;; zone: forward
$ORIGIN lab.example.com.
$TTL 3600
@ IN SOA ns.lab.example.com. hostmaster.lab.example.com. (
1 ; serial
86400 ; refresh
7200 ; retry
3600000 ; expire
3600 ) ; minimum
@ IN NS ns.lab.example.com.
ap-ceiling-01.hosts IN A 10.10.0.4
cam-lobby-01.hosts IN A 10.10.0.31
laptop-lobby.hosts IN A 10.10.0.41
rtr-edge-01.network IN A 10.0.0.1
rtr-edge-01.network IN A 192.0.2.1
srv-app-01.hosts IN A 10.10.0.11
srv-db-01.hosts IN A 10.10.0.12
sw-access-01.network IN A 10.10.0.3
sw-core-01.network IN A 10.0.0.2
sw-core-01.network IN A 10.10.0.2
...
--origin is required: a zone file has no meaning without the domain its
records hang under, and an origin that is not a domain name is refused before
the inventory is even read.
The reverse zones come from the same prefixes ipam sizes
Reverse zones are not re-derived here. netviz.subnets
is the one implementation of "which prefixes exist, and who sits in them?" —
shared with the layer-3 diagram, netviz list subnets and
netviz ipam — and each prefix is rounded down to the nearest
delegation boundary: an octet for IPv4 (RFC 1035 §3.5), a nibble for IPv6
(RFC 3596 §2.5).
| Prefix | Reverse zone |
|---|---|
10.0.0.0/24 |
0.0.10.in-addr.arpa. |
10.0.0.0/22 |
0.10.in-addr.arpa. — the /16 zone, because a /22 is not on a boundary |
10.1.2.0/30 |
2.1.10.in-addr.arpa. |
192.0.2.1/32 |
2.0.192.in-addr.arpa. — a host route still needs an owner name inside a zone |
2001:db8::/48 |
0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. |
Every forward record has a matching PTR and every PTR a matching forward record. That is the failure this export exists to prevent: a stale PTR left behind after a device was re-addressed.
One document, several zones
--zones all (the default) writes the forward zone and every reverse zone into
one document, separated by ;; zone: banners. That is for reading and
diffing. A nameserver loads one zone per file, so publishing means:
$ netviz export dns-zone --origin example.com --zones forward -o db.example.com
$ netviz export dns-zone --origin example.com --zones reverse -o reverse.zones
The serial does not move on its own
--serial defaults to 1 and is never derived from the clock. A date-based
serial would make every export a diff, which would defeat the point of a
committed artefact. Bump it in the pipeline that publishes the zone:
$ netviz export dns-zone --origin example.com --serial "$(date +%Y%m%d%H)" -o db.example.com
SOA and NS parameters — --soa-mname, --soa-rname, --refresh, --retry,
--expire, --minimum, --ns — default to the conventional values of
RFC 1912 §2.2. Every zone written carries its own apex SOA and at least one NS,
because a zone without them is one no nameserver will load.
ansible-inventory
$ netviz -i examples/campus export ansible-inventory -o inventory.json
$ ansible-inventory -i inventory.json --graph
$ ansible-playbook -i inventory.json site.yml
The document is Ansible's own JSON schema: a _meta.hostvars table plus one key
per group naming its hosts and children.
Groups
Four axes, each prefixed so two of them cannot collide on a name:
| Prefix | From | Example |
|---|---|---|
ns_ |
The namespace, nested | ns_sites_north_access, a child of ns_sites_north, a child of ns_sites |
kind_ |
The element kind of §3 | kind_switch |
vendor_ |
spec.vendor |
vendor_cisco |
role_ |
The role label, when the inventory uses one |
role_distribution |
Namespace groups nest, so group_vars/ns_sites_north.yml applies to a whole
site exactly as a reader of the folder tree would expect. Intermediate groups
are created even when no element sits directly in them.
Namespace groups are folded segment by segment, so sites/1-north and
sites/2-north stay two groups. Where two different namespaces do fold to one
group — a/b and a b — the merge is reported in the manifest rather than left
for somebody to find from a playbook that touched more hosts than they meant.
Group names are lower-cased. Ansible group names are case-sensitive, which is
precisely why: an estate that writes Cisco on one device and cisco on the
next would otherwise get two vendor groups, and a playbook targeting either
would silently skip half the fleet. Every fold is recorded in the
manifest.
ansible_host
One field cannot hold the four addresses a router has, so the choice is made by a fixed ranking rather than by whichever interface happens to be first:
- An interface that says it is for management —
mgmt0,Mgmt1,idrac,ilo,bmc, or any interface whose description mentions management or out-of-band. Matched case-insensitively, in the name and the description. - A loopback interface with a routable address — a router ID. It is up whenever any path to the device is, which is what a management target wants.
- Anything else, in the order the interfaces were declared.
Within each tier IPv4 comes before IPv6: a dual-stacked estate still has tooling that only speaks v4. An element with no routable address is not a host anybody can reach, so it is skipped and recorded rather than emitted with a null.
Host variables
Everything a playbook can template a device's configuration from, namespaced
under netviz_ so nothing can collide with a variable Ansible or a role
already defines:
| Variable | Contents |
|---|---|
netviz_element |
The fully-qualified name, e.g. sites/north/access/sw-north-acc-01 |
netviz_name, netviz_kind, netviz_namespace |
Identity |
netviz_description, netviz_labels |
metadata.description and metadata.labels |
netviz_vendor, netviz_model, netviz_serial |
Hardware, when declared |
netviz_location |
site, room, rack, position, height from metadata.location |
netviz_addresses |
Every routable address, 10.0.0.1/24 form, canonically sorted |
netviz_interfaces |
One record per interface in declaration order: name, type, enabled, description, mac, mtu, addresses, VLAN mode and VLAN ids |
netviz_vlan_ids |
Every VLAN the element participates in (§9.3) |
netviz_vlans |
The device VLAN database — id, name, description |
Interfaces keep declaration order deliberately: the set of hosts and groups is sorted, but a template rendering one host's interfaces back into a device configuration should produce the order a person wrote.
Or skip the file: the plugin
This document is a snapshot, which is the right thing for a control node that
cannot have netviz installed and the wrong thing everywhere else. The
netviz.netviz collection (docs/ansible.md) builds the same
document at play time — plus host variables and groups that are queries — and
adds the piece a file cannot have: a lookup, so a template can ask the network
for the address it is about to write down.
{% for address in query('netviz.netviz.query',
'select (device filter .fqn = $fqn).addresses.address') %}
Address={{ address }}
{% endfor %}
prometheus-sd
$ netviz -i examples/campus export prometheus-sd --port 9100 -o /etc/prometheus/targets/netviz.json
# prometheus.yml
scrape_configs:
- job_name: network
file_sd_configs:
- files: [/etc/prometheus/targets/netviz.json]
Prometheus re-reads the file whenever it changes, so regenerating it in the same pipeline that renders the diagram keeps monitoring and documentation in step by construction.
The output is a JSON array of target groups — one per element, because the labels are per element:
[
{
"targets": ["10.1.99.11:9100"],
"labels": {
"instance": "sw-north-acc-01.access.north.sites",
"netviz_element": "sites/north/access/sw-north-acc-01",
"netviz_name": "sw-north-acc-01",
"netviz_namespace": "sites/north/access",
"netviz_kind": "switch",
"netviz_vendor": "Cisco"
}
}
]
--port is optional; without it the target is the bare address, which is valid
file_sd and lets the scheme's default port apply. An IPv6 target is bracketed
per RFC 3986 §3.2.2 when a port is appended, because 2001:db8::1:9100 is
unparseable.
--label KEY=VALUE merges a static label into every target. Three names are
refused rather than emitted: a reserved __-prefixed one, which Prometheus
strips after relabelling; instance; and anything in the netviz_ namespace.
The last two are computed per element, and a static value for one of them
would give every target in the estate the same identity — from a file that looks
correctly configured.
The target address is the same management address ansible_host uses. A label
whose value is empty is omitted rather than written as "".
Deliberately absent: the netviz version. Every label becomes part of the identity of every time series scraped from the target, so a label that changed on upgrade would end each series and start a new one. The version of the tool that wrote the file is not worth a break in the history.
cable-list
The artefact somebody carries into a room:
$ netviz -i examples/patch-room export cable-list --table-format markdown
| RUN | SEGMENT | CABLE | LABEL | MEDIUM | LENGTH_M | A_ELEMENT | A_PORT | A_PANEL_PORT | A_RACK | A_UNIT | B_ELEMENT | B_PORT | B_RACK | B_UNIT | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | | | cables/cbl-rtr-sw | C-001 | copper | 1 | network/rtr-edge-01 | ge-0/0/0 | | r1 | 40 | network/sw-core-01 | GigabitEthernet1/0/1 | r1 | 38 | | sw-core-01:GigabitEthernet1/0/7 - srv-app-01:eno1 | 1 of 3 | cables/cbl-sw-pp07 | P-007A | copper | 1 | panels/pp-r1-a | front/7 | front/7 | r1 | 42 | network/sw-core-01 | GigabitEthernet1/0/7 | r1 | 38 | | sw-core-01:GigabitEthernet1/0/7 - srv-app-01:eno1 | 2 of 3 | cables/cbl-tie-07 | T-007 | copper | 18 | panels/pp-r1-a | rear/7 | rear/7 | r1 | 42 | panels/pp-r2-a | rear/7 | r2 | 42 | | sw-core-01:GigabitEthernet1/0/7 - srv-app-01:eno1 | 3 of 3 | cables/cbl-pp-app07 | P-007B | copper | 2 | panels/pp-r2-a | front/7 | front/7 | r2 | 42 | hosts/srv-app-01 | eno1 | r2 | 10 |
(abbreviated — the real table also carries CATEGORY, CONNECTOR, SPEED and
the SITE/ROOM columns for both ends.)
One row per cable document, which is one run of cable an installer pulls.
The graph is built at --layer physical
for exactly that reason: every other layer splices a run through a patch panel
into the single logical link it is electrically equivalent to (§15.2), which is
right for a diagram and wrong for a worklist — the two segments either side of a
panel are two separate things to pull, terminate and label.
The panel is not lost. RUN names the end-to-end link the segment belongs to,
taken from the spliced view, and SEGMENT says which leg of it this is, so the
three rows of one logical link sort together and read 1 of 3, 2 of 3,
3 of 3. A direct cable is its own run and leaves both columns blank.
The columns
| Column | Contents |
|---|---|
RUN |
The end-to-end link this segment is part of, when it crosses a patch panel |
SEGMENT |
2 of 3 — which leg of that run |
CABLE |
The cable's fully-qualified name |
LABEL |
spec.label — what is printed on the cable |
MEDIUM, CATEGORY, CONNECTOR, SPEED |
copper/fiber/wireless, cat6a, rj45, 1Gbps |
LENGTH_M |
spec.length_m, or blank when undeclared — never 0, which a spreadsheet would sum |
| any cell | A field starting with =, +, @ or a non-numeric - is prefixed with ', so a cable labelled =HYPERLINK(…) is text rather than a live formula when the sheet is opened (CWE-1236) |
A_ELEMENT, A_PORT |
One end, and the port on it |
A_PANEL_PORT |
The same port when the element is a patchpanel, so a technician scanning for "which panel position" does not have to know which end is the panel |
A_SITE, A_ROOM, A_RACK, A_UNIT |
Where that end is, from metadata.location (§3.2) |
B_* |
The other end, same columns |
A and B are the cable's endpoints in the canonical order §7.1 stores them
in, so a run does not swap ends between two exports of the same inventory.
Rows are sorted by where the A end is — site, room, rack, unit — so the list
is walked rack by rack. Anything the inventory does not place sorts last.
--table-format csv (the default) is RFC 4180 with \n line endings; a cable
label containing a comma, a quote or a newline is quoted by the csv module.
--table-format markdown holds exactly the same rows and columns; a | is
backslash-escaped and a line break becomes <br>.
routes
The static routes of spec.routes
(docs/schema.md §16.3) and the policy
database of spec.routing_policy
(§16.4), as a script the
device's own host can run. The two are emitted together because they are useless
apart — a table nobody selects holds routes nobody consults — and the routes come
first, so no rule ever diverts traffic into a table that is still empty:
$ netviz -i examples/home-lab export routes
#!/bin/sh
# Generated by 'netviz export routes' -- do not edit.
# netviz 0.0.3. Re-run the command to regenerate from the inventory.
# 0 device(s) with routing to apply, 0 command(s).
# Apply with 'sh <file>' on the device, or 'sh <file> <device name>'.
# Only 'spec.routes' and 'spec.routing_policy' are emitted: BGP and OSPF
# configuration is vendor syntax and is deliberately not invented here. The
# manifest on stderr says what each device contributed and what was left out.
# A routing table is named by number; its name from the inventory is in the
# trailing comment, since a name resolves only through /etc/iproute2/rt_tables.
#
set -eu
target="${1:-$(uname -n)}"
case "$target" in
*)
echo "netviz: no routing declared for '$target'" >&2
exit 1
;;
esac
...
The manifest on stderr — elided above — says the same thing seven times over:
each element declares no spec.routes. The home-lab inventory declares no static
route at all, which is what a script with
nothing but its dispatcher looks like — and it still exits non-zero rather than
quietly doing nothing. An inventory that does declare routes gets one shell
function per device and one case arm per name:
# sites/north/core/rtr-north-core-01
netviz_routes_sites_north_core_rtr_north_core_01() {
ip -4 route replace blackhole 10.1.0.0/16 metric 250
ip -4 route replace 10.2.99.0/24 via 198.51.100.2 dev xe-0/0/1 metric 200
}
…
target="${1:-$(uname -n)}"
case "$target" in
rtr-north-core-01|sites/north/core/rtr-north-core-01)
netviz_routes_sites_north_core_rtr_north_core_01
;;
Four decisions worth knowing about:
- Nothing runs until the dispatcher matches. The file is inert on a machine
the inventory does not describe: it exits non-zero and says so, rather than
applying somebody else's routing table. A device is matched by its own name
and by its fully-qualified name, because
uname -nreports the former and a deployment pipeline usually holds the latter. - Every command is a
replace. An idempotent script can be re-run after a partial failure, whichaddcannot — it fails on a route that is already there and leaves the rest unapplied. - A VRF becomes
vrf <name>, which iproute2 resolves to that instance's table, andblackholebecomes a route type before the prefix, which is where iproute2 wants it. set -eu, so a route the kernel rejects stops the script instead of leaving a table half applied.
power
The electrical counterpart of the pull list
(docs/schema.md §17.7). An installer carries
the cable-list; the person signing off a rack carries this — which outlet, on
which strip, on which feed, powering which box in which rack unit, drawing how
many watts:
$ netviz -q -i examples/patch-room export power
| FEED_KIND | SOURCE | OUTLET | SOURCE_PORT | INPUT_FEED | LOAD | PSU | LOAD_RACK | LOAD_UNIT | RESERVED_W | ELEMENT_W | REDUNDANT | VIA |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| outlet | power/pdu-r1-a | 1 | utility-a | network/sw-core-01 | psu1 | r1 | 38 | 24 | 48 | yes | ||
| outlet | power/pdu-r1-b | 1 | ups-1 | network/sw-core-01 | psu2 | r1 | 38 | 24 | 48 | yes | ||
| outlet | power/pdu-r2-a | 1 | utility-a | hosts/srv-app-01 | psu1 | r2 | 10 | 210 | 420 | yes | ||
| outlet | power/pdu-r2-b | 1 | ups-1 | hosts/srv-app-01 | psu2 | r2 | 10 | 210 | 420 | yes | ||
| poe | network/sw-access-01 | GigabitEthernet1/0/1 | hosts/ap-ceiling-01 | 30 | 22 | pp-r2-a | ||||||
| poe | network/sw-access-01 | GigabitEthernet1/0/2 | hosts/cam-lobby-01 | 7 | 5 |
(abbreviated — the real schedule also carries SOURCE_KIND, LOAD_KIND and the
SITE/ROOM/RACK/UNIT columns for the source end.)
One row per feed, not per element. A dual-corded server is two rows, which is
the whole point: the two cords are two different things somebody can unplug, and a
schedule that summarised them into one would hide exactly the fact it exists to
show. A PoE-powered camera is a row too, and its FEED_KIND says poe so that a
reader summing outlet loads does not double-count something that occupies no
outlet.
Rows are ordered by where the source end sits — site, room, rack, unit — so the schedule is walked strip by strip, and anything the inventory does not place sorts last.
csv or json
| Option | Default | Effect |
|---|---|---|
--schedule-format csv|json |
csv |
How the schedule is laid out. |
csv is the sheet somebody prints and initials. json is the same feed rows plus
the per-PDU and per-PSE totals — outlets used and free, load, failover, spare
watts, utilisation, and per PoE switch the allocation of each port against the
budget — which a capacity tool wants and a spreadsheet computes for itself:
$ netviz -q -i examples/patch-room export power --schedule-format json | jq '.pdus[2]'
{
"pdu": "power/pdu-r2-a",
"name": "pdu-r2-a",
"outlets": 8,
"usedOutlets": 3,
"freeOutlets": 5,
"loadWatts": 492.5,
"failoverWatts": 985.0,
"loads": ["hosts/srv-app-01", "hosts/srv-db-01", "network/sw-access-01"],
"inputFeed": "utility-a",
"capacityWatts": 1840.0,
"freeWatts": 1347.5,
"utilisation": 0.267663
}
Neither shape is the lossy one: the CSV holds every fact about every feed, and the JSON adds only sums derived from them.
The columns of the schedule
Twenty-two, in the one order the CSV uses. SOURCE and LOAD are the two ends of
the feed, always in that direction — power flows one way, unlike a cable, so there
is nothing to canonicalise and no A/B to look up.
| Column | Contents |
|---|---|
FEED_KIND |
outlet for a cord from a PDU, poe for power over the uplink |
SOURCE, SOURCE_KIND |
What feeds it: the PDU, or the switch whose PSE port does |
OUTLET |
The outlet as the PDU numbers it. Blank on a poe row |
SOURCE_PORT |
The PSE port. Blank on an outlet row |
INPUT_FEED |
The PDU's input_feed — utility-a, ups-1 — which is what makes an A/B pair legible |
SOURCE_SITE, SOURCE_ROOM, SOURCE_RACK, SOURCE_UNIT |
Where that end is, from metadata.location. A 0U strip has a rack and no unit |
LOAD, LOAD_KIND |
The powered element |
PSU |
Which supply on the device this feeds, e.g. psu1 — what an operator reads off the back of a chassis |
LOAD_PORT |
The port the power arrives on. Blank on an outlet row |
LOAD_SITE, LOAD_ROOM, LOAD_RACK, LOAD_UNIT |
Where the load is |
RESERVED_W |
What this feed carries: typical / n for a device with n cords, and the PSE-side class figure on a poe row |
ELEMENT_W |
What the whole element draws, so the two cords of a server are recognisable as halves of one load |
REDUNDANT |
yes when the element claims redundant: true |
VIA |
The patch panels the PoE run crosses, in order — pp-r2-a |
VIA is the column that makes a PoE feed traceable. ap-ceiling-01 in
examples/patch-room is fed from
sw-access-01:GigabitEthernet1/0/1 through pp-r2-a, because a run through a
panel is electrically one run for power exactly as it is for frames
(§17.4) — and the person who has to
find the dead access point needs the panel position, not just the switch port.
Every cell goes through the same spreadsheet-injection guard cable-list uses: a
field starting with =, +, @ or a non-numeric - is prefixed with '
(CWE-1236).
What it drops, and what the manifest gets instead
Everything that is not power. Cables, addressing and VLANs have their own exports;
a load schedule that also carried them would be a second copy of the inventory.
The data path a PoE feed rides on is named only by VIA and the two ports — its
medium, length and label are the cable-list's business.
Two things are not rows, and are recorded in the manifest instead:
- a feed whose
pdu:outletdid not resolve — there is no outlet to schedule, so the row would be a lie; - an element that declares a draw and no power path at all
(
W137), because a load nobody can find the socket for is precisely what a schedule is supposed to surface.
Only --force gets that far: the validator refuses both first.
drawio
The one format here that a person opens rather than a machine — and, unlike the SVG a render produces, edits and hands back. It writes one mxGraph model of one view: a vertex per node carrying the stored arrangement so the file opens already arranged, an edge per link with its waypoints, a container frame per namespace, and the shipped icons inlined as data URIs so the file is self-contained.
What makes it more than a picture is the identity block on each cell —
netviz:name, netviz:kind, netviz:document, netviz:hash — which is
what lets netviz import drawio tell a moved switch from
a new one when the file comes back.
$ netviz export drawio --view l1 --icons none
...
exported drawio: 15 of 15 emitted, 1 skipped (not-arranged 1)
The skip is the interesting part of that line: home-lab has no stored
arrangement, so the export laid its nodes out on a grid rather than reproducing
one somebody chose. Run netviz layout --write first and
the file opens the way your diagrams actually look.
drawio.md is the whole workflow, including the part that matters most: what a draw.io user may and may not safely change.
Device configuration: the seven dialects
Everything above is about the network. A hosts file, a zone, a pull-list, a monitoring target — each is a description some other tool consumes, and none of them is the network. The configuration a device actually runs is, and until netviz could write one the inventory was a document beside the truth rather than the source of it: somebody still typed the addresses into the box, and the typing is where the two started to disagree.
These seven formats close that loop. Six of them describe how a device is
wired; nftables describes what it refuses, which is the one half of a
configuration none of the others could write. Each is a pure function from one device's
resolved inventory to a set of files, named at the paths that device keeps them
at, so the output is something to copy into place rather than something to
translate first:
| Dialect | Writes | Devices it covers | What it refuses |
|---|---|---|---|
netplan |
etc/netplan/10-netviz.yaml, one document per host |
computer, server, router |
The 802.1Q configuration of a bridge port, and a VRF — netplan's vrfs section needs a numeric routing table and an inventory states a route distinguisher |
networkd |
etc/systemd/network/10-<port>.network, plus a 20-<name>.netdev and .network for every link the host builds |
computer, server, router |
A VRF, for the same missing table number. Nothing else: [BridgeVLAN] says the thing netplan cannot, and [RoutingPolicyRule] writes the policy database of §16.4 |
ifupdown |
etc/network/interfaces — the Debian original, with routes as up/down hooks |
computer, server, router |
A bridge port's 802.1Q, a VRF, and an ap radio: wpa-ssid configures a station, so pointing it at the SSID the radio is meant to beacon would make it a client of its own network |
frr |
etc/frr/frr.conf — VRFs, static routes, OSPF areas, BGP neighbours |
Any device declaring spec.routing, spec.routes or spec.vrfs |
Nothing. FRR's grammar is a superset of what §16 can state about routing, and inventing a refusal to have one would be dishonest |
nftables |
etc/nftables.conf — one table inet netviz with a set per zone, the three base chains and whatever NAT the translations need |
Any device declaring spec.firewall |
A rule using invert: nftables negates one expression at a time (!=) and spec.firewall inverts the whole selector set, so the rule would be written matching the opposite of what the document says |
wireguard |
etc/wireguard/<interface>.conf, one file per tunnel with an end here |
Any device terminating a wireguard tunnel |
A cipher other than WireGuard's own, and an auth that is not public-key — that is the part deciding whether the two ends can speak at all |
interfaces |
interfaces.conf — netviz's own vendor-neutral grammar |
Every device | Nothing, ever. It is not somebody else's format; it is defined as whatever holds one device's interface configuration completely |
The first six are somebody else's grammar and therefore cover only the devices
that grammar is for. interfaces is the fallback that makes the set complete: a
Catalyst, a MikroTik, an access point and a hub all get a file, because an export
that silently covers a third of an estate is worse than one that covers none —
the missing third is the third somebody will assume is fine. Nothing consumes
interfaces.conf; it is what a person reads before typing into a vendor CLI,
what a diff shows when a switch's configuration is meant to change, and what
netviz drift reads back.
One device selected by name goes to stdout, which is the case worth optimising for — it is a file, and piping it somewhere is the point:
$ netviz -q -i examples/home-lab export netplan --name pc-desk
# Generated by 'netviz export netplan' -- do not edit.
# netviz 0.0.3. Re-run the command to regenerate from the inventory.
# netviz-dialect: netplan
# netviz-element: hosts/pc-desk (computer)
# netviz-source: hosts/pc-desk.yaml
# Apply with 'netplan try' first: this file replaces the interface configuration
# of the host, and nothing here was read off the running system.
#
network:
version: 2
ethernets:
eno1:
addresses:
- 192.168.10.20/24
- 2001:db8:10::20/64
macaddress: "3c:97:0e:20:01:01"
mtu: 1500
The radio wlp1s0 is in the inventory and not in that file, and the manifest on
stderr says why: netplan's wifis: section requires at least one access point
and refuses the whole document without one, so a radio the inventory names no
SSID on cannot be written — and netviz will not invent an SSID to fill the key
in. That is a skip rather than a refusal, because the rest of the file is still
correct for the rest of the host, and netviz drift knows netplan cannot list
a radio, so the absence reads as a blind spot rather than as a deletion.
The three host dialects differ in shape rather than in content, and the shape is
the format's, not netviz's. ifupdown's iface carries a single address, so a
dual-stacked port is two stanzas — the eno1:0 idiom is a 2.x-era label on an
address rather than a second address, and generating labels would put names into
ip addr output the inventory never mentions:
$ netviz -q -i examples/home-lab export ifupdown --name srv-nas
# Generated by 'netviz export ifupdown' -- do not edit.
# netviz 0.0.3. Re-run the command to regenerate from the inventory.
# netviz-dialect: ifupdown
# netviz-element: hosts/srv-nas (server)
# netviz-source: hosts/srv-nas.yaml
# This is the whole file: 'ifup -a' reads it in order, so it replaces
# /etc/network/interfaces rather than adding to it. The distribution's 'lo'
# stanza and any 'source' line are not reproduced here -- keep them.
# Nothing below was read off the running system; try 'ifup --no-act <iface>'.
#
auto eth0
iface eth0 inet static
address 192.168.10.10/24
mtu 1500
hwaddress ether 00:11:32:40:01:01
iface eth0 inet6 static
address 2001:db8:10::10/64
Emitted, skipped, refused
The whole design turns on a three-way distinction, and it is worth learning before anything else here, because it is what decides whether you get a file:
Emitted. The inventory states it and the dialect can write it. It is in the file.
Skipped. The field is outside the dialect's remit. A PoE budget in netplan, an OSPF area in ifupdown, a radio's SSID in systemd-networkd: none of them makes the generated file wrong, because the file was never the place for them. So it is recorded in the skip manifest, naming the dialect that does cover it, and the file is written. Skipping a whole device works the same way — netplan has nothing to say about a switch:
$ netviz -i examples/home-lab export netplan --name sw-home
{
"apiVersion": "netviz.dev/v1alpha1",
"kind": "ExportManifest",
"format": "netplan",
"counts": {
"considered": 1,
"emitted": 0,
"skipped": 1,
"rewritten": 0
},
"skipped": [
{
"subject": "switches/sw-home",
"reason": "not-representable",
"detail": "netplan is rendered by a Linux host; a switch is configured through its own CLI, so 'netviz export interfaces' is what describes this device"
}
],
"rewritten": []
}
exported netplan: 0 of 1 emitted, 1 skipped (not-representable 1)
The detail names the command to run instead. That is the whole difference between a skip and a hole — and it works at the level of a single field, too. A campus core router gets a perfectly good netplan file and two skips, one for its OSPF and BGP configuration and one for its loopback:
$ netviz -i examples/campus export netplan --name rtr-north-core-01
# Generated by 'netviz export netplan' -- do not edit.
...
"subject": "sites/north/core/rtr-north-core-01",
"reason": "not-representable",
"detail": "'spec.routing' is a routing protocol; netplan configures interfaces. Generate it with 'netviz export frr'"
},
...
exported netplan: 1 of 1 emitted, 2 skipped (not-representable 2)
Do as it says, and the routing that netplan had no business with comes out of the dialect that does:
$ netviz -q -i examples/campus export frr --name rtr-north-core-01
! Generated by 'netviz export frr' -- do not edit.
...
ip route 10.1.0.0/16 blackhole
ip route 10.2.99.0/24 198.51.100.2 xe-0/0/1
!
! The area is enabled per interface, with 'ip ospf area' in the interface blocks
! above, rather than with 'network' statements: a network statement enables OSPF
! on whatever interface happens to hold a matching address, and the inventory
! names interfaces. The two agree only until the address plan changes.
!
! This is OSPFv2. The inventory states one OSPF instance and no protocol version,
! so no 'router ospf6' is inferred from the interfaces' IPv6 addresses.
router ospf
ospf router-id 192.0.2.1
...
Two lines a generator would normally emit are missing from that file on purpose,
and it says so where they would have been: frr version/frr defaults, because
the traditional and datacenter profiles differ in timers and in what a
session advertises; and no bgp ebgp-requires-policy, because turning a safety
default off quietly is the kind of thing nobody finds until the session is up.
A third omission is smaller and shows the rule at its sharpest. Both routes above
declare a metric:, and neither carries one here. The number FRR takes in that
position is an administrative distance — which protocol's answer wins for a
prefix, 1 to 255 — while a metric ranks routes within one protocol over the whole
32-bit range. They are different quantities that happen to sit in the same
column, so writing one where the other belongs would turn a metric of 200 into
a distance of 200 and a metric of 1000 into a syntax error. Both are reported
in the manifest instead.
Refused. The field is inside the dialect's remit and the dialect has no
syntax for it, so writing the file without it would configure the device to
behave differently from what the inventory declares. Nothing is written for
the entire run — not for the offending device, not for the four that were
fine — every refusal is listed with the field path that produced it, and the
exit code is 4.
That is deliberately harsher than skipping, and the reason is what an operator would otherwise be left holding: a device that is almost what the inventory says, with nothing in the file to say which part is missing. A directory that looks like a complete estate and is not is worst of all in a pipeline, which is why every selected device is asked for its limits before a single byte is rendered.
No inventory shipped with netviz refuses anything, so here is a constructed one — two files in a temporary directory, not part of this repository. A Linux virtualisation host with a trunk port into a bridge, which is an entirely ordinary thing to build and exactly what netplan cannot express:
# /tmp/vlan-host/kvm-host.yaml
apiVersion: netviz.dev/v1alpha1
kind: computer
metadata:
name: kvm-host
spec:
interfaces:
- name: eno1
type: ethernet
vlan:
mode: trunk
trunk_vlans: [10, 20]
- name: br0
type: bridge
members: [eno1]
ipv4:
addresses: [10.0.10.2/24]
routes:
- prefix: 10.0.20.0/24
via: 10.0.10.1
$ netviz -q -i /tmp/vlan-host export netplan --name kvm-host
error: the 'netplan' dialect cannot express 1 field(s) of 1 device(s); nothing was written, because a configuration missing one of these would put the device out of step with the inventory it was generated from
kvm-host: spec.interfaces[0].vlan -- eno1 is a trunk carrying VLAN 10,20; without the tagged set the port would admit every VLAN there is. netplan creates VLAN sub-interfaces but has no syntax for the 802.1Q configuration of a bridge port
spec.interfaces[0].vlan is a path you can grep the YAML for, which is the
difference between a diagnostic and a complaint. And a refusal is a property of
the dialect, never of the device: systemd-networkd has [BridgeVLAN], so the
same inventory generates cleanly there, VLAN filtering on the bridge and all —
$ netviz -q -i /tmp/vlan-host export networkd --out /tmp/vlan-out
$ cat /tmp/vlan-out/kvm-host/etc/systemd/network/10-eno1.network
...
[Match]
Name=eno1
[Network]
Bridge=br0
[BridgeVLAN]
VLAN=10
VLAN=20
$ cat /tmp/vlan-out/kvm-host/etc/systemd/network/20-br0.netdev
...
[NetDev]
Name=br0
Kind=bridge
[Bridge]
VLANFiltering=yes
$ cat /tmp/vlan-out/kvm-host/etc/systemd/network/20-br0.network
...
[Match]
Name=br0
[Network]
Address=10.0.10.2/24
[Route]
Destination=10.0.20.0/24
Gateway=10.0.10.1
(The ... in each file stands for the six-line banner.)
— and so does interfaces, which has a spelling for everything the schema can
state:
$ netviz -q -i examples/home-lab export interfaces --name sw-home
# Generated by 'netviz export interfaces' -- do not edit.
...
interface port5
type ethernet
description PoE injector for ap-home; trunks the guest VLAN up to it
mac 00:22:07:aa:00:05
mtu 1500
vlan-mode trunk
vlan-native 10
vlan-tagged 10,20
vlan-acceptable-frames admit-all-frames
VLANFiltering=yes above is derived from the bridge members' own vlan blocks
and from nothing else. Which brings us to the three places netviz reasons
rather than transcribes.
Three derivations, and why none of them is a guess
A generated configuration is trustworthy exactly to the degree that every value in it can be traced to something a person wrote. Three values here cannot be copied out of a single field, and all three are derivations from stated facts rather than inventions — each is recorded as such, and each would be a different kind of mistake to get wrong.
A WireGuard peer's Endpoint. wg-quick needs a host:port for the outer
packets. The inventory never states one, and the chain that produces it is
entirely inside the inventory: the peer's tunnel interface names its underlay
port in parent (§14.4), that port declares an
address, and the tunnel document declares a port. In
examples/overlay, rtr-branch-a:wg0 has
parent: ether1, ether1 has 198.51.100.6/30, and wg-mesh listens on
51820:
$ netviz -q -i examples/overlay export wireguard --name rtr-hq
# Generated by 'netviz export wireguard' -- do not edit.
# netviz 0.0.3. Re-run the command to regenerate from the inventory.
# netviz-dialect: wireguard
# netviz-element: sites/hq/rtr-hq (router)
# netviz-source: sites/hq/router.yaml
# netviz-source: tunnels/overlays.yaml
# One tunnel, not one host: 'wg-quick up wg0' reads this file.
# It is chmod 600 material -- a WireGuard private key belongs in it, and this
# file holds REPLACE-ME where the keys go, because netviz stores no key
# material (docs/schema.md section 14.2). Fill them in before the tunnel is used.
#
# tunnels/wg-mesh
[Interface]
Address = 10.255.0.1/24
ListenPort = 51820
MTU = 1420
PrivateKey = REPLACE-ME # private key of rtr-hq; netviz holds no key material (docs/schema.md section 14.2)
# sites/branch-a/rtr-branch-a:wg0
[Peer]
PublicKey = REPLACE-ME # public key of rtr-branch-a:wg0
AllowedIPs = 10.255.0.2/32
Endpoint = 198.51.100.6:51820
# sites/branch-b/rtr-branch-b:wg0
[Peer]
PublicKey = REPLACE-ME # public key of rtr-branch-b:wg0
AllowedIPs = 10.255.0.3/32
Endpoint = 198.51.100.10:51820
Break any link of that chain — no parent, an undeclared underlay, an underlay
with no routable address — and there is no Endpoint and a comment saying
which link was missing. That is not a degraded answer: it is the ordinary case
of a peer behind NAT, which WireGuard calls roaming and configures by leaving
Endpoint out. Note AllowedIPs beside it, which is not a derivation at all:
the peer declares 10.255.0.2/24 inside the tunnel, and it is narrowed to a
/32 host route rather than widened to the /24, because widening would route
every address of that prefix down the tunnel — a policy nobody wrote down.
A static route's interface. netplan and systemd-networkd hang a route off an
interface; spec.routes does not have to name one. Where the route states dev
there is nothing to derive. Otherwise netviz takes the interface whose declared
prefix contains the next hop — in the constructed example above, 10.0.10.1 is
inside br0's 10.0.10.2/24, so the route lands on br0. This is not a
preference among candidates: a next hop must be on-link for the route to work at
all, which the validator enforces as
E032, so the interface
holding the covering prefix is the only interface the route could use. When no
declared address covers the next hop the route is skipped rather than attached
to whichever interface came first, because attaching it would put a route on a
port that cannot reach its own gateway.
Access or trunk. An interfaces[].vlan block means two different things
depending on what it is attached to, and the dialects with no 802.1Q port syntax
have to tell them apart. On a vlan sub-interface it is the encapsulation VID,
which every dialect here writes. On a port frames arrive at — ethernet, radio,
LAG, bridge — it is 802.1Q port configuration, and then the question is whether
dropping it would change what the device does:
- A trunk cannot be dropped. "Admits VLAN 10 and 20 tagged" becomes "admits every VLAN there is" the moment the line is missing, which is a different device. Refused.
- An access port usually can. "VLAN 10, untagged" describes which broadcast domain the wire is in — a fact about the network, not a knob on the host — and a plain interface with no VLAN configuration carries it exactly. Refusing here would refuse most ordinary Linux hosts for stating something true.
- The exception is an access port enslaved to a bridge that also carries a different VLAN. That bridge must filter for either port to behave as declared, so a dialect that cannot say so would merge two broadcast domains into one. Refused, and named specifically rather than folded into the general access case.
Key material is never invented
There is nowhere in the schema to put a private key, a pre-shared key, a
passphrase or a certificate, and the field names people reach for are rejected
by name (docs/schema.md §14.2). An
inventory is a file in version control that gets rendered into diagrams and
pasted into tickets; it is the wrong place for a secret, and a schema that
accepted one would be inviting the mistake.
So every dialect that needs one writes REPLACE-ME, with a comment saying why.
Two properties of that placeholder are deliberate:
- It is not a key of any length, so nothing it is fed to will accept it. A placeholder that parses is a placeholder that reaches production.
- It is written rather than omitted. Leaving
PrivateKeyout gives a file wg-quick rejects with a message about its own schema, which sends the reader looking in the wrong place; leaving netplan'spasswordout does the same.
A generated WireGuard file is therefore a skeleton with the topology already correct, not a configuration waiting to be applied unread — which is the intended shape.
The provenance header
Every generated file opens with a banner, and it answers one more question than
the header on a hosts file does: which YAML is this the shadow of? Months
later, holding a file under /etc on a box, that is the only question worth
asking, and the generated file is the only thing in front of the person asking.
# Generated by 'netviz export netplan' -- do not edit.
# netviz 0.0.3. Re-run the command to regenerate from the inventory.
# netviz-dialect: netplan
# netviz-element: hosts/pc-desk (computer)
# netviz-source: hosts/pc-desk.yaml
The three netviz-* keys are the machine-readable half, prefixed so they
cannot collide with a directive of the format they sit in:
| Key | Contents | Read by |
|---|---|---|
netviz-dialect |
Which of the six wrote the file | drift and import, so neither needs --from; and --out, to tell a file netviz generated from one it did not |
netviz-element |
The fully-qualified name and kind of the device | drift and import, so neither needs --host |
netviz-source |
Every inventory document behind this file, relative to the root. Repeated when there is more than one — a router's own YAML and the tunnel document that gave it a peer | A person, editing |
FRR's banner uses ! and everything else here uses #, so the comment is inert
in the format it sits in.
Nothing in the banner is derived from the clock, the host or the directory the command ran in, for the same reason nothing else here is: two exports of an unchanged inventory must be byte-identical, or a diff in a generated file stops meaning "the network changed".
--out DIR, and the one-device stdout rule
A configuration set is a tree — netplan is one file per host, systemd-networkd
is a pair per stacked link, wg-quick is one file per tunnel — and stdout is not a
tree. --out DIR writes it: one directory per device, named after the device's
fully-qualified name, holding each file at the path the device keeps it at.
$ netviz -i examples/overlay export networkd --out build/config --manifest build/manifest.json
manifest written to build/manifest.json
exported networkd: 7 of 7 emitted, 6 skipped (not-representable 6), 3 renamed, 26 file(s) for 7 device(s) written under build/config
$ find build/config -type f | sort | head -6
build/config/sites/branch-a/pc-branch-a/etc/systemd/network/10-enp3s0.network
build/config/sites/branch-a/rtr-branch-a/etc/systemd/network/10-ether1.network
build/config/sites/branch-a/rtr-branch-a/etc/systemd/network/10-ether2.network
build/config/sites/branch-a/rtr-branch-a/etc/systemd/network/20-wg0.netdev
build/config/sites/branch-a/rtr-branch-a/etc/systemd/network/20-wg0.network
build/config/sites/branch-b/pc-branch-b/etc/systemd/network/10-enp3s0.network
The tree is shaped like the inventory tree, which is what makes diff -r between
two exports readable, and it cannot collide, because a fully-qualified name is
unique by construction. The 3 renamed in the summary is the interface-name fold
that made ge-0/0/0 into a file called 10-ge-0-0-0.network; like every other
fold it is recorded in the manifest.
An --out that resolves inside the inventory is refused, and the reason is
worth knowing rather than merely obeying: a generated 10-netviz.yaml sitting
under the tree netviz loads is a document netviz will try to load on the next
run, and NV-D001 reports it as YAML with no kind: — a diagnostic with no
obvious relation to what you did. The generated tree is a build artefact and
belongs outside the source, the same way a rendered diagram does.
$ netviz -q -i examples/home-lab export netplan --out examples/home-lab/build
error: refusing to write a configuration tree into the inventory: examples/home-lab/build is under the inventory root, and the loader reads every YAML document there -- so the next command would try to load the generated files as elements. Point --out outside the inventory, or pass --force
--force still allows it, because "outside the source" is a convention and
somebody's layout will disagree — but they will have said so.
Without --out, a selection of more than one device is a usage error rather than
a stream nobody can split:
$ netviz -q -i examples/home-lab export netplan
Usage: netviz export [OPTIONS] FORMAT
Try 'netviz export --help' for help.
Error: netplan would write 5 file(s) for 5 device(s), and stdout holds one: hosts/laptop, hosts/pc-desk, hosts/phone, hosts/srv-nas, and 1 more. Write the tree with '--out DIR', or narrow the selection to one device with '--name NAME'
Both ways out are named because both are reasonable. One device is allowed and is
the interesting case — the artefact is a file, so
netviz export netplan --name pc-desk | ssh pc-desk 'cat >/etc/netplan/10-netviz.yaml'
is what closing the loop looks like from a laptop. Where a dialect writes several
files for a single device — a .netdev and a .network, two tunnels — stdout
carries them separated by a # ==> path <== banner in the dialect's own comment
syntax, so the reader can tell where one ends.
Overwriting, and what is never deleted
--out is the one place netviz writes a tree it will be asked to write again
tomorrow, so the rule is narrower than the one netviz import
uses. An imported tree is hand-edited immediately and clobbering it is a real
loss; a generated configuration says do not edit on its first line, and a
command that demanded --force on every run would simply train everybody to pass
--force on every run.
A file netviz generated is overwritten, no flag required. It carries the banner, so there is no doubt about who owns it.
A file netviz did not generate is refused, with every clash listed at once.
That is the case worth stopping — an operator who pointed --out at /etc
rather than at a staging directory:
$ netviz -q -i examples/home-lab export netplan --out /tmp/cfg
error: refusing to overwrite 1 file(s) under /tmp/cfg that netviz did not generate: hosts/pc-desk/etc/netplan/10-netviz.yaml; pass --force to replace them, or point --out at a directory this command owns
Nothing is ever deleted. A device dropped from the inventory, a filter narrowed since the last run, or a dialect swapped for another all leave files behind. They are found and counted rather than removed, because a stale configuration nobody applies is harmless while a tree removed by a tool is not, and deciding which is which is an operator's job:
$ netviz -q -i examples/home-lab export netplan --name pc-desk --out /tmp/cfg2
warning: 4 file(s) under /tmp/cfg2 were generated by an earlier run and this one did not rewrite them; nothing was deleted -- the first is /tmp/cfg2/hosts/laptop/etc/netplan/10-netviz.yaml
The round trip: generate, then compare
The same seven dialects are read back by netviz drift,
which is what makes generate-then-compare symmetric rather than approximately
symmetric. The banner is why: a generated file names its own dialect and its own
element, so neither --from nor --host is needed for the half of the round
trip netviz wrote itself.
$ netviz -q -i examples/home-lab export netplan --name pc-desk -o want.yaml
$ netviz -i examples/home-lab drift --only pc-desk want.yaml
drift of examples/home-lab against 1 input (netplan)
unobserved (3)
declared, but outside what these dialects see; never counted as drift
cables/cbl-sw-desk hosts/pc-desk:eno1, switches/sw-home:port2: no input reported the neighbours of either end; 'lldp', 'csv' do, and nothing else netviz reads does — a configuration says what a box does with a port, never what is plugged into it
hosts/pc-desk:eno1 enabled: the capture reports no value for these fields, so the declared ones could not be checked
hosts/pc-desk:lo: the 'iproute' dialect does not import loopback interfaces, so this one could not appear in the capture
no drift: 1 element compared, 3 unobserved
No drift, which is the answer that proves the loop closes: what the emitter
writes is exactly what the reader understands. Replace want.yaml with the file
actually running on the box and the same command answers the question worth
asking:
$ scp pc-desk:/etc/netplan/10-netviz.yaml live.yaml
$ netviz -i examples/home-lab drift --only pc-desk live.yaml
drift of examples/home-lab against 1 input (netplan)
hosts/pc-desk (computer)
+ eno1.ipv4 the capture reports 192.168.10.21/24 here; the inventory does not declare it
- eno1.ipv4 192.168.10.20/24 is declared here; the capture reports this host's addresses and that is not one of them
...
2 differences across 1 element (+1 undeclared, -1 missing); 3 unobserved
Two caveats keep the answer honest, and both are the point rather than a limitation.
A configuration is intent, not observation. It says what the device was
asked to be, which is not always what it is. ip -j addr show answers the
other question. Both are worth asking, and drift reports which dialects saw
each device so the answer says which question was asked.
frr and wireguard are additive-only. The other four describe the whole of
a host's networking, so an interface absent from a netplan document is an
interface that host does not bring up, and that absence is a difference. An
frr.conf configures the interfaces the routing daemon cares about and is silent
about every other link on the box; a wg-quick file is one tunnel. Reading absence
there as deletion would be nonsense, so both are given the empty capability: they
can report an address FRR configures that the inventory does not declare, and
they can never report anything as missing. The full table is
What each dialect can see.
What every format guarantees
Deterministic. Every collection is sorted by an explicit canonical key —
never by dict order, never by the loader's directory traversal. Two runs over an
unchanged tree produce identical bytes, so an artefact is a file worth
committing and a diff in it means the network changed. This is asserted against
committed golden files in tests/fixtures/export/.
Scoped like a render. The same filters that narrow a diagram narrow an export; see Scoping an export.
Loud about what it drops. See the skip manifest.
Correctly escaped. Each format has its own grammar for names and its own
quoting — RFC 1035 labels, Ansible identifiers, RFC 4180 fields, Markdown cells
— and folding an inventory name into one of them happens once, in
netviz/export/names.py, with every fold
recorded.
Text. Nothing here emits bytes. All eight artefacts are diffable — drawio
included, as long as you leave --compress off, which is why it is off.
Names, and how they are folded
An inventory name is not a hostname. metadata.name is bounded by §4.1 and is
close to one, but a namespace is a directory path — whatever the operating
system let somebody call a folder — and a fully-qualified name is the two joined
by /. No artefact here can hold that, so it is folded:
- Transliterate.
NFKDdecomposition and an ASCII encode turnmünchenintomunchenandÜnicornintoUnicorn, rather than throwing the segment away because it is not ASCII. - Fold. Everything outside
[a-z0-9]becomes-(or_for an Ansible group); runs collapse; leading and trailing separators go. - Truncate to 63 octets, the RFC 1035 §2.3.4 limit for a label.
The namespace is reversed, not just translated, so the domain hierarchy mirrors the folder hierarchy:
| Fully-qualified name | Host name |
|---|---|
sites/north/access/sw-01 |
sw-01.access.north.sites |
Building A/rack 1/sw-01 |
sw-01.rack-1.building-a |
sw-01 |
sw-01 |
ns/core.example.com |
core.example.com.ns — a dot in metadata.name is already a label separator, and all of it is the element's own name |
Every fold that changes a name is reported as a rewritten entry in the
manifest, because a reader grepping the artefact for the spelling they wrote
would not otherwise find it.
Collisions
Folding is not injective: two elements whose names differ only past the 63rd
character, or only in characters that fold to the same separator, come out with
the same spelling. Every format here keys on that spelling, so the registry
refuses to hand it out twice — the first element in canonical order wins, the
second is skipped with name-collision, and the manifest names both.
Qualified names and short aliases live in one namespace, because they end up
in one namespace in the artefact: a root-level element called sw-01 and the
alias of sites/sw-01 are the same string to a resolver.
The alias itself is treated more gently, because two sw-01s in two sites are
ordinary rather than a mistake: the loser keeps its qualified name and loses
only the bare alias, which is recorded too. Only hosts publishes an alias, so
only hosts can report losing one — a zone file, an Ansible inventory and a
target list hold one name per element by construction, and reporting an alias
collision from them would describe something they never emit.
A name of which nothing survives the fold is skipped with not-representable.
Inventing a replacement would put a record in a zone file pointing at a machine
nobody asked about.
The skip manifest
Every format is lossy, and the tempting behaviour — drop what does not fit and
say nothing — is the one that costs an afternoon, because the artefact looks
complete and is not. So each export emits a JSON manifest of what it left out
and what it had to rename. It goes to stderr unless --manifest FILE names
somewhere else, leaving stdout for the artefact:
$ netviz -i examples/home-lab export prometheus-sd -o targets.json 2> manifest.json
{
"apiVersion": "netviz.dev/v1alpha1",
"kind": "ExportManifest",
"format": "prometheus-sd",
"counts": { "considered": 6, "emitted": 5, "skipped": 2, "rewritten": 0 },
"skipped": [
{
"subject": "hosts/laptop",
"reason": "not-routable",
"detail": "every configured address is loopback or link-local, so none of them identifies the element on this network"
},
{
"subject": "hosts/laptop:lo",
"reason": "unnumbered",
"detail": "2 address(es), all loopback or link-local"
}
],
"rewritten": []
}
A clean export still produces a manifest: a consumer must not have to
distinguish "nothing was skipped" from "the tool forgot to say". counts counts
records, not elements — one element can produce a skip for itself and one per
interface.
The stderr copy is commentary and is silenced by -q/--quiet, as every other
note this CLI writes is. --manifest FILE is not: a pipeline that wants the
record and wants the run quiet names a file for it.
The reasons
reason |
Meaning | Usually |
|---|---|---|
no-address |
The element configures no address at all | Expected of a patch panel and an unmanaged switch; a gap in the inventory otherwise |
not-routable |
It has addresses, but all of them are loopback or link-local | A host declared with only 127.0.0.1 |
unnumbered |
One interface carries no usable address | Normal on a switch; reported per interface so the one that was meant to be numbered is visible |
no-management-address |
Nothing could be chosen for ansible_host or a scrape target |
Follows from the two above |
unresolved-endpoint |
A cable or tunnel whose endpoint does not resolve | Only reachable behind --force; netviz validate refuses it first |
not-representable |
Nothing survives folding the name into the format's grammar | A name with no ASCII fold, or one longer than RFC 1035 allows |
name-collision |
Two elements fold to one name, or want the same short alias | See Collisions |
half-selected |
A cable with one end outside the selection | A filtered cable-list, where the uplinks leave the namespace |
not-selected |
Both ends survived but the link did not | --vlan 10 keeps a device that is in VLAN 10 and still drops a cable that carries none of it |
subject is the fully-qualified name of the element, cable or tunnel — or
element:interface when the record is about one port.
What each format drops
Every format here is lossy. What each one drops, in one line:
| Format | Not representable in it |
|---|---|
hosts |
Everything except names and addresses: no VLANs, no cabling, no hardware, no interface detail. Loopback and link-local addresses are excluded deliberately — 127.0.0.1 my-server is wrong on every machine that is not that server. |
dns-zone |
Address records only. No VLANs, no cabling, no hardware. Routing instances are flattened: the DNS has no VRF column, so two instances holding one prefix land in one reverse zone. One name per element: the short name is not published, because two namespaces may hold it and a zone file cannot report a collision — it can only serve one of them. |
ansible-inventory |
The topology. An Ansible inventory has no concept of a cable, so which port is plugged into which does not appear. Host variables carry what a template needs to generate config, not the adjacency needed to reason about the network. |
prometheus-sd |
Everything except one address and a handful of labels. No topology, no interface detail, no VLAN membership. An element with no routable address cannot be scraped and does not appear. |
cable-list |
Adapter attachments — a USB-to-Ethernet dongle's upstream is a physical connection, but it is part of the adapter rather than a run somebody pulls, and it has no medium, length or label to carry. Tunnels and addressing are not physical and never appear. |
routes |
Everything that is not a static route or a policy rule. spec.routing is a protocol — an inventory can say a router is in AS 65001 and OSPF area 0, but the configuration that makes it so is vendor syntax this emitter has no business inventing, so it is left to a template engine and the manifest says it was left. Nothing here computes a best path either: the routes are emitted in declaration order. |
drawio |
The model. A .drawio file records a name, a kind, a link and a coordinate per cell; not interfaces, addresses, VLANs, routing or hardware detail. That is what the round trip reconciles gestures — move, rename, delete, connect — rather than replacing documents wholesale. |
power |
Everything that is not power. A feed carries no medium, no length and no label, and the data run a PoE feed rides on is named by its two ports and the panels in VIA but not otherwise described — that is the cable-list's job. No measured watts anywhere: draw_watts and capacity_watts are nameplate figures, and comparing them with a meter is netviz drift's business. |
For the lossless view of an inventory, use
netviz render -f json, which exports the
resolved graph in full.
Scoping an export
Every filter netviz render takes, netviz export takes, and they mean the
same thing — both go through the same FilterSpec:
# The northern site's hosts file
$ netviz -i examples/campus export hosts --namespace sites/north
# Only the routers, as Ansible hosts
$ netviz -i examples/campus export ansible-inventory --kind router
# The pull list for one rack's neighbourhood
$ netviz -i examples/patch-room export cable-list --neighbors-of sw-core-01 --depth 2
| Option | Effect |
|---|---|
--namespace NS |
Keep only elements in this namespace or below it. Repeatable. |
--vlan VID |
Keep only elements participating in this VLAN. Repeatable. |
--kind KIND |
Keep only elements of this kind. Repeatable. |
--name GLOB |
Keep only elements whose name matches this glob. Repeatable. |
--neighbors-of NAME |
Keep only the neighbourhood of this element. |
--depth N |
How many hops --neighbors-of reaches. Default 1. |
A cable-list narrowed by a filter reports every cable that lost one end to it
as half-selected, so a scoped pull-list cannot be mistaken for a complete
cabling record.
Options
Common
| Option | Default | Effect |
|---|---|---|
-o, --output FILE |
stdout | Where the artefact goes. Parent directories are created. |
--manifest FILE |
stderr | Where the JSON skip manifest goes. Written whatever the verbosity; the stderr copy is not. |
--strict |
off | Treat warnings as errors. |
--force |
off | Export despite validation errors. The artefact may not match the network. |
dns-zone
| Option | Default | Effect |
|---|---|---|
--origin NAME |
required | Zone origin, e.g. example.com. Normalised to its absolute form. |
--ttl SECONDS |
3600 |
$TTL of every zone written. |
--zones all|forward|reverse |
all |
Which zones the document holds. |
--soa-mname NAME |
ns.<origin> |
Primary nameserver for the SOA. |
--soa-rname NAME |
hostmaster.<origin> |
Responsible mailbox, in DNS form. |
--serial N |
1 |
SOA serial. Never derived from the clock. |
--refresh, --retry, --expire, --minimum |
86400, 7200, 3600000, 3600 |
The rest of the SOA. |
--ns NAME |
the --soa-mname |
NS record at the apex. Repeatable. |
prometheus-sd
| Option | Default | Effect |
|---|---|---|
--port PORT |
none | Appended to every target. IPv6 is bracketed automatically. |
--label KEY=VALUE |
none | Static label merged into every target. Repeatable. Reserved __ names are refused. |
cable-list
| Option | Default | Effect |
|---|---|---|
--table-format csv|markdown |
csv |
How the list is laid out. The rows and columns are the same either way. |
power
| Option | Default | Effect |
|---|---|---|
--schedule-format csv|json |
csv |
How the load schedule is laid out. json adds the per-PDU and per-PSE totals; the feed rows are the same either way. |
drawio
| Option | Default | Effect |
|---|---|---|
--view VIEW |
l1 |
Which view the diagram draws. Any of the ten layers a render draws. |
--icons THEME|DIR |
cisco |
Icon theme inlined as data URIs, so the file needs nothing beside it. none draws coloured boxes. |
--compress / --no-compress |
--no-compress |
Write the deflate+base64 encoding draw.io writes by default. Off here: a plain diagram is one that reviews and diffs, and draw.io opens both. |
--frames / --no-frames |
--frames |
Draw a container frame per namespace, so dragging a site carries its devices. |
Passing a format-specific option to a format that has no use for it is a usage error, not a silent no-op: a flag that is quietly dropped is worse than an error, because the operator believes they asked for something they did not get.
Exit codes
| Code | When |
|---|---|
0 |
The artefact was written. Skips recorded in the manifest do not change this — a patch panel with no address is not a failure. |
1 |
The inventory has errors and --force was not given. |
2 |
A usage error: an unknown format, a malformed --origin or --label, or an option the chosen format cannot use. |
As a library
from pathlib import Path
from netviz.export import EXPORTERS, ExportContext, ExportOptions, export
from netviz.loader import load_tree
from netviz.render import FilterSpec, build_graph, filter_graph
inventory = load_tree(Path("inventory"))
spec = FilterSpec(namespaces=("sites/north",))
exporter = EXPORTERS["dns-zone"]
graphs = {
layer: filter_graph(build_graph(inventory, layer=layer), spec) for layer in exporter.layers
}
result = export(
"dns-zone",
lambda recorder: ExportContext(
inventory=inventory,
graphs=graphs,
options=ExportOptions(origin="example.com."),
recorder=recorder,
),
)
Path("db.example.com").write_bytes(result.encode())
print(result.manifest.summary()) # 18 of 20 emitted, 2 skipped (no-address 2)
See also
docs/ipam.md— where the prefixes the reverse zones are built from come from, and how they are sized.docs/schema.md§3.2 —metadata.location, which fills the rack and unit columns of the pull list.docs/schema.md§17 — thepdukind,spec.powerandinterfaces[].poe, which the load schedule is built from.docs/rendering.md—render -f json, the lossless export.