Dev setup
Fast, hermetic, and entirely local — no lab needs to be running to work on this repo.
Install
That gets pytest, ruff and pyrefly. Nothing else is needed: the repo declares no runtime dependencies, because the host scripts are stdlib-only by design.
The gates
| Target | Command |
|---|---|
make lint |
uv run ruff format --check . then uv run ruff check . |
make format |
uv run ruff format . then uv run ruff check --fix . |
make typeCheck |
uv run pyrefly check |
make test |
uv run pytest tests |
Ruff is configured with line length 120, target py312, and a broad rule selection (F E W I B UP N S A C4 SIM RET PIE ARG PTH PERF PT PL RUF) with E501 delegated to the formatter.
What CI runs
.github/workflows/ci.yml, on pushes to main/develop and on every PR:
lint-testonubuntu-latest—uv sync --group dev --frozen, then ruff format check, ruff lint, pyrefly, pytest. Exactlymake check.dockeron the self-hostedhetznerrunner, gated onlint-test—make build-docker. The images are local-only, so this job exists purely to make a broken Dockerfile fail in CI rather than on a developer’s firstmake local-lab-up.
There is no job that stands the lab up: 15 device containers and a few GB of SR Linux RAM do not belong in CI.
The extension-less script rule
Add a new host script to both include lists or it is never checked
The host scripts are executables with no .py extension:
Ruff and pyrefly both discover files by extension, so each script has to be
named explicitly in pyproject.toml — in two places:
[tool.ruff]
extend-include = ["gen_clab_topology", "gen_device_configs", "run_workflow", "wait_devices", "wait_ready"]
[tool.pyrefly]
project-includes = ["tests/**/*.py", "function_blocks/**/*.py", "gen_clab_topology", …]
Miss one and the script is silently never linted or type-checked. There is no warning; the gate just passes.
Why they have no extension
Three callers depend on the bare names:
tests/*load them by path viaimportlib.machinery.SourceFileLoader—spec_from_file_locationreturnsNonefor an unrecognised extension, which is why the tests use the explicit-loader form;gen_clab_topologyloadsgen_device_configsthe same way;- the
Makefileand the docs invoke them as./gen_clab_topology.
Renaming them to *.py is not an option — it would break every caller.
apply_cms_config is bash, not Python
It is deliberately absent from both include lists. Do not “fix” that by adding it — ruff would try to parse shell as Python.
Note also that CI lints . (a directory walk) rather than an explicit file
list, because extend-include only applies to discovery.
Tests
Two modules, both loading the generator scripts by path:
tests/test_gen_device_configs.py— the per-device renderers: FRR.ifacelines, the loopback-first rule, SR Linuxset /lines, and theethernet-1/N→e1/Ndescription conversion.tests/test_gen_clab_topology.py— interface-name mapping, veth-link deduplication, dummy-link generation, and the byte-for-byte reproduction of the committedworkflow-execution-parameters/*.json.
That last one is the repo’s real guard. _dump_discover_params hand-rolls a compact layout json.dumps cannot produce, so the assertion is exact: change topology.json, rerun ./gen_clab_topology, and commit the regenerated JSON — or make test fails.
Working on the docs
make doc-serve # live preview with regeneration on config change
make doc-build # what to run before pushing docs changes
mkdocs.yml is autogenerated — never edit it
It is produced by setup_documentation.py from mkdocs_custom.yml
deep-merged over the shared mkdocs_base.yml (vendored under
.make_scripts/mkdocs-documentation/). Two consequences:
- Dicts merge, lists concatenate. Re-declaring a
hooks:orplugins:entry that the base already registers loads it twice. docs/assets/extra.cssandextra.jsare overwritten on everymake doc-update-assets. Project-specific styling must go in a differently named file.
docs/ contains symlinks to the repo’s source directories (workflows/, devices/, clab/, workflow-execution-parameters/, …) created by the setup script. That is what makes --8<-- snippet includes work without referencing paths outside docs/:
# Lab discovery: SSH into the containerlab devices and register them as Device
# entities in the CMS.
label: simple_lab_discovery
name: simple_lab_discovery
package: wf.lab.neops.io
majorVersion: 1
minorVersion: 2
patchVersion: 0
seedEntity: global
type: workflow
description: "Discover the lab devices and register them in the CMS."
parameterSchema:
type: object
properties:
subnets:
type: array
description: "CIDRs to expand; a single host is a /32. A subnet can scope credentials and a platform, and the most specific prefix wins for overlaps."
default: []
items:
type: object
properties:
cidr: { type: string }
platform: { type: string }
credentials:
type: array
items:
type: object
properties:
username: { type: string }
password: { type: string }
platform: { type: string }
required: [username, password]
required: [cidr]
credentials:
type: array
description: "Logins tried in order against hosts whose subnet carries none. An entry may be scoped to a platform."
items:
type: object
properties:
username: { type: string }
password: { type: string }
platform: { type: string }
required: [username, password]
default: []
steps:
- type: functionBlock
label: discover
functionBlock: "fb.base.neops.io/global_discover_network:0.1.0"
runOn: global
parameters:
subnets: "{{ parameters.subnets }}"
credentials: "{{ parameters.credentials }}"
Snippets are configured with check_paths: true, so a broken include fails make doc-build rather than rendering an empty block. Keep code blocks longer than a few lines as includes from real files — that is what stops the docs drifting from the source.
