netviz GitHub

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

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:

  1. An interface that says it is for managementmgmt0, Mgmt1, idrac, ilo, bmc, or any interface whose description mentions management or out-of-band. Matched case-insensitively, in the name and the description.
  2. 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.
  3. 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:


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_feedutility-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:

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:

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:

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:

  1. Transliterate. NFKD decomposition and an ASCII encode turn münchen into munchen and Ünicorn into Unicorn, rather than throwing the segment away because it is not ASCII.
  2. Fold. Everything outside [a-z0-9] becomes - (or _ for an Ansible group); runs collapse; leading and trailing separators go.
  3. 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