Skip to content

Scenarios

The lab can hold more than one network. A scenario is a directory under scenarios/ carrying everything that makes one lab different from another: its topology, its workflows, its CMS scope configuration, its device configs and its OIDC configuration.

make scenarios                          # what is available
make local-lab-up                       # the default scenario
make local-lab-up SCENARIO=frr-only     # a different one

One scenario runs at a time. They share lab-net, the 172.30.0.0/24 subnet and the compose project, so switching means tearing the running one down first: both make local-lab-up and make kind-lab-up refuse while a different scenario is deployed, and each records which one in generated/.deployed-scenario-*.

What a scenario owns

Anything a scenario does not carry comes from scenarios/_base/, file by file. A scenario that overrides one file in scope/Global/ keeps the base’s other five; a scenario that adds a workflow keeps the base’s discovery workflow too.

Path Inherited from _base?
topology.json No — required in every scenario. Its optional zones declare network zones
scenario.json, README.md No — required in every scenario
workflow-execution-parameters/*.json, …/zones/*.json No — generated from the topology
workflows/*.{yaml,yml,json} Yes
scope/<name>/*.json Yes
devices/frr/frr.conf, daemons, set-aliases.sh Yes
function_blocks/ Yes
cms/oidc-config.json, cms/permissions.json Yes

resolve_scenario and all four generators take --scenario NAME as an alternative to $SCENARIO; ./resolve_scenario --list prints the table make scenarios shows, and --quiet drops the per-file listing. Run by hand with neither the flag nor the variable they refuse rather than guess, because the Makefile holds the only default.

topology.json is the one thing never inherited: a scenario is its topology, so there is no meaningful base to fall back to.

How it is resolved

flowchart LR
    base["scenarios/_base/"]
    scen["scenarios/$SCENARIO/"]
    resolve["resolve_scenario"]
    out["generated/$SCENARIO/scenario/<br/>flat, complete"]
    consumers["compose bind mounts<br/>apply_cms_config<br/>gen_clab_topology<br/>gen_kind_manifests"]

    base --> resolve
    scen --> resolve
    resolve --> out
    out --> consumers

The resolved tree is the plain union of both directories, with no carve-outs: scenario.json, README.md, topology.json and workflow-execution-parameters/ land there too, not only the assets something mounts. Note that the generators still read scenarios/<name>/topology.json rather than the resolved copy, so do not “fix” one to read the other.

docker compose cannot express a fallback in a bind mount, so the overlay is materialised once — by make scenario-resolve, a prerequisite of every target that consumes a scenario — into one flat directory. Nothing downstream knows overlays exist, and the rule lives in exactly one place instead of being reimplemented in Python, in bash and in YAML.

resolve_scenario prunes whatever a previous resolve left behind, so removing an override takes effect on the next run rather than lingering.

One selector, one default

SCENARIO is the only selector. The Makefile exports it and holds the only default; every script, compose file and bash entry point fails when it is unset rather than guessing. A scenario applied half from one lab and half from another is invisible until discovery produces a confusing result, which is a far more expensive failure than a target that refuses to run.

The compose files use ${SCENARIO:?…} rather than a bare reference for the same reason: left to warn, compose interpolates a blank path and docker creates the missing bind source as an empty directory — the lab would come up having registered no workflows at all.

Adding a scenario

  1. mkdir scenarios/<name>, then write topology.json, scenario.json and README.md. The manifest is a contract, enforced by tests/test_scenario_manifests.py:

    Key Rule
    name Must equal the directory name
    title Short label, shown by make scenarios
    summary One sentence, non-empty
    flavours Non-empty subset of clab, kind. Listing kind requires at least one FRR device, since the Kubernetes flavour renders those only
    demonstrates Non-empty list of what the scenario is for

    A README.md is required beside it, and every device’s mgmt_ip must be unique within the scenario. 2. make generate SCENARIO=<name> — writes the containerlab topology under generated/<name>/clab/ and the committed scenarios/<name>/workflow-execution-parameters/*.json. 3. make test — the generator and manifest guards run against every scenario, so this is where a topology that does not round-trip, or a link left pointing at a device you removed, is caught. 4. Commit the regenerated parameter files. make test fails if they drift.

Add only what differs from scenarios/_base/. A scenario needing a different FRR baseline ships a devices/frr/daemons or devices/frr/frr.conf delta — there is no second image to build, because the FRR image no longer bakes either file.

Network zones in a scenario

A network zone is a network that only its own workers reach; zones may reuse each other’s addresses. A topology may declare network zones. The lab gives each one what a file overlay cannot carry: a management network, a containerlab topology and a worker of its own.

{
  "zones": {
    "zone-a": { "subnet": "172.30.10.0/24", "description": "Site A" }
  },
  "devices": {
    "core-rtr-01":   { "mgmt_ip": "172.30.0.11",  "vendor": "frr", "...": "..." },
    "site-a-rtr-01": { "mgmt_ip": "172.30.10.11", "vendor": "frr", "zone": "zone-a", "...": "..." }
  }
}
  • A device without zone is in default, on lab-net, and is moved into LAB_SUBNET like every other scenario’s devices. A zone’s devices keep the addresses the topology gives them, inside that zone’s subnet.
  • Zone names follow the CMS’s rule: lowercase letters, digits and hyphens. default cannot be declared; it is where a device without a zone already is.
  • No link may cross zones, and no zone subnet may overlap another or the lab’s own networks; tests/test_network_zones.py checks both. Zone subnets are fixed in the topology and LAB_SUBNET does not move them, so a scenario with zones cannot run as a second lab beside itself; make doctor names a network that holds one of them.
  • apply_cms_config creates the zones in the CMS from the resolved topology, before any worker starts. It needs neops-core 2.4.0, the release .env.example pins.
  • A scenario with zones runs on containerlab only ("flavours": ["clab"]); the kind-lab-* targets refuse it.
  • A scenario that starts more workers than the developer preview admits (two) names "engine": "enterprise" in its scenario.json, so the lab pulls the licensed engine, which needs docker login quay.io. ./resolve_scenario --engine prints what a scenario asks for; without the key it is developer-preview. wait_ready stops at once, naming the license, when the engine admits fewer workers than the lab starts.

Make targets lists what each lab target does with them.

The scenarios that ship

WAN and DC fabric

The lab’s original network, and the one every make target uses unless SCENARIO says otherwise.

Two independent islands on lab-net (172.30.0.0/24):

  • A 10-node FRR network — two core routers, two edge routers, three PEs, two WAN routers and a border router, wired core-to-edge-to-PE with customer CE stubs and an ISP uplink stub. Reached over SSH as frr / frr.
  • A 5-node Nokia SR Linux fabric — two spines and three leaves in a leaf-spine mesh, with server stubs on the leaves. Reached as admin / NokiaSrl1!.

What it demonstrates

Discovery against two NOS families in one run, from four different parameter shapes: declared platforms, autodetected platforms, a summarising subnet, and a hand-written mixed file. LLDP neighbour discovery is real on the SR Linux fabric only; the FRR nodes do not run LLDP.

Flavours

Both. make kind-lab-up renders the 10 FRR devices only — the SR Linux nodes are far too heavy for a shared local cluster.

FRR network only

wan-and-fabric with the five Nokia SR Linux nodes removed: two core routers, two edge routers, three PEs, two WAN routers and a border router. Reached over SSH as frr / frr.

The two vendor groups are separate islands in the full topology — no FRR interface points at a spine or a leaf — so nothing had to be rewired to produce this.

Why it exists

The SR Linux nodes want several GB of RAM and boot slowly. This scenario brings the lab up in a fraction of the time, and it is what the Kubernetes flavour renders anyway, so make local-lab-up SCENARIO=frr-only and make kind-lab-up SCENARIO=frr-only cover the same devices.

It also demonstrates the overlay: this directory holds a topology, a manifest and this README, and nothing else. The workflows, the Global scope configuration, the FRR device config and the example function blocks all come from scenarios/_base/.

Discovery parameters

The three generated files only. There is no discover-params-mixed.json here — mixing per-subnet platforms and credentials has nothing to express when every device runs the same NOS.

Network zones

A network zone is a network that only its own workers reach; zones may reuse each other’s addresses.

Six FRR routers in three network zones. Two sit on the lab network in default, as every other scenario’s devices do; zone-a and zone-b each hold two on a subnet of their own, with a worker attached to that subnet alone. Reached over SSH as frr / frr.

Zone Subnet Devices Worker
default 172.30.0.0/24 (LAB_SUBNET) core-rtr-01, core-rtr-02 worker, the central one
zone-a 172.30.10.0/24 site-a-rtr-01, site-a-rtr-02 worker-zone-a
zone-b 172.30.20.0/24 site-b-rtr-01, site-b-rtr-02 worker-zone-b

Why it exists

It shows network zones on real devices. Each zone’s worker is the only one that can reach that zone’s routers, so a discovery job that ran on the wrong worker would find nothing:

  • Routing. make local-lab-discover discovers default as every scenario does, then runs the same wf.lab.neops.io/simple_lab_discovery once per zone with that zone’s parameters. Its step declares networkZone: "{{ parameters.zone || 'default' }}", so the engine hands each run’s job only to a worker serving the zone the parameters name.
  • Stamping. The discovery block records each device in the zone its job ran in. The web client’s device list shows the zone, and the CMS filters by it: devices(networkZone_Name: "zone-a").

No two zones share an address here, because one Docker daemon refuses overlapping subnets. Two devices answering on the same address in two zones is covered by the workflow engine’s end-to-end playbook, not by this lab.

What it needs

The licensed workflow engine. The scenario starts three workers, the central one and one per zone, and the public developer preview admits two. Its scenario.json names "engine": "enterprise", so the lab pulls quay.io/zebbra/neops-workflow-engine instead of the preview. That image is not public: run docker login quay.io with an account that can pull it before make local-env-init or make local-lab-up. A NEOPS_WORKFLOW_ENGINE_IMAGE in .env or the environment still wins over the scenario, so unset it or point it at a licensed engine. On an engine that admits fewer workers than the lab starts, make local-lab-up stops at wait_ready with a message saying so, rather than timing out.

Images that carry network zones. .env.example pins these releases; a .env written before them keeps its older pins, so copy the three image pins over:

  • a CMS from neops-core 2.4.0 or later (NEOPS_CMS_IMAGE): apply_cms_config stops at bootstrap_admin_role otherwise;
  • a workflow engine 0.46.0 or later (NEOPS_ENGINE_TAG, the tag of the licensed engine in this scenario);
  • a worker from neops-worker-sdk 0.5.0 or later (NEOPS_WORKER_SDK_IMAGE), which carries global_discover_network:0.2.0.

It also needs docker compose 2.24.4 or later, for the !override that keeps each zone’s worker off the other networks.

It runs on containerlab only: the Kubernetes flavour puts every FRR device in one namespace and knows no zones, and the kind-lab-* targets refuse it.

How the lab carries it

The zones are declared in topology.json under zones, each with its subnet and a description, and each device names its zone with "zone"; a device without one is in default. From that one declaration:

  • apply_cms_config creates the zones in the CMS before any worker starts. A zone’s worker is refused while its zone does not exist.
  • make generate writes one containerlab topology per zone (generated/network-zones/clab/neops-lab-<zone>.clab.json, on network ${LAB_NET}-<zone>), a compose overlay with one worker per zone (generated/network-zones/compose/zones.yml), and the per-zone discovery parameters under workflow-execution-parameters/zones/.
  • make local-lab-up waits until default and each zone have an online worker holding the discovery block, then for each zone’s devices from inside that zone’s worker.

The central worker serves default in every scenario (NEOPS_WORKER_NETWORK_ZONES: default in docker-compose.worker.yml). The zones stay in the CMS volume after make local-lab-down, and once a zone exists the engine refuses a worker that lists none. move_device_to_zone stays in the engine’s database too: on the developer preview, which admits five workflows, a scenario started afterwards has one slot fewer for workflows of your own.

Edit the zones while the scenario is deployed and make generate keeps the topology of a zone you removed, so make local-lab-down still tears that zone down, its worker included.

The zone subnets are fixed in topology.json. LAB_SUBNET moves only the default devices, so this scenario cannot run as a second lab beside itself; make doctor names the networks that hold a zone’s subnet.

Discovery parameters

The three generated files cover the default devices only. Each zone has one generated file, zones/<zone>.json, which carries the zone’s name beside its /32 targets and credentials.

Moving a device to another zone

workflows/move-device.workflow.yaml is registered with the scenario’s other workflows as wf.lab.neops.io/move_device_to_zone:1.0.0. It runs fb.base.neops.io/device_update_device:0.2.0 on one device, with the block’s network_zone parameter set to the zone you give it:

./run_workflow wf.lab.neops.io/move_device_to_zone:1.0.0 '{"zone": "zone-b"}' --ids <device id>

--ids takes the device’s id in the CMS, which devices(networkZone_Name: "zone-a") { results { id hostname } } lists. The block records the move without connecting to the device, and its job runs in the device’s current zone. The device’s next jobs go to the new zone’s workers. In this lab those workers cannot reach the device’s own subnet, so move it back with '{"zone": "zone-a"}' before you run anything else on it.

When the device’s current zone has no worker left, as at a site that is already unreachable, copy the workflow and give its step networkZone: { name: <target zone>, override: true }: the job then runs on a worker of the target zone, and only the network_zone parameter moves the device.