Skip to content

Dev setup

Fast, hermetic, and entirely local — no lab needs to be running to work on this repo.

Install

uv sync --group dev

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

make check     # lint + typeCheck + test
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:

  1. lint-test on ubuntu-latestuv sync --group dev --frozen, then ruff format check, ruff lint, pyrefly, pytest. Exactly make check.
  2. docker on the self-hosted hetzner runner, gated on lint-testmake 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 first make 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:

gen_clab_topology  gen_device_configs  run_workflow  wait_ready  wait_devices

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 via importlib.machinery.SourceFileLoaderspec_from_file_location returns None for an unrecognised extension, which is why the tests use the explicit-loader form;
  • gen_clab_topology loads gen_device_configs the same way;
  • the Makefile and 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

uv run pytest tests

Two modules, both loading the generator scripts by path:

  • tests/test_gen_device_configs.py — the per-device renderers: FRR .iface lines, the loopback-first rule, SR Linux set / lines, and the ethernet-1/Ne1/N description conversion.
  • tests/test_gen_clab_topology.py — interface-name mapping, veth-link deduplication, dummy-link generation, and the byte-for-byte reproduction of the committed workflow-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: or plugins: entry that the base already registers loads it twice.
  • docs/assets/extra.css and extra.js are overwritten on every make 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.

Before you push

make check
make doc-build   # if you touched docs/ or mkdocs_custom.yml