Rulebooks¶
A rulebook tells Annet how to turn a diff into device commands, in what order to send them, and how to deploy them. Where a generator decides what the configuration should look like, a rulebook decides how to get the device there.
Remember the Annet pipeline:
annet genbuilds the target config.annet diffcompares it with the current config and produces a diff.annet patchturns that diff into a patch — the actualundo/no/deleteand configuration commands.annet deploysends the patch to the device.
Steps 3 and 4 are where rulebooks mostly act — though the ordering rulebook also tidies the
generated config back at steps 1–2 (more on that below). Most of the time the built-in rulebooks
already do the right thing and you never touch them. You reach for a rulebook when a vendor needs
something special — a command that must be removed in an unusual way, a block that must be sent
before another, or a device that asks Are you sure? [Y/N] and waits for an answer.
This page assumes you already know the ACL language: rulebooks reuse the same
matching syntax (indentation, * and ~). It starts simple and adds the advanced parts at
the end.
The three kinds of rulebook¶
There are three rulebook types, each a plain-text file with its own extension:
Extension |
Type |
Answers the question… |
|---|---|---|
|
patching |
How do I build the commands from the diff? |
|
ordering |
In what order do I send the commands? |
|
deploying |
How do I send each command and answer prompts? |
Rulebooks are per vendor. They live in a Python package (the default one is
annet.rulebook.texts) and are named after the device vendor:
annet/rulebook/texts/
huawei.rul huawei.order huawei.deploy
cisco.rul cisco.order cisco.deploy
juniper.rul juniper.order
...
Only the patching file (<vendor>.rul) is required, and it must be named exactly after
the device’s vendor. The ordering and deploying files are optional — if a vendor has no
.order or .deploy file, Annet simply uses sensible defaults.
A minimal set of rulebooks for an imaginary vendor acme might be:
acme.rul
hostname *
interface *
description ~
mtu *
acme.order
hostname
interface
acme.deploy
reload
dialog: Proceed with reload? [confirm] ::: y
Patching rulebooks (.rul)¶
A patching rulebook is a list of rules, one per line, using the same matching syntax as ACLs. Each rule matches a configuration line and describes how to produce commands for it.
The simplest possible rule is just the command:
hostname *
Read this as: “a line that starts with ``hostname`` followed by one word is a hostname command.”
* and ~, and why they matter for removal¶
You already know * (one word) and ~ (the rest of the line) from ACLs. In a patching
rulebook they do one extra job: the values they match are remembered and reused to build the
removal command.
Suppose the diff changes a syslog server:
- syslog-server 192.168.18.1
+ syslog-server 192.168.18.2
If the rulebook rule has a placeholder:
syslog-server *
then Annet knows the old value and can remove it explicitly, producing:
undo syslog-server 192.168.18.1
syslog-server 192.168.18.2
But if the rule has no placeholder:
syslog-server
then Annet treats the command as a simple toggle and does not generate a removal — it just sets the new value:
syslog-server 192.168.18.2
This is the single most important thing to understand about .rul files: a placeholder
makes a command removable; no placeholder means “just overwrite, never undo”. The exact
removal keyword (undo for Huawei, no for Cisco, delete for Juniper, …) is chosen
automatically from the file’s vendor — you never write it in a plain rule.
Note
This is the rulebook side of the “capturing only matters for rulebooks” note in the
ACL docs. You do not need any special capture syntax here — plain * and
~ are all you use. The values they matched are passed to custom logic as the key (see
below).
Nested blocks¶
Just like ACLs, indentation builds blocks:
interface *
description ~
ip address * *
A child rule is only used inside a matched parent. Removing a whole interface block, or just
one setting inside it, follows naturally from the structure.
Excluding lines with !¶
Prefix a rule with ! to tell Annet to leave matching lines completely alone — never
generate an add or a remove for them:
!vrf context management
This is useful for config that exists on the device but is managed out of band.
Custom patch logic: %logic¶
By default every rule is processed by a built-in function called default that implements the
“add it, or undo it” behaviour described above. When a vendor needs something cleverer, point the
rule at your own function with %logic=:
ip ssh version 2 %logic=annet.rulebook.cisco.misc.ssh_key
%logic lets you generate extra commands, suppress commands, or rewrite them. Here is a
real example — enabling SSH on Cisco also needs a key to be generated, which the running config
never shows, so the logic emits an additional command:
def ssh_key(rule, key, diff, **_):
"""Enabling ssh also requires generating a key, which the config never shows."""
if diff[Op.ADDED]:
added = sorted(x["row"] for x in diff[Op.ADDED])
if added == ["ip ssh version 2"]:
yield (False, "crypto key generate rsa general-keys modulus 2048", None)
yield from common.default(rule, key, diff)
To write your own logic function you need to understand its arguments and what it yields. A
logic function always has the signature def fn(rule, key, diff, **_). To make the three
positional arguments concrete, we’ll follow one sample rule through — snmp-agent sys-info *
(Huawei: sets the SNMP contact, location, etc.):
snmp-agent sys-info * %logic=mycompany.rulebook.huawei.snmp.sys_info
Argument 1 — rule: a dict describing the matched rule. The fields you’ll use most are
shown below; the full list with types is in the reference.
{
# compiled regexp used to parse the config line
"regexp": re.compile(r"^snmp-agent\s+sys-info\s+([^\s]+).*$"),
# template for the removal command; {} placeholders are filled from `key`
"reverse": "undo snmp-agent sys-info {}",
# ... plus the rule's params (comment, multiline, context, …)
}
Argument 2 — key: a tuple of the values that * / ~ matched on the line. For the
line snmp-agent sys-info contact matched by snmp-agent sys-info * the key is
("contact",). This is what you pass to rule["reverse"].format(*key) to build a removal
command.
Argument 3 — diff: a dict that groups what changed for this command, keyed by operation:
{
Op.ADDED: [{"row": "...", "children": None}], # present only in the new config
Op.REMOVED: [{"row": "...", "children": None}], # present only in the old config
Op.AFFECTED: [], # a block whose children changed
Op.MOVED: [], # same line, different position
Op.UNCHANGED: [{"row": "...", "children": None}], # unchanged (sometimes needed for context)
}
Each entry is {"row": <text>, "children": <subtree or None>}.
Extra keyword arguments (**_): besides those three, Annet always passes a few more
keyword arguments. Most functions don’t need them and swallow them with **_ (or **kwargs
if they want to forward them to common.default). They are:
hw— the device’sHardwareView, so the logic can branch on the exact model (if hw.Huawei.CE: ...).rule_pre— the full pre-computed data for this rule:{"rule": <text>, "attrs": ..., "items": {key: diff, ...}}.rule(argument 1) is just itsattrs;rule_prealso lets you see the rule’s other keys and their diffs, not only the one you were called for.root_pre— the same structure for the whole patch, keyed by rule text. Use it to peek at sibling commands elsewhere in the config when one command’s patch depends on another.
You only reach for these in advanced cases; the vast majority of logic functions use just
rule, key and diff.
What you yield: tuples of (is_forward, command, children).
The first element flags whether command is a forward (configuration) command or a
removal command — i.e. is this a hostname foo or a no hostname foo? Annet can’t tell
from the text alone (vendors differ), so you say so explicitly. It affects two things:
Ordering. The flag is passed to the ordering rulebook as the command’s direction. In particular,
%order_reverserules only act on removal commands, and removals are grouped/ sorted separately from forward commands.Block nesting. A forward command on a block rule opens a nested block that Annet descends into (via
children); a removal command is always emitted as a single flat line (you delete the whole block in oneno …/undo …, you don’t descend into it).
So:
(True, row, children)— emitrowas a forward command. Ifchildrenis notNoneit is a block and Annet recurses into it.(False, row, None)— emitrowas a removal command (usually built fromrule["reverse"].format(*key)).yielding nothing emits no command for this rule.
The built-in default does exactly this: True for an added/changed line, and False
for the reverse command it builds when a line is removed.
In practice most custom functions handle their one special case and then delegate everything else to the built-in default:
yield from common.default(rule, key, diff)
There are several ready-made logic functions in annet.rulebook.common you can point
%logic at directly, without writing any Python:
Function |
Behaviour |
|---|---|
|
the standard add / undo behaviour (used when |
|
never delete this command, even when it leaves the config |
|
re-create the block from scratch instead of diffing it |
|
add or remove lines, but never replace one value with another |
|
remove the old command first, then add the new one (two steps) |
|
return to the default state with |
Custom diff logic: %diff_logic¶
%logic works on a single command. %diff_logic works one level up: it controls how the
diff of a whole block is computed before the patch logic runs. Use it when “what changed” needs
massaging — for example, when a vendor reorders arguments and you want to treat two differently
written lines as equal.
ntp server * %diff_logic=annet.rulebook.cisco.misc.case_insensitive_diff
A diff-logic function receives the old and new subtrees and returns a list of DiffItems:
def case_insensitive_diff(old, new, diff_pre, _pops=(Op.AFFECTED,)):
old = _change_keys(old, str.lower)
new = _change_keys(new, str.lower)
diff_pre = _change_keys(diff_pre, str.lower)
return common.default_diff(old, new, diff_pre, _pops)
The arguments are:
old/new— ordered dicts of the block’s children in the old and new config (each value is itself a subtree of children).diff_pre— Annet’s internal pre-computed diff metadata for these rows (pass it straight through tocommon.default_diff)._pops— the chain of parent operations, used internally; forward it unchanged.
A DiffItem is a named tuple (op, row, children, diff_pre) (its fields are typed in the
reference). The usual pattern is to call common.default_diff(...)
to get the normal list and then filter or tweak it, as the real vlan_diff and
local_user_diff helpers do. As with %logic, annet.rulebook.common ships ready-made
diff functions: default_diff, ordered_diff, rewrite_diff and multiline_diff.
Other patching params¶
These flags cover the most common special cases, so you rarely need to write Python at all:
Param |
Effect |
|---|---|
|
the rule (and its behaviour) applies at any depth below, not just here |
|
the block’s children are order-sensitive; reordering them re-creates the block |
|
replace the whole block from scratch instead of diffing it line by line |
|
treat a multi-line value (e.g. an RSA key) as one indivisible command |
|
match and compare this command case-insensitively |
|
the resulting command only makes sense after a commit; skip it in no-commit mode |
|
tag the rule with a named context (paired with |
%ordered, %rewrite and %multiline are shortcuts that set matching %logic /
%diff_logic for you, so you cannot combine them with an explicit %logic / %diff_logic
on the same rule.
Ordering rulebooks (.order)¶
Some commands only work if they are sent in the right order — you must create a tacacs-server
before an aaa block can reference it, and you must delete them the other way around. An
ordering rulebook is simply the list of commands in the order they should be applied:
tacacs-server
aaa
Commands are sent following this top-to-bottom order. If the order of a command does not matter, leave it out — only list what needs to be constrained.
Like patching rules, ordering rules can nest, and they automatically understand the vendor’s
removal form (undo … / no … / delete …).
Reversing the order of removals: %order_reverse¶
By default a removal sits in the same slot as the command it removes. But dependencies run
backwards when you tear things down: you must remove the reference in aaa before removing
the tacacs-server it points at. %order_reverse lets you place the removal command at its
own position:
tacacs-server
aaa
no tacacs-server %order_reverse
Now additions go tacacs-server → aaa (top to bottom), but the removal no
tacacs-server is emitted after the aaa changes, at the position written here.
%order_reverse only affects removal commands (undo / no / delete). For a
forward command it does nothing — so you write it on a rule whose text is the removal form
itself, as in no tacacs-server %order_reverse above.
Tip
If a rule uses custom %logic or %diff_logic in the patching rulebook, it is good
practice to also list it in the ordering rulebook so its position is well defined.
Other ordering params¶
Param |
Effect |
|---|---|
|
the ordering rule applies at any nesting level |
|
the rule may appear several times, splitting a block into ordered phases
(e.g. |
|
restrict the rule to the patch-ordering pass (see below) |
Annet orders things in two situations: it orders the generated config (to produce a
stable, canonical representation for gen and diff), and it orders the patch (the
command sequence that is actually sent to the device). The same ordering rulebook drives both.
By default a rule applies to both passes; %scope=patch limits it to the patch pass only, so
it changes the order commands are sent in without disturbing how the config itself is laid out.
Repeating a rule with %split¶
Normally a command may appear only once in an ordering rulebook (listing it twice is an
error, because its position would be ambiguous). Some blocks genuinely need a command in two
places — for example remove all VLANs, then later add the new ones. Mark such a rule with
%split and it may repeat, each occurrence acting as its own ordering slot:
interface
bridge
vlan %split
remove
vlan %split
add
Deploy rulebooks (.deploy)¶
A deploy rulebook controls how each command is actually pushed to the device: how long to wait, whether the device asks an interactive question, and what to answer.
The most common use is answering prompts. Many devices ask for confirmation before a
dangerous command. A dialog: line says “when you see this question, send this answer”:
undo bgp
dialog: Warning: The BGP process will be deleted. Continue? [Y/N]: ::: Y
The format is dialog: <question> ::: <answer>. The question is matched as a substring of
the device’s output (whitespace and case are ignored). To match with a regular expression
instead, wrap the question in slashes:
undo rsa peer-public-key
dialog: /Do you want to remove the public key named .*\? \[Y/N\]:/ ::: Y
A deploy rule is matched against the command being sent (hierarchically, like the other rulebooks); if nothing matches, sensible defaults are used.
Deploy params¶
Param |
Effect |
|---|---|
|
how long to wait for the command to finish, in seconds (default |
|
whether to send a newline after the answer (default |
|
do not treat an error from this command as a failure |
|
only apply this rule in a named context (paired with |
|
custom Python that returns extra commands to run before/after (e.g. a |
Example putting several of these together:
commit %timeout=180
no snmp-server sysobjectid type stack-oid %suppress_errors
port mode 200GE interface * %timeout=40 %suppress_errors
An %apply_logic function returns two command lists — commands to run before and after the
patch:
def apply(hw, do_commit, do_finalize, **_):
before, after = CommandList(), CommandList()
if do_commit:
after.add_cmd(Command("write memory"))
return before, after
It is called as apply(hw, do_commit=..., do_finalize=..., path=...), so the arguments are:
hw— the device’sHardwareView(branch on model if needed).do_commit— whether this deploy will commit the changes.do_finalize— whether this deploy will run the finalizing steps.path— the device/config path being deployed, orNone.
Just like %logic, most functions only need a couple of these and swallow the rest with
**_ — the example above ignores path entirely.
Mako templates¶
Before a rulebook is parsed, its text is rendered with Mako,
and the device’s hardware object hw is available. This lets one rulebook adapt to different
models of the same vendor:
%if hw.Huawei.CE or hw.Huawei.Quidway:
stp edged-port
%else: # other models take an argument
stp edged-port *
%endif
You can use ${...} substitutions and %for loops too. Two things to keep in mind:
A line starting with
%that is not a Mako keyword (%if,%else,%elif,%endif,%for,%endfor) is treated as a normal rulebook param, so%global,%logicand friends are safe to write at the start of a line.Lines starting with
#are comments and are stripped out.
Writing your own rulebooks¶
When you need to customise behaviour for your fleet, do not copy a whole built-in rulebook and edit it — you would then have to re-merge every upstream change by hand. Instead, create your own rulebook package and inherit from the built-in one, overriding only what you need.
Inheriting with %inherit_from¶
Put %inherit_from= on the very first line of your file, pointing at the parent rulebook
(module.vendor, without the extension):
%inherit_from=annet.rulebook.texts.huawei
# only the rules below differ from the built-in Huawei rulebook
bgp %logic=mycompany.rulebook.huawei.bgp.special
Annet loads the parent, loads your file, and merges them. A rule with the same text as one in
the parent is merged into it (your params win, and child blocks merge recursively); a rule that
does not exist in the parent is added. Only one %inherit_from is allowed per file, and it must
be the first line.
To remove an inherited rule rather than override it, mark it with %not_inherit:
%inherit_from=annet.rulebook.texts.huawei
stp disable %not_inherit
The merge rules differ slightly per type:
Patching — rules merge by their text; child params and child sub-blocks override/merge with the parent’s.
Ordering — works by anchoring; this one is worth a closer look, see the next section.
Deploy — both rules and their
dialog:answers are merged;%not_inheritdrops an inherited rule or dialog.
How ordering inheritance works¶
An ordering rulebook is fundamentally a sequence, so “merging” two sequences needs a rule for where the child’s lines land. The mechanism is anchoring, and once you see it, it stops being mysterious.
Your child file does not restate the whole order. Instead, Annet reads it like this:
Rules are matched between parent and child by their exact command text (params such as
%order_reversedon’t affect matching). A rule that exists in both files is an anchor — a fixed point that pins the child to the parent.A rule that exists only in the child is a new rule. It is inserted relative to the nearest anchor above it in your child file: by default, right after that anchor.
The anchors you mention must appear in the same relative order as in the parent. You cannot reorder existing rules from a child — the parent owns the order. Listing anchors out of order raises “The relative order of rules must stay the same in both parent and child rulebooks.”
A worked example. Say the built-in acme.order is:
hostname
ntp
logging
snmp
interface
and your child rulebook is:
%inherit_from=annet.rulebook.texts.acme
hostname
my-banner
interface
hostname and interface exist in both files, so they are anchors. my-banner is
new, and it sits after the hostname anchor, so the merged order becomes:
hostname
my-banner ← inserted right after its anchor
ntp
logging
snmp
interface
Notice you only had to name the two anchors around your insertion point — you never repeated
ntp, logging or snmp.
Pushing a rule to the end of a group: %insert_to_end_group¶
The span between two of your anchors is a group. By default a new rule goes to the top
of its group (right after the opening anchor). Add %insert_to_end_group to send it to the
bottom of the group instead — right before the next anchor, after all the parent rules in
between:
%inherit_from=annet.rulebook.texts.acme
hostname
my-banner
my-cleanup %insert_to_end_group
interface
Both my-banner and my-cleanup are anchored to hostname (the group runs from
hostname to interface), but they land at opposite ends of it:
hostname
my-banner ← top of the group (right after the anchor)
ntp
logging
snmp
my-cleanup ← bottom of the group (right before the next anchor)
interface
Anchoring on a repeated rule: %split¶
A plain rule may anchor only once. A rule marked %split (see Ordering rulebooks above)
repeats, and each occurrence is its own anchor. For that to work the rule must carry
%split in both the parent and the child, so the merge can line the occurrences up.
Pointing Annet at your rulebooks¶
Tell Annet which package holds your rulebooks via the rulebook.module setting in your context
config (context.yml):
rulebook:
default:
module: mycompany.rulebook.texts
context:
default:
rulebook: default
# ... your other context settings
The default, if you do not set this, is annet.rulebook.texts.
Two requirements for the package to be discoverable:
It is loaded with
importlib.resources, so every directory in the path must be a real Python package — don’t forget the__init__.pyin your rulebook folder (and in the packages holding any%logic/%diff_logic/%apply_logicmodules).The patching file must be named exactly
<vendor>.rulto match the device’s vendor;<vendor>.orderand<vendor>.deployare optional.
Reference¶
Patching (.rul) params¶
Param |
Meaning |
|---|---|
|
apply the rule at any nesting depth below this point |
|
custom function to build commands for a single matched rule |
|
custom function to compute the diff of a whole block |
|
the block’s children are order-sensitive |
|
re-create the block from scratch instead of diffing it |
|
treat a multi-line value as one indivisible command |
|
match and compare case-insensitively |
|
skip the produced command when running without a commit |
|
tag the rule with a named context |
|
when inheriting, drop this rule from the parent |
|
(first line only) inherit from another rulebook |
|
exclude matching lines from patching entirely |
Ordering (.order) params¶
Param |
Meaning |
|---|---|
|
place the command at this position, but only for its removal form (no-op for forward commands) |
|
apply the ordering rule at any nesting level |
|
allow the rule to appear several times, splitting a block into phases |
|
when inheriting, place this new rule at the end of its group (before the next anchor) instead of right after the preceding anchor |
|
restrict the rule to the patch-ordering pass (not the config-ordering pass) |
|
when inheriting, drop this rule from the parent |
|
(first line only) inherit from another rulebook |
Deploy (.deploy) params¶
Param |
Meaning |
|---|---|
|
answer the device prompt |
|
seconds to wait for the command (default |
|
send a trailing newline with the answer (default |
|
do not treat an error from this command as a failure |
|
only apply in the named context |
|
custom function returning before/after command lists |
|
when inheriting, drop this rule or dialog from the parent |
|
(first line only) inherit from another rulebook |
Built-in %logic functions (annet.rulebook.common)¶
default, permanent, rewrite, ordered, ignore_changes, undo_redo,
default_instead_undo.
Built-in %diff_logic functions (annet.rulebook.common)¶
default_diff, ordered_diff, rewrite_diff, multiline_diff.
The %logic function in detail¶
Annet calls the function with every argument by keyword, so the complete signature is:
def fn(rule, key, diff, hw, rule_pre, root_pre) -> Iterable[tuple[bool | None, str, dict | None]]
In practice you only declare the arguments you use and collect the rest with **_ — but for
reference, here is every one of them.
Input — rule (dict) — every attribute of the matched rule:
Field |
Type |
Meaning |
|---|---|---|
|
|
compiled regex that parsed the config line; its capture groups are the |
|
|
removal-command template; |
|
|
the |
|
|
the |
|
|
whether the rule is a block (has child rules) |
|
|
the |
|
|
the |
|
|
the |
|
|
the |
|
|
extra strings shown with |
|
|
the rule’s |
Input — key (tuple[str, ...]) — the values the * / ~ placeholders matched,
i.e. rule["regexp"].match(line).groups(). Feed it into rule["reverse"].format(*key).
Input — diff (dict[Op, list[dict]]) — what changed for this command, grouped by
operation (Op.ADDED / Op.REMOVED / Op.AFFECTED / Op.MOVED / Op.UNCHANGED).
Each entry is {"row": str, "children": dict | None}.
Input — hw (HardwareView) — the device’s hardware, for branching on model.
Input — rule_pre (dict) — this rule’s full pre-computed data
({"rule": str, "attrs": dict, "items": {key: diff, ...}}); rule above is its attrs.
Input — root_pre (dict) — the same structure for the whole patch, keyed by rule text;
use it to inspect sibling commands. (hw / rule_pre / root_pre are rarely needed.)
Output — an iterable of (is_forward, command, children) tuples:
is_forward(bool) — forward (configuration) command vs removal command;command(str) — the command text to emit;children(dict | None) — a sub-tree to recurse into, orNonefor a leaf line.
Yield nothing to emit no command for the rule.
The %diff_logic function in detail¶
A %diff_logic function computes the diff of a whole block. Annet calls it with every argument
by keyword, so the complete signature is:
def fn(old, new, diff_pre, _pops) -> list[DiffItem]
Input — old (OrderedDict) — the block’s children in the old config, mapping each
row (str) to its sub-tree.
Input — new (OrderedDict) — the same for the new config.
Input — diff_pre (OrderedDict) — Annet’s pre-computed match metadata, keyed by config
line. Most functions just pass it straight through to common.default_diff; if you do inspect
it, each entry has the shape:
diff_pre[row] = {
"match": {
"attrs": {...}, # the rule's attributes — the same dict as `rule` in %logic
"rule": str, # rule text without params, e.g. "ntp server *"
"raw_rule": str, # rule text with params
"key": tuple, # placeholder values matched on this row
},
"subtree": OrderedDict, # the same structure again for this row's children
}
The diff_pre field on each returned DiffItem (below) is exactly the "match" dict here.
Input — _pops (tuple[Op, ...]) — the chain of parent operations, used internally;
forward it unchanged. Its default is (Op.AFFECTED,).
Output — a list of DiffItem named tuples (op, row, children, diff_pre):
op(Op) — the operation:ADDED/REMOVED/AFFECTED/MOVED/UNCHANGED;row(str) — the config line;children(list[DiffItem]) — the diff of this row’s sub-block;diff_pre(dict) — the"match"entry for this row (the inner dict shown above).
The usual pattern is to call common.default_diff(old, new, diff_pre, _pops) for the normal
list and then filter or tweak it.