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
-
mkdir scenarios/<name>, then writetopology.json,scenario.jsonandREADME.md. The manifest is a contract, enforced bytests/test_scenario_manifests.py:Key Rule nameMust equal the directory name titleShort label, shown by make scenariossummaryOne sentence, non-empty flavoursNon-empty subset of clab,kind. Listingkindrequires at least one FRR device, since the Kubernetes flavour renders those onlydemonstratesNon-empty list of what the scenario is for A
README.mdis required beside it, and every device’smgmt_ipmust be unique within the scenario. 2.make generate SCENARIO=<name>— writes the containerlab topology undergenerated/<name>/clab/and the committedscenarios/<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 testfails 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
zoneis indefault, onlab-net, and is moved intoLAB_SUBNETlike every other scenario’s devices. A zone’s devices keep the addresses the topology gives them, inside that zone’ssubnet. - Zone names follow the CMS’s rule: lowercase letters, digits and hyphens.
defaultcannot 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.pychecks both. Zone subnets are fixed in the topology andLAB_SUBNETdoes not move them, so a scenario with zones cannot run as a second lab beside itself;make doctornames a network that holds one of them. apply_cms_configcreates the zones in the CMS from the resolved topology, before any worker starts. It needs neops-core 2.4.0, the release.env.examplepins.- A scenario with zones runs on containerlab only (
"flavours": ["clab"]); thekind-lab-*targets refuse it. - A scenario that starts more workers than the developer preview admits (two)
names
"engine": "enterprise"in itsscenario.json, so the lab pulls the licensed engine, which needsdocker login quay.io../resolve_scenario --engineprints what a scenario asks for; without the key it isdeveloper-preview.wait_readystops 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-discoverdiscoversdefaultas every scenario does, then runs the samewf.lab.neops.io/simple_lab_discoveryonce per zone with that zone’s parameters. Its step declaresnetworkZone: "{{ 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_configstops atbootstrap_admin_roleotherwise; - 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 carriesglobal_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_configcreates the zones in the CMS before any worker starts. A zone’s worker is refused while its zone does not exist.make generatewrites 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 underworkflow-execution-parameters/zones/.make local-lab-upwaits untildefaultand 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:
--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.
