Annet end to end: generators with ACL-scoped partial config, the gen→diff→deploy workflow, multivendor dispatch, gnetcli transport over SSH or Telnet (streamer_type/dev_port), NetBox vs file inventory, and an offline fake-device self-test.
Annet (from the annetutil project) is a config-generation and deploy framework: you write Python generators that emit the target config, Annet fetches the device's running config, computes a vendor-aware diff, and deploys only the delta. Its signature idea is the ACL — each generator declares exactly which config it owns, so Annet does partial config management safely. This page follows a real stand that drives Cisco over Telnet via gnetcli.
Flow:annet gen(build target config from generators) →annet diff(fetch running-config, diff within each generator's ACL) →annet deploy(push the diff). Inventory comes from NetBox or a file; transport is pluggable (SSH or Telnet).
Unlike Ansible or Nornir, Annet has no operational-state / show collection step. It
takes intent entirely from the SoT (NetBox or a file), and the only thing it pulls from the
device is the running-config — purely to diff it against the generated target. If you need
show version, interface counters, or any live facts, run gnetcli / netmiko / NAPALM alongside Annet. See
the side-by-side in Frameworks
Compared.
| Piece | Role |
|---|---|
| Storage | Where the device list & metadata come from — netbox (production) or file (offline YAML). |
| Generators | Python classes that yield target config per vendor. |
| Fetcher | Pulls running-config from the device (here the gnetcli adapter). |
| Deployer | Pushes the computed diff back. |
Context (config.yaml) | Wires storage + fetcher + deployer + generators into a named context you select. |
A generator is a PartialGenerator with two methods per vendor: acl_<vendor> (which
lines it manages) and run_<vendor> (the lines it produces). Annet dispatches by
the device's vendor and diffs only inside the ACL — config outside it is left untouched.
from annet.generators import PartialGenerator
from annet.storage import Device
class Hostname(PartialGenerator):
TAGS = ["hostname"]
def acl_cisco(self, _: Device):
return "hostname" # this generator owns the 'hostname' line
def run_cisco(self, device: Device):
yield "hostname", device.name # ...and sets it to the device name
Nested config uses self.block(). A trimmed interfaces generator (access vs trunk from NetBox tags):
class IfaceAccess(PartialGenerator):
TAGS = ["access"]
def acl_cisco(self, _: Device):
return """
interface
description
switchport mode ~
switchport access ~
""" # '~' / '*' are ACL wildcards
def run_cisco(self, device: Device):
for iface in device.interfaces:
if self.isAccess(iface.tags):
vlan = iface.untagged_vlan.vid or 999
with self.block(f"interface {iface.name}"):
yield "switchport mode access"
yield f"switchport access vlan {vlan}"
Why the ACL matters: it's the guardrail. Annet will only add/remove lines that match the ACL, so a hostname generator can never accidentally wipe your interfaces — the diff is scoped by construction.
annet gen cisco-sw1.example.net # print the generated target config
annet diff cisco-sw1.example.net # fetch running-config, show the delta
annet deploy cisco-sw1.example.net # push the delta
A diff against a device whose hostname is fakesw:
# -------------------- cisco-sw1.cfg --------------------
- hostname fakesw
+ hostname cisco-sw1
Annet reaches devices through an adapter. This stand uses gnetcli: a Go daemon
(gnetcli_server) does the actual connection, a Python gRPC client (gnetclisdk) talks to it,
and gnetcli_adapter plugs it into Annet as fetcher/deployer. Transport is chosen with two params:
# config.yaml — fetcher/deployer using the gnetcli adapter
fetcher:
telnet:
adapter: gnetcli
params:
streamer_type: telnet # ssh (default) or telnet
dev_port: 23
deployer:
telnet:
adapter: gnetcli
params:
streamer_type: telnet
dev_port: 23
Credentials are never in the config — the adapter reads any dev_* param from
GNETCLI_* environment variables:
export GNETCLI_DEV_LOGIN=admin
export GNETCLI_DEV_PASSWORD=secret
Telnet support is spread across three repos and, as of 2026, not fully upstream:
| Component | Telnet status |
|---|---|
annet | Transport-agnostic ✅ (PyPI annet[netbox]) |
gnetcli_server (Go) | Telnet merged upstream ✅ (≥ v1.3.6) |
gnetclisdk (Python) | Has StreamerType_telnet ✅ |
gnetcli_adapter | Telnet still in an open PR ❌ — install from the fork until merged |
Only the adapter needs the fork (it lacks streamer_type/dev_port
fields, so stock installs go SSH). Everything else is upstream.
storage:
netbox:
adapter: netbox
params: { url: http://netbox:8000/, token: REPLACE_ME }
file:
adapter: file
params: { path: /file_inventory.yaml }
# file_inventory.yaml — offline testing, no NetBox
devices:
- fqdn: cisco-sw1.example.net # required, also the lookup key
vendor: cisco # cisco, huawei, arista, routeros, ...
breed: ios12 # concrete breed needed for diff/deploy parsing
NetBox drives production (interfaces, tags, VLANs, cabling feed the generators); the file adapter is a
minimal stand-in for CI — but it has no tags/VLANs, so only vendor-agnostic generators (like hostname) run.
The stand ships a fake Cisco telnet server so the whole path runs locally in Docker:
make build && make run && make shell # build + start + enter the container
bash /test/run_annet_telnet_diff.sh # annet diffs the fake Cisco over telnet
# => - hostname fakesw / + hostname cisco-sw1
That exercises generators → gnetcli_server → telnet → fake device → diff, end to end, with no credentials and no lab.
acl_<vendor> / run_<vendor> dispatch, breed for parsing)streamer_type / dev_port on the gnetcli adapter)GNETCLI_* env vars, never in YAML)annet diff in Docker)