1. Intent and Repository Layout
Chapter 1: Intent and Repository Layout¶
Capstone chapter 1 of 7 โ project overview
"Decide What Is Data Before You Write Any Code"¶
Every automation project makes one decision early that it lives with for years: what is data and what is code?
Get it right and adding a new site is editing a YAML file. Get it wrong and adding a new site means editing Python, which means testing Python, which means a code review and a release for something that should have been a pull request against a text file.
This chapter makes that decision explicitly, then builds the skeleton around it.
๐ฏ What You'll Build¶
- The
netpipepackage layout every later chapter fills in intent/man1.yamlโ a complete description of a small access-layer site- A loader that reads it and prints it back
Builds on: YAML Data Modelling
๐งญ The Data/Code Boundary¶
Here's the rule this project uses. It's not the only defensible one, but it is consistent, which matters more:
| Belongs in data (YAML) | Belongs in code (Python/Jinja2) |
|---|---|
| Which devices exist | How to connect to a device |
| What VLANs a site has | What a VLAN configuration block looks like |
| Which interface is in which VLAN | The rule that access ports need a VLAN |
| Site codes, naming, addressing | The naming standard's regular expression |
| Anything that differs per site | Anything that's the same everywhere |
The test to apply when you're unsure: would a network engineer who doesn't write Python need to change this? If yes, it's data.
The Failure Mode This Prevents
The most common shape of a doomed automation project is a Python file with a dictionary at the top containing site-specific values, "just for now". Six months later there are fourteen of them, three have drifted out of sync, and nobody can deploy a site without a developer. Draw the line now while the project is one file.
๐๏ธ Create the Skeleton¶
mkdir -p netpipe/{intent,netpipe,templates,inventory,tests,artefacts}
cd netpipe
python -m venv .venv
source .venv/bin/activate
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
pip install "pydantic>=2.0" pydantic-settings pyyaml jinja2 \
nornir nornir-netmiko nornir-utils netmiko rich
pip freeze > requirements.txt
Create the package marker and a .gitignore that keeps your secrets and generated files out of version control:
touch netpipe/__init__.py
cat > .gitignore <<'EOF'
.venv/
__pycache__/
*.pyc
.env
artefacts/
EOF
git init && git add -A && git commit -m "netpipe: project skeleton"
artefacts/ Is Ignored on Purpose โ For Now
Generated configs and diffs don't belong in git while you're developing; they'd churn on every run. Audit records are different, and chapter 7 discusses where those should actually go. Local-only is fine until then.
๐ Write the Intent File¶
This is the heart of the project. Everything downstream is a transformation of this file.
intent/man1.yaml:
---
# Site intent for MAN1 โ access layer
# This file describes what the site SHOULD look like.
# It does not describe how to achieve it.
version: 1
site: MAN1
description: Manchester campus, building 1, access layer
change_reference: CHG0047821
defaults:
domain_name: example.internal
ntp_servers:
- 10.0.0.10
- 10.0.0.11
syslog_server: 10.0.0.20
unused_vlan: 999
mtu: 1500
vlans:
- id: 10
name: USERS
description: General user access
- id: 20
name: VOICE
description: IP telephony
- id: 30
name: PRINTERS
description: Managed print devices
- id: 999
name: PARKING
description: Shutdown holding VLAN for unused ports
devices:
- name: man1-acc-sw-01
mgmt_ip: 10.1.1.11
platform: iosxe
role: access
model: C9300-48P
interfaces:
- name: GigabitEthernet1/0/1
description: Desk 101
mode: access
access_vlan: 10
voice_vlan: 20
- name: GigabitEthernet1/0/2
description: Desk 102
mode: access
access_vlan: 10
voice_vlan: 20
- name: GigabitEthernet1/0/3
description: Print room
mode: access
access_vlan: 30
- name: TenGigabitEthernet1/1/1
description: Uplink to man1-dist-sw-01
mode: trunk
trunk_vlans: [10, 20, 30]
- name: man1-acc-sw-02
mgmt_ip: 10.1.1.12
platform: iosxe
role: access
model: C9300-24P
interfaces:
- name: GigabitEthernet1/0/1
description: Desk 201
mode: access
access_vlan: 10
voice_vlan: 20
- name: GigabitEthernet1/0/2
description: Meeting room AV
mode: access
access_vlan: 10
- name: TenGigabitEthernet1/1/1
description: Uplink to man1-dist-sw-01
mode: trunk
trunk_vlans: [10, 20, 30]
Five decisions worth noticing¶
version: 1 at the top. When the schema changes โ and it will โ you need a way to tell an old file from a new one. It costs one line now and saves a guessing game later.
change_reference is in the data. The pipeline's audit record (chapter 7) needs to tie back to your change management system. Putting it in the intent file means the change number travels with the change, rather than being typed on a command line and mistyped at 02:00.
Only exceptions are listed. man1-acc-sw-01 is a 48-port switch with four interfaces described. The other forty-four aren't an oversight โ chapter 2 expands them into explicit unused state. Operators document what's special; the tool accounts for everything else. This is the same decision the Cisco Config Generator makes, and for the same reason: a port omitted by accident and a port omitted deliberately must not produce different outcomes.
defaults is a block, not repetition. NTP servers are the same on every device at the site. Repeating them per device would guarantee they eventually differ.
No credentials. Not commented out, not placeholders โ absent. Chapter 4 handles them properly. Anything you put in this file is going in git.
๐ The Loader¶
A deliberately minimal one for now. Chapter 2 replaces its guts with Pydantic; this version exists so you can see the raw shape of what you've written.
netpipe/intent.py:
#!/usr/bin/env python3
"""
Load raw site intent from YAML.
This is the chapter 1 version: it parses, and that's all. Chapter 2
replaces the return type with validated models.
"""
from pathlib import Path
import yaml
def load_raw_intent(path: str | Path) -> dict:
"""Read a YAML intent file and return it as a plain dictionary."""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Intent file not found: {path}")
with path.open(encoding="utf-8") as handle:
data = yaml.safe_load(handle)
if not isinstance(data, dict):
raise ValueError(f"{path} did not contain a YAML mapping")
return data
Note yaml.safe_load, not yaml.load. An intent file is input, and yaml.load can instantiate arbitrary Python objects. There is never a reason to use it here.
๐ฅ๏ธ A Front Door¶
Every chapter adds a subcommand. Set the pattern up now so there's somewhere to put them.
netpipe/cli.py:
#!/usr/bin/env python3
"""
netpipe command line interface.
"""
import argparse
import sys
from netpipe.intent import load_raw_intent
def cmd_show(args: argparse.Namespace) -> int:
"""Print a summary of an intent file."""
try:
intent = load_raw_intent(args.intent)
except (FileNotFoundError, ValueError) as exc:
print(f"โ {exc}", file=sys.stderr)
return 1
print(f"Site: {intent.get('site')}")
print(f"Change: {intent.get('change_reference')}")
print(f"VLANs: {len(intent.get('vlans', []))}")
print(f"Devices: {len(intent.get('devices', []))}\n")
for device in intent.get("devices", []):
described = len(device.get("interfaces", []))
print(f" {device['name']:<20} {device['mgmt_ip']:<12} "
f"{device.get('model', '?'):<12} {described} interfaces described")
return 0
def build_parser() -> argparse.ArgumentParser:
# Options every subcommand shares. Defining them on a parent parser
# means `netpipe show --intent X` works โ which is the order people
# actually type. An argument on the top-level parser would only be
# accepted *before* the subcommand.
common = argparse.ArgumentParser(add_help=False)
common.add_argument(
"--intent",
default="intent/man1.yaml",
help="path to the site intent file (default: %(default)s)",
)
parser = argparse.ArgumentParser(
prog="netpipe",
description="Intent-driven configuration pipeline",
)
sub = parser.add_subparsers(dest="command", required=True)
show = sub.add_parser("show", parents=[common],
help="summarise an intent file")
show.set_defaults(func=cmd_show)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
Make it runnable as a module:
netpipe/__main__.py:
import sys
from netpipe.cli import main
sys.exit(main())
โถ๏ธ Run It¶
python -m netpipe show
Output:
Site: MAN1
Change: CHG0047821
VLANs: 4
Devices: 2
man1-acc-sw-01 10.1.1.11 C9300-48P 4 interfaces described
man1-acc-sw-02 10.1.1.12 C9300-24P 3 interfaces described
No devices touched, nothing installed beyond PyYAML. That's chapter 1 working.
Windows: UnicodeEncodeError on the Tick Characters
If you're on Windows and the first โ throws UnicodeEncodeError: 'charmap' codec can't encode character 'โ', your console is running the legacy cp1252 code page. The code is fine; the terminal can't render it.
Fix it for the session:
$env:PYTHONUTF8 = "1"
Or permanently, by adding this to the top of netpipe/__main__.py:
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
Worth doing now โ every remaining chapter prints status symbols.
๐งช Prove the Boundary Holds¶
One test, to lock in the decision this chapter made:
tests/test_intent.py:
import pytest
from netpipe.intent import load_raw_intent
def test_loads_real_intent_file():
intent = load_raw_intent("intent/man1.yaml")
assert intent["site"] == "MAN1"
assert len(intent["devices"]) == 2
def test_missing_file_is_a_clear_error():
with pytest.raises(FileNotFoundError, match="Intent file not found"):
load_raw_intent("intent/nope.yaml")
def test_no_credentials_in_intent():
"""Intent is committed to git. Secrets must never appear in it."""
with open("intent/man1.yaml", encoding="utf-8") as handle:
content = handle.read().lower()
for forbidden in ("password", "secret", "enable_pass", "api_key", "token"):
assert forbidden not in content, f"'{forbidden}' found in intent file"
pip install pytest
pytest tests/ -q
That third test is not a joke test. It's a tripwire, it costs nothing to run in CI, and it will catch the day somebody adds a password: key because it was the fastest way to get their change out.
๐ Where You Are¶
netpipe/
โโโ .gitignore
โโโ requirements.txt
โโโ intent/
โ โโโ man1.yaml โ the site, described
โโโ netpipe/
โ โโโ __init__.py
โ โโโ __main__.py
โ โโโ cli.py โ show
โ โโโ intent.py โ raw loader
โโโ templates/ (empty โ chapter 3)
โโโ inventory/ (empty โ chapter 5)
โโโ tests/
โ โโโ test_intent.py
โโโ artefacts/ (empty โ chapter 3 onwards)
๐ฏ Key Takeaways¶
- โ Decide data vs code once, early โ The boundary is expensive to move later
- โ Intent describes the destination, never the route to it
- โ Version your intent format โ One line now, no archaeology later
- โ Document exceptions, derive the rest โ Omission must be deliberate, not accidental
- โ Never put secrets in intent โ It's committed; test that it stays clean
- โ
yaml.safe_load, always โyaml.loadexecutes what it reads
โก๏ธ Next¶
Right now nothing checks any of this. mgmt_ip could be banana, access_vlan could be 5000, and show would print it happily.
Chapter 2 โ Validating Intent turns the file into typed models that refuse to load when they're wrong.
โ Capstone Overview | Chapter 2 โ Validating Intent โ
Need help applying this in a live Cisco environment?
This guide is part of the Nautomation Prime Foundation and stays free to read, share, and reuse. If you want the pattern implemented, governed, or adapted for your estate, that is paid engineering work — start a discovery conversation or review how Nautomation Prime delivers engagements. If you are a registered UK charity or CIC, there is a free and low-cost route instead.