netviz GitHub

Ansible

The inventory that draws the diagram is the inventory Ansible runs against, and the addresses in a generated configuration file are the ones the YAML declares.

Two things are on offer, and they answer different questions.

Question What answers it
Who is out there? the inventory plugin — hosts, groups and per-host facts
What should this host's configuration say? the lookup plugin — a query, in a template

The second is the interesting one. Ansible's usual source of a host's addresses is the host: ansible_facts reports what is configured, which is precisely the wrong direction when the point is to say what should be configured. A query answers from the declaration instead — so the unit file, the diagram, the validation rules and the review all come from one document.

[Match]
Name={{ item.name }}

[Network]
{% for address in item.addresses %}
Address={{ address }}
{% endfor %}

Install

The plugins run on the control node — the machine typing ansible-playbook — and they need netviz importable there. Nothing is needed on the targets.

pip install netviz            # or: uv tool install netviz, pipx install netviz
export ANSIBLE_COLLECTIONS_PATH="$(netviz ansible path)"

netviz ansible path prints a collections path pointing straight into the installed package, so nothing is copied and the plugins are always the ones belonging to the netviz beside them. To put a copy where the control node keeps its collections instead:

netviz ansible install        # into ~/.ansible/collections

Either way:

ansible-doc -t lookup netviz.netviz.query

docs/commands/ansible.md is the reference for both.


The inventory

One file, and the tree it points at:

# inventory/netviz.yml
plugin: netviz.netviz.netviz
root: ../net

root is relative to this file, not to the working directory: the two are checked in together, and the directory ansible-playbook happens to be run from is not.

$ ansible-inventory -i inventory/netviz.yml --graph
@all:
  |--@ns_routers:
  |  |--rtr-home.routers
  |--@kind_switch:
  |  |--ap-home.wireless
  |  |--sw-home.switches
  …

Every element with a management address becomes a host, named as netviz export ansible-inventory names it — because it is that exporter: the plugin builds the same document and then adds to it. Two implementations of "which hosts are there" would drift, and the day they did, a checked-in inventory file and the plugin that replaces it would disagree about who is a server.

Four families of group, each prefixed so two can never collide:

Group From
ns_* the namespace, nestedns_sites_north is a child of ns_sites, so group_vars/ns_sites.yml applies to a whole site
kind_* the element kind: kind_switch, kind_server
vendor_* spec.vendor
role_* the role label, when the inventory uses one

And every host starts with the facts a template needs — netviz_interfaces, netviz_addresses, netviz_vlans, netviz_location, netviz_labels, netviz_element — plus netviz_root, which is the tree it was read from. That last one is what makes a lookup in a template need no arguments at all.

Variables and groups that are queries

plugin: netviz.netviz.netviz
root: ../net
select: kind = server and namespace under 'sites/north'
query_vars:
  uplink_vlans: select distinct (device filter .fqn = $fqn).interfaces.vlans.id
  mgmt: select (device filter .fqn = $fqn).interfaces { addresses := .addresses.address } filter .name = 'mgmt0'
query_groups:
  unaddressed: select device.fqn filter not exists .addresses
  wireless: kind = switch and interface[type = wifi]

select narrows which elements become hosts, in the selector language every other command's --select speaks.

query_vars is answered once per host, with that host bound (see below). One row is the value; anything else is the list of rows, so a query that can answer twice always reads as a list and a template never has to sniff.

query_groups is answered once, and every row that names an element — a fully-qualified name, or an object with one in it — puts that element's host in the group. A group that ends up empty is not written, because a group naming no host is one every Ansible command warns about; a group whose name is one of the derived ones is refused rather than replaced, because a kind_router holding something other than the routers is a trap rather than a shortcut.

Ansible's own compose, groups and keyed_groups work as they do on any inventory plugin. They are Jinja over the variables a host already has; query_vars and query_groups are queries over the network. Both are useful, and the names are different so it is always clear which is meant.


Queries in a template

{% for address in query('netviz.netviz.query',
                        'select (device filter .fqn = $fqn).addresses.address') %}
Address={{ address }}
{% endfor %}

$fqn is the fully-qualified name of the element this host came from. It is bound for you, along with the rest of the host's identity:

Parameter Is
$host the name Ansible knows it by — sw-01.sites.north
$fqn the element's fully-qualified name — sites/north/sw-01
$name the short name — sw-01
$namespace sites/north
$kind switch, server, …

Use query() rather than lookup() when the answer is structured: lookup() joins its results into a string, and an array of objects deserves better.

To ask about something other than the current host, pass parameters:

{{ query('netviz.netviz.query',
         'select (device filter .name = $who).addresses.address',
         params={'who': 'rtr-edge'}) | netviz.netviz.one }}

Why a parameter, and not a string

Because this is wrong:

{# don't #}
{{ query('netviz.netviz.query',
         "select device filter .name = '" ~ inventory_hostname ~ "'") }}

It means what it says only while nobody puts an apostrophe in a device name, and the day somebody does, the query either fails to parse or — worse — parses into a different question. A $name is a token: the value never reaches the parser, so nothing in it can change what is being asked. It is also typed, by the value that was passed, which is why params={'id': 10} compares against a VLAN id and refuses to compare against a name.

The same holes work at the terminal, which is where a template's query is developed:

$ netviz query 'select (device filter .name = $host).addresses.address' --param host=rtr-home
ADDRESS
-----------------
192.0.2.1/32
2001:db8::1/128
203.0.113.2/30
192.168.10.1/24
2001:db8:10::1/64

docs/nql.md is the language; the parameters section is the whole of this feature.

Selectors answer too

A query that does not begin with select or with is a selector, and answers with the fully-qualified name of every element it picked — which is what when: and loop: want:

- name: Warn about anything unaddressed
  ansible.builtin.debug:
    msg: "{{ item }} has no address"
  loop: "{{ query('netviz.netviz.query', 'kind = server and not has address') }}"

The filters

Five, for the last inch between an answer and a file. Anything larger belongs in ansible.utils, which does it properly.

Filter 10.20.0.5/24 becomes
netviz.netviz.host 10.20.0.5
netviz.netviz.network 10.20.0.0/24
netviz.netviz.netmask 255.255.255.0
netviz.netviz.prefix_length 24

And netviz.netviz.one, which is the one that matters: exactly one row, or an error saying how many there were. A template that silently took | first of three answers is a template that will one day configure the wrong address.


A worked example: systemd-networkd

The collection ships the playbook. It renders one .network unit per addressed interface, from the inventory, on the control node:

export ANSIBLE_COLLECTIONS_PATH="$(netviz ansible path)"
ansible-playbook netviz.netviz.systemd_network \
    -i inventory/netviz.yml -e netviz_units="$PWD/build/units"
# build/units/rtr-home.routers/10-wan0.network
# Generated by netviz.netviz.systemd_network from routers/rtr-home.
# The inventory is the source of truth: edit the YAML, not this file.

[Match]
Name=wan0

[Network]
Address=203.0.113.2/30

[Link]
MTUBytes=1500
RequiredForOnline=yes

Nothing is written to a machine: the play produces files you can read, diff and then ship with your own copy task and a systemctl restart systemd-networkd handler. Deciding when a network interface is reconfigured is not a decision a generator should be making.

The query it runs is a variable, so a play that wants different interfaces overrides it rather than forking the playbook:

vars:
  netviz_interface_query: >-
    select (device filter .fqn = $fqn).interfaces {
      name, mtu, addresses := .addresses.address, vlans := .vlans.id
    } filter exists .addresses and .type != 'loopback' order by .name

netviz export is the other way to generate configuration — netplan, networkd, ifupdown, frr, wireguard, interfaces — and it needs no Ansible at all. Reach for a template when the file is yours: your comments, your options, your conventions, with the network's facts filled in. Reach for export when netviz already knows the dialect.


Without the collection

Two ways, both of which keep working when a control node cannot have netviz installed:

netviz export ansible-inventory -o inventory.json   # a file, committed or generated in CI
netviz ansible inventory --var mgmt='select …'      # the plugin's document, on stdout

The first is what to commit or to generate in a CI step. The second is the plugin's exact output, and wrapping it in a two-line executable makes it a dynamic inventory script — see docs/commands/ansible.md.

Neither gives a template the lookup, so a query in that world becomes a netviz query --param … -F json in a command task. That works, and it costs a process per question; the collection exists so it does not have to.


Notes and limits


See also