diff --git a/docs/caveats.md b/docs/caveats.md index 79e4594659..e9581e80f4 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -436,6 +436,21 @@ ansible_httpapi_port: 80 * An OSPFv3 ABR running FRR release 10.3 does not originate summary external routes from NSSA areas * As of FRRouting release 10.6.0, the FRR implementation of EVPN for VXLAN-over-IPv6 is incompatible with the Arista EOS implementation due to different encodings of the PMSI_TUNNEL_ATTRIBUTE. +(caveats-ocnos)= +## IP Infusion OcNOS + +* OcNOS ships a commercial NOS image; there is no public Vagrant/Containerlab box. Obtain the `vrnetlab/ipinfusion_ocnos` container image (or an equivalent OcNOS VM) from IP Infusion and build/import it yourself. +* OcNOS's CLI (`cmlsh`) is a restricted shell with **no non-interactive exec mode** -- a plain `ssh device 'show ...'` fails with `Try 'cmlsh --help'`. Configuration deploy and collection use the **ipinfusion.ocnos** Ansible collection (`ocnos_config` / `ocnos_facts`) over `ansible_connection: network_cli`, which drives `cmlsh` interactively through dedicated cliconf/terminal plugins. Install the collection with `ansible-galaxy collection install ipinfusion.ocnos`. +* Because of the `cmlsh` restriction above, *netlab*'s SSH and `docker exec` validation transports cannot issue `show` commands directly against an OcNOS node. OcNOS instead uses the **`ansible` validation action**, which fetches show output through the `ipinfusion.ocnos.ocnos_command` module (declared in `netsim/devices/ocnos.yml` as `netlab_validate.ansible_module`); `netlab validate` works against OcNOS on both the device under test and attached FRR/Linux probes. +* An OSPF (v2 or v3) network-membership change on an already-running process (a new area or network added to an existing `router ospf`/`router ipv6 ospf`) makes OcNOS's `commit` return a non-empty informational notice (`Use "clear ip[v6] ospf process" command to take effect`). The `ocnos_config` Ansible module treats any non-empty commit response as a failure, which aborts the rest of that node's play -- any modules queued after `ospf` in the same **netlab initial** run (for example `isis`, `bgp`, `vrf`) are silently skipped for that node. Run **netlab initial** a second time after the first OSPF network/area change on a node; the second pass re-sends the now-unchanged OSPF config (no new membership, no notice) and lets the remaining modules deploy normally. This is a one-time cost per fresh OSPF network change, not a per-boot cost. +* The main loopback interface name is `lo`; the kernel `lo` owns `127.0.0.1`, so *netlab*'s loopback address is configured as a `secondary` address. Additional *netlab* loopbacks become `loopback` (OcNOS does not support `loopback0`). +* OcNOS declares DHCP client and relay support and renders the corresponding configuration (cross-checked against the OKF CLI reference), but the DHCP **relay datapath** is not exercised by the integration suite on a containerlab-only host -- the `dhcp/11-ipv4-relay` test needs a libvirt-based `dnsmasq` server probe. OcNOS is therefore intentionally omitted from the [DHCP support table](platform-services-support) until that end-to-end path is covered. +* GRE tunnel configuration renders for OcNOS, but the tunnel line protocol does not come up on the tested OcNOS 7.0 image, so OcNOS is not listed among the [tunnel.gre](plugin-tunnel-gre) supported platforms. +* VLANs use the Customer Bridge (VLAN-aware bridge) model: `bridge 1 protocol ieee vlan-bridge` + `vlan database`, with SVIs named `vlan1.`. +* Per-VRF OSPF uses a positional VRF name, not a `vrf` keyword: `router ospf `. +* LACP aggregates are named `po`; static (non-LACP) port channels are named `sa`. +* OSPFv3 is not supported inside a VRF: the `router ipv6 vrf ospf ` command commits and the interface area binding is accepted, but the process never actually attaches to the VRF (`show ipv6 ospf` reports zero areas indefinitely). This is a proven NSM-level gap, not a template limitation. + (caveats-junos)= ## Common Junos caveats diff --git a/docs/install/ocnos.md b/docs/install/ocnos.md new file mode 100644 index 0000000000..060637031f --- /dev/null +++ b/docs/install/ocnos.md @@ -0,0 +1,116 @@ +# Installing IP Infusion OcNOS + +netlab runs **IP Infusion OcNOS** as a [containerlab](clab.md)-provisioned device: a vrnetlab-packaged +OcNOS VM. Only the **clab** provider is supported (no Vagrant box). + +## Container image + +* Build the vrnetlab OcNOS container from the OcNOS `qcow2` using vrnetlab's `ipinfusion_ocnos` kind. +* Tag it `vrnetlab/ipinfusion_ocnos:`. Verified against **7.0.0-262** (and 6.5.2-101). +* The device sets clab `kind: ipinfusion_ocnos`; point it at your image: + +``` +defaults.devices.ocnos.clab.image: vrnetlab/ipinfusion_ocnos:7.0.0-262 +``` + +## Configuration deployment + +OcNOS configuration is pushed with the **`ipinfusion.ocnos` Ansible collection** over `network_cli` +(the collection drives the interactive `cmlsh` shell). Install it once: + +``` +ansible-galaxy collection install ipinfusion.ocnos +``` + +The same collection is used to read device state during `netlab validate` (see the validation +note below). Device settings: `interface_name eth{ifindex}`, `mgmt_if eth0`, loopbacks +`lo`/`loopbackN`. + +## Supported configuration modules + +`initial`, `ospf` (+areas/NSSA), `bgp` (+plugins/policy/multihop), `isis`, `vrf` (+isis), `vlan`, +`lag` (+passive), `gateway`, `dhcp`/relay, `stp`, `mpls`, `sr` (SRGB), `vxlan`, `evpn` (MPLS), `bfd`, +`gre`. See `netsim/devices/ocnos.yml` `features:` for the authoritative list; support level is +**best-effort** (see `docs/caveats.md`). + +## Validation with the `ansible` action (`netlab validate`) + +OcNOS's `ocnos` user has a **restricted shell**: it drops into `cmlsh` only on an *interactive* +login. `ssh ocnos@node "show ..."` (and every `cmlsh -e/-c` variant) returns ``Try `cmlsh --help'`` -- +there is **no non-interactive exec** -- and OcNOS emits CLI **text**, not JSON. netlab's SSH and +`docker exec` validation transports therefore cannot drive OcNOS show commands. + +OcNOS uses the **`ansible` validation action** instead -- a validation data *source* (a peer of +`netsim/cli/validate/suzieq.py`, implemented in `netsim/cli/validate/ansible.py`), **not** a +connection-method change (`netsim/cli/connect.py` is unchanged). A validation test selects it with +an `ansible` action or a validation-plugin `ansible_()` function that supplies the show +command; the device names the Ansible module to run it through in `netsim/devices/ocnos.yml`: + +``` +netlab_validate: + ansible_module: ipinfusion.ocnos.ocnos_command +``` + +`netlab validate` then runs the show command through `ipinfusion.ocnos.ocnos_command` against the +netlab-generated inventory. The result is parsed as JSON when the command emits it, otherwise the +CLI text is returned in `stdout`; the OcNOS validators (`netsim/validate//ocnos.py`, +re-exported from `netsim/validate/ocnos.py`) screen-scrape that text. The action is generic -- any +device whose CLI lacks a non-interactive SSH exec can opt in the same way. + +Run the standard integration suite against an OcNOS device under test: + +``` +export NETLAB_DEVICE=ocnos NETLAB_PROVIDER=clab +netlab up tests/integration/ospf/ospfv2/01-network.yml +netlab validate +``` + +Verified live (vrnetlab `ipinfusion_ocnos:7.0.0-262`, FRR probes): the OSPF, BGP and IS-IS +integration tests pass with native `netlab validate`, and DUT-side neighbor/prefix checks pass +over the Ansible transport. + +## Validated modules (native `netlab validate`) + +Live-verified against vrnetlab `ipinfusion_ocnos:7.0.0-262` with FRR / cEOS / Linux probes, +using the `ansible` validation action described above. "Probe" = the check runs on the +adjacent probe (interop); "DUT" = the check runs on the OcNOS device via the `ansible` action. + +| Module | Integration test | Result | +|---|---|---| +| ospf (v2) | `ospf/ospfv2/01-network` | PASS 4/4 (probe) + 3/3 (DUT: neighbor Full, route present) | +| bgp | `bgp/01-ebgp-session` | PASS 3/3 (probe) + 3/3 (DUT: sessions Established, prefix present) | +| isis | `isis/01-ipv4` | PASS 5/5 (probe) + 2/2 (DUT: adjacency L1, prefix present) — needed the `dynamic-hostname` fix | +| vlan | `vlan/01-vlan-bridge-single` | PASS 1/1 (host-to-host ping across the bridge) | +| lag | `lag/01-l3-lag` | PASS (LAG active on both EOS probes + IPv4 ping; one warning-level path-MTU check) | +| stp | `stp/01-stp-priority` | PASS 2/2 (link forwarding + root-bridge priority) — needed the bridge-priority fix | +| gateway | `gateway/02-vrrp` | IPv4 VRRP fully green (VIP ping, backup/master/preempt). IPv6 VRRP control-plane green (master election + backup/master/preempt) + steady-state datapath — needed the IPv6-VRRP fix. See exception for the v6-transit-on-failover gap. | +| vrf | `vrf/11-multi-vrf-ospf` | Single-area VRF fully green (per-VRF adjacency, routes, ping, inter-VRF isolation). See exception below for the multi-area sub-case. | + +Three config-completeness fixes came out of this pass: IS-IS `dynamic-hostname` (peers can map the +DUT system-id to a name), the STP customer-bridge `priority` (was never rendered), and IPv6 VRRP +(the gateway template rendered IPv4 VRRP only; VRRPv3 needs a link-local primary virtual-ipv6). + +## Documented exceptions + +A "full" device may ship with clearly-documented exceptions; these are recorded rather than faked. + +* **Multi-area OSPF inside a VRF** (`vrf/11` blue sub-case). OcNOS is a strict (Cisco-type) ABR: it + will not originate inter-area type-3 summaries when its backbone (area 0) is *inactive* — here the + VRF's only area-0 interface is a stub loopback, so two non-backbone areas connected only through the + DUT do not exchange routes. (OSPF-in-VRF also defaults to MPLS-VPN "superbackbone" mode; + `capability vrf-lite` clears that but not the inactive-backbone rule.) FRR/EOS are lenient ABRs and + summarize anyway. Single-area VRF OSPF is unaffected and passes. +* **gateway / VRRP — IPv6 transit forwarding on failover.** The module boots and validates on stock + clab (an earlier boot failure was a bug in a local `clab-render-mtu` change, not netlab core — MTU + is handled device-side). IPv4 VRRP is fully green; IPv6 VRRP is now configured (VRRPv3, link-local + primary virtual address) and control-plane-verified — the DUT wins/holds master and the probe sees + correct backup/master/preempt transitions, and the v6 datapath pings in steady state. The remaining + gap: after the VRRP peer's LAN link drops, IPv6 *transit* forwarding through the DUT fails even + though it is master and the client's neighbor cache holds the virtual MAC — an OcNOS v6-VRRP + failover-forwarding edge (IPv4 failover is unaffected). +* **dhcp relay** — the 3-piece OcNOS relay config generates and parser-checks correctly, but the + `dhcp/11-ipv4-relay` integration test needs a libvirt-based `dnsmasq` server probe, unavailable + on a clab-only host, so the end-to-end relay datapath is not covered by the integration suite. +* **EVPN datapath**, **GRE tunnel line-protocol**, **VRF-bound-interface ingress**, and + **per-VRF OSPFv3 + IPv6 VRF route-leak** — tracked platform/image limitations; config + generation is present where applicable. diff --git a/docs/platforms.md b/docs/platforms.md index 99aecdd5ef..abd26a3710 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -33,6 +33,7 @@ | Fortinet FortiOS [❗](caveats-fortios) | fortios | minimal | | FRRouting (FRR) [❗](caveats-frr) | frr | full | | [Generic Linux host](generic-linux-devices) | linux | full | +| IP Infusion OcNOS [❗](caveats-ocnos) | ocnos | best effort | | Juniper cRPD | crpd | full | | Juniper cSRX [❗](caveats-csrx) | csrx | minimal | | Juniper vMX [❗](caveats-vmx) | vmx | best effort | @@ -130,6 +131,7 @@ You cannot use all supported network devices with all virtualization providers. | Fortinet FortiOS | [✅](build-fortios) | ✅ | | FRR | [✅](build-frr)[❗](caveats-frr) | ✅ | | Generic Linux (Ubuntu/Alpine) | [❗](labs/linux.md) | ✅ | +| IP Infusion OcNOS | ❌ | ✅[❗](clab-vrnetlab) | | Juniper cRPD | ❌ | ✅ | | Juniper cSRX | ❌ | ✅ | | Juniper vMX | ❌ | ✅[❗](clab-vrnetlab) | @@ -202,6 +204,7 @@ Ansible playbooks included with **netlab** can deploy and collect device configu | Fortinet FortiOS | ✅ | ✅ | | FRR | ✅ [❗](caveats-frr) | ✅[❗](caveats-frr) | | Generic Linux | ✅ | ❌ | +| IP Infusion OcNOS [❗](caveats-ocnos) | ✅ | ✅ | | Juniper cSRX | ✅ | ❌ | | Junos[^Junos] | ✅ | ✅ | | Mikrotik RouterOS 6 | ✅ | ✅ | @@ -288,6 +291,7 @@ The following system-wide features are configured on supported network operating | Fortinet FortiOS | ✅ | ❌ | ✅ | ✅ | ✅ | | FRR | ✅ | ✅[^HIF] | ❌ | ✅ | ✅ | | Generic Linux | ✅ | ✅[^HIF] | ✅[❗](linux-lldp) | ✅ | ✅ | +| IP Infusion OcNOS | ✅ | ❌ | ✅ | ✅ | ✅ | | Juniper cSRX | ✅ | ✅ | ❌ | ❌ | ❌ | | Junos[^Junos] | ✅ | ❌ | ✅ | ✅ | ✅ | | Mikrotik RouterOS 6 | ✅ | ✅ | ✅[❗](caveats-routeros6) | ✅ | ✅ | @@ -320,6 +324,7 @@ The following interface parameters are configured on supported network operating | Fortinet FortiOS | ✅ | ✅ | ✅[❗](caveats-fortios) | ❌ | | FRR | ✅ | ✅ | ✅ | ✅ | | Generic Linux | ❌ | ❌ | ✅ | ❌ | +| IP Infusion OcNOS | ✅ | ❌ | ✅ | ✅ | | Juniper cSRX | ✅ | ❌ | ✅ | ❌ | | Junos[^Junos] | ✅ | ✅ | ✅ | ❌ | | Mikrotik RouterOS 6 | ✅ | ❌ | ✅ | ❌ | @@ -349,6 +354,7 @@ The following interface addresses are supported on various platforms; most daemo | Fortinet FortiOS | ✅ | ✅ | ❌ | ❌ | | FRR | ✅ | ✅ | ✅ | ✅ | | Generic Linux | ✅ | ✅ | ❌ | ❌ | +| IP Infusion OcNOS | ✅ | ✅ | ❌ | ❌ | | Juniper cSRX | ✅ | ✅ | ❌ | ❌ | | Junos[^Junos] | ✅ | ✅ | ✅ | ❌ | | Mikrotik RouterOS 6 | ✅ | ✅ | ❌ | ❌ | @@ -397,6 +403,7 @@ Routing protocol [configuration modules](module-reference.md) are supported on t | Extreme Networks EXOS | ✅ | ❌ | ❌ | ❌ | ❌ | | Fortinet FortiOS | ✅ [❗](caveats-fortios) | ❌ | ❌ | ✅ | ❌ | | FRR | ✅ | ✅ | ❌ | ✅ | ✅ | +| IP Infusion OcNOS [❗](caveats-ocnos) | ✅ | ✅ | ❌ | ✅ | ✅ | | Junos[^Junos] | ✅ | ✅ | ❌ | ✅ | ❌ | | Mikrotik RouterOS 6 | ✅ | ❌ | ❌ | ✅ | ❌ | | Mikrotik RouterOS 7 | ✅ | ❌ | ❌ | ✅ | ❌ | @@ -421,6 +428,7 @@ These devices support additional control-plane protocols or BGP address families | Dell OS10 | ✅ | ✅ | ❌ | ❌ | | Extreme Networks EXOS | ❌ | ❌ | ❌ | ✅ | | FRR | ✅ | ✅ | ✅ | ❌ | +| IP Infusion OcNOS [❗](caveats-ocnos) | ✅ | ✅ | ✅ | ✅ | | Juniper cRPD | ✅ | ❌ | ✅ | ❌ | | Juniper vMX | ✅ | ❌ | ✅ | ✅ | | Juniper vPTX | ✅ | ✅ | ✅ | ✅ | @@ -449,6 +457,7 @@ The layer-2 control plane [configuration modules](module-reference.md) are suppo | Dell OS10 | ✅ | ✅ | | dnsmasq | ❌ | ✅ | | FRR | ✅ | ✅ | +| IP Infusion OcNOS [❗](caveats-ocnos) | ✅ | ✅ | | Linux | ❌ | ✅ | (platform-dataplane-support)= @@ -471,6 +480,7 @@ The data plane [configuration modules](module-reference.md) are supported on the | Dell OS10 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | Extreme Networks EXOS | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | FRR | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| IP Infusion OcNOS [❗](caveats-ocnos) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Juniper cRPD | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | | Juniper vMX | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | | Juniper vPTX | ✅ | ✅ | ✅ [❗](caveats-vptx) | ✅ | ✅ | ❌ | @@ -518,6 +528,7 @@ Core *netlab* functionality and all multi-protocol routing protocol configuratio | Extreme Networks EXOS | ✅ | ❌ | ❌ | ❌ | ❌ | | Fortinet FortiOS | ✅ | ❌ | ❌ | ✅ | ❌ | | FRR | ✅ | ✅ | ❌ | ✅ | ❌ | +| IP Infusion OcNOS [❗](caveats-ocnos) | ✅ | ✅ | ❌ | ✅ | ❌ | | Junos[^Junos] | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | | Mikrotik RouterOS 6 | ❌ | ❌ | ❌ | ✅ | ❌ | | Mikrotik RouterOS 7 | ✅ | ❌ | ❌ | ✅ | ❌ | diff --git a/docs/release/26.07.md b/docs/release/26.07.md index 77e6c186ef..74867faeb8 100644 --- a/docs/release/26.07.md +++ b/docs/release/26.07.md @@ -14,11 +14,13 @@ * The [**WireGuard tunnel** plugin](plugin-tunnel-wireguard) supports WireGuard tunnels on FRR. * The [**bgp.session** plugin](plugin-bgp-session) and the [OSPF module](module-ospf) support graceful restart on Arista EOS, BIRD, FortiOS, and FRR * The [**bgp.policy** plugin](plugin-bgp-policy) supports the **bgp.role** attribute on FRR and BIRD. +* [IP Infusion OcNOS](https://www.ipinfusion.com/products/ocnos/) is now supported (best-effort) as a containerlab node, with configuration deployment and validation through the **ipinfusion.ocnos** Ansible collection. **Minor changes and improvements** * The **[netlab status](netlab-status)** command displays IPv6 management addresses. * Validation plugins can check BFD state in BGP neighbor details, use platform-independent parameters, and mark expected failures. +* The new **ansible** validation action lets `netlab validate` fetch device state through an Ansible module, for platforms whose CLI cannot run non-interactive SSH commands (for example, IP Infusion OcNOS). * Configuration templates can use directories in the `package:extra` template search path. * Filter static route node next hops by link name diff --git a/netsim/ansible/tasks/deploy-config/ocnos.yml b/netsim/ansible/tasks/deploy-config/ocnos.yml new file mode 100644 index 0000000000..2b7e732e6f --- /dev/null +++ b/netsim/ansible/tasks/deploy-config/ocnos.yml @@ -0,0 +1,17 @@ +--- +# netlab config-deploy task for OcNOS. Pushes the rendered config file +# (config_template) to the node using the ipinfusion.ocnos Ansible collection over +# ansible.netcommon.network_cli -- OcNOS's cmlsh restricted shell has no +# non-interactive exec mode, so raw SSH command push (paramiko/netmiko) does not +# work; this collection ships the cliconf/terminal plugins that drive cmlsh +# interactively. Requires: ansible-galaxy collection install ipinfusion.ocnos +- name: "ocnos_config: deploying {{ netsim_action | default('config') }} from {{ config_template }}" + ipinfusion.ocnos.ocnos_config: + src: "{{ config_template }}" + # match: none -- send the rendered file verbatim in order. The default + # line-matching skips lines already present in running-config, including + # mode-exit lines like exit-address-family, which breaks mode tracking on + # re-deploys. Our templates are written to be re-runnable, so verbatim is + # correct. + match: none + tags: [ print_action, always ] diff --git a/netsim/ansible/tasks/fetch-config/ocnos.yml b/netsim/ansible/tasks/fetch-config/ocnos.yml new file mode 100644 index 0000000000..b15a9e9a29 --- /dev/null +++ b/netsim/ansible/tasks/fetch-config/ocnos.yml @@ -0,0 +1,7 @@ +# fetch OcNOS running configuration for 'netlab collect', using the +# ipinfusion.ocnos_facts module (network_cli / cmlsh) -- raw SSH command exec +# ("show running-config" over paramiko) does not work against OcNOS's restricted +# cmlsh shell, see tasks/deploy-config/ocnos.yml. +--- +- ipinfusion.ocnos.ocnos_facts: + gather_subset: config diff --git a/netsim/ansible/tasks/readiness-check/ocnos.yml b/netsim/ansible/tasks/readiness-check/ocnos.yml new file mode 100644 index 0000000000..5b8c97894b --- /dev/null +++ b/netsim/ansible/tasks/readiness-check/ocnos.yml @@ -0,0 +1,17 @@ +# +# OcNOS device readiness check (triggered by netlab_ready: [ ansible ] in devices/ocnos.yml). +# +# OcNOS CLI (cmlsh) has no non-interactive exec mode, so the generic raw-SSH readiness probe +# (tasks/readiness-check/ssh.yml runs `ssh node "show version"`, which cmlsh rejects) never +# succeeds. Instead probe over the ipinfusion.ocnos connection -- the same transport config +# deploy uses -- retrying until the management CLI answers. The vrnetlab OcNOS VM takes ~9 min +# to boot, so the bound is netlab_check_retries x netlab_check_delay (default 60 x 12 = 720s). +# +--- +- name: "Wait for OcNOS management CLI to become ready ({{ inventory_hostname }})" + ipinfusion.ocnos.ocnos_command: + commands: [ show version ] + register: _ocnos_ready + until: _ocnos_ready is succeeded + retries: "{{ netlab_check_retries | default(60) }}" + delay: "{{ netlab_check_delay | default(12) }}" diff --git a/netsim/ansible/templates/bfd/ocnos.j2 b/netsim/ansible/templates/bfd/ocnos.j2 new file mode 100644 index 0000000000..fa8aefb230 --- /dev/null +++ b/netsim/ansible/templates/bfd/ocnos.j2 @@ -0,0 +1,15 @@ +{# + OcNOS BFD template. + OKF-verified syntax: per-interface `bfd interval <3-999> minrx <3-999> multiplier + <3-50>` (note `minrx`, not IOS's `min_rx`; max 999ms). Protocol enablement is + rendered by the protocol templates: `bfd all-interfaces` under router ospf/isis, + `neighbor fall-over bfd` under router bgp. +#} +{% for l in netlab_interfaces|default([]) if (bfd|default(False) or l.bfd|default(False)) and l.type != 'loopback' %} +{% if not (l.bfd is defined and not l.bfd) %} +{% set link_bfd = l.bfd|default({}) %} +interface {{ l.ifname }} + bfd interval {{ [link_bfd.min_tx|default(bfd.min_tx)|default(500), 999]|min }} minrx {{ [link_bfd.min_rx|default(bfd.min_rx)|default(500), 999]|min }} multiplier {{ link_bfd.multiplier|default(bfd.multiplier)|default(3) }} +! +{% endif %} +{% endfor %} diff --git a/netsim/ansible/templates/bgp/ocnos.j2 b/netsim/ansible/templates/bgp/ocnos.j2 new file mode 100644 index 0000000000..315fcf9929 --- /dev/null +++ b/netsim/ansible/templates/bgp/ocnos.j2 @@ -0,0 +1,86 @@ +{# + OcNOS BGP config template + netlab data model: node.bgp {as, router_id, neighbors[], ipv4, ipv6, advertise_loopback, + advertise[]}; each neighbor {name, as, type(ibgp/ebgp), ipv4|ipv6 (string or True), + activate.{af}, next_hop_self}. OcNOS syntax verified vs demos/bisbee/20-target-ocnos.cfg: + per-neighbor `neighbor remote-as`, `update-source lo` for iBGP, and + `address-family unicast` / `exit-address-family` blocks with `neighbor activate`. + No whitespace-strip tags near "!" (OcNOS comment marker). + + Route reflector: node.bgp.rr + bgp.rr_cluster_id -> + `bgp cluster-id`; per-neighbor n.rr_client -> `neighbor route-reflector-client` + (address-family scoped, like send-community/next-hop-self). Live-verified against + tests/integration/bgp/03-ibgp-rr.yml on vrnetlab/ipinfusion_ocnos:7.0.0-262: reflected + route, correct next hop, and RR cluster-id all present in the client's BGP table. +#} +{% if bgp.as is defined %} +{# bgp.originate: OcNOS advertises a only if it is in the RIB, so back each + originated prefix with a Null0 discard route #} +{% for pfx in bgp.originate|default([]) %} +ip route {{ pfx }} Null0 +{% endfor %} +router bgp {{ bgp.as }} +{% if bgp.router_id is defined %} + bgp router-id {{ bgp.router_id }} +{% endif %} +{% if bgp.rr|default(False) and bgp.rr_cluster_id|default(False) %} + bgp cluster-id {{ bgp.rr_cluster_id }} +{% endif %} +{% for n in bgp.neighbors | default([]) %} +{% for af in ['ipv4','ipv6'] if n[af] is defined and n[af] is string %} + neighbor {{ n[af] }} remote-as {{ n.as }} +{% if n.local_as is defined %} + neighbor {{ n[af] }} local-as {{ n.local_as }} +{% endif %} +{% if n.bfd|default(False) %} + neighbor {{ n[af] }} fall-over bfd +{% endif %} +{% if n.name is defined %} + neighbor {{ n[af] }} description {{ n.name }} +{% endif %} +{% if n.type == 'ibgp' %} + neighbor {{ n[af] }} update-source {{ loopback.ifname | default('lo') }} +{% endif %} +{% endfor %} +{% endfor %} +{% for af in ['ipv4','ipv6'] if bgp[af] is defined %} + ! + address-family {{ af }} unicast +{% if af == 'ipv4' %} +{% for s_proto in bgp.import|default({}) %} + redistribute {{ s_proto }} +{% endfor %} +{% endif %} +{# bgp.advertise is the consolidated origination list — it already includes the + loopback prefix when advertise_loopback is set, so don't emit it twice. #} +{% for pfx in bgp.advertise | default([]) if af in pfx %} + network {{ pfx[af] }} +{% endfor %} +{% for n in bgp.neighbors | default([]) if n[af] is defined and n[af] is string %} + neighbor {{ n[af] }} activate +{% if n.type in bgp.community|default({}) %} +{% set c_list = bgp.community[n.type] %} +{# send-community is an ADDRESS-FAMILY command on OcNOS (rejected at router level). + OcNOS enables communities by default and re-enabling errors ("%% already + enabled") while the `no` form is silently idempotent -> delete-then-add, + the same converging pattern as the VRRP disable/enable wrap #} + no neighbor {{ n[af] }} send-community both +{% if 'standard' in c_list and 'extended' in c_list %} + neighbor {{ n[af] }} send-community both +{% else %} +{% for c_type in c_list %} + neighbor {{ n[af] }} send-community {{ c_type }} +{% endfor %} +{% endif %} +{% endif %} +{% if n.next_hop_self | default(false) %} + neighbor {{ n[af] }} next-hop-self +{% endif %} +{% if n.rr_client | default(false) %} + neighbor {{ n[af] }} route-reflector-client +{% endif %} +{% endfor %} + exit-address-family +{% endfor %} +! +{% endif %} diff --git a/netsim/ansible/templates/dhcp/ocnos.j2 b/netsim/ansible/templates/dhcp/ocnos.j2 new file mode 100644 index 0000000000..224bf1739d --- /dev/null +++ b/netsim/ansible/templates/dhcp/ocnos.j2 @@ -0,0 +1,77 @@ +{# + OcNOS DHCP template (relay + client; VRF/L3VPN relay). + OcNOS 7.0 relay is parser-probed as GROUP/GLOBAL based (the per-interface + `ip dhcp relay address` form in older OKF prose is NOT in this parser): + - global `ip dhcp relay address ` registers the relay server + - the CLIENT-facing interface needs bare `ip dhcp relay` (the option; + OKF system-management relay-agent example) — gi-addr/server-select/uplink are extras. + Client: interface `ip address dhcp` (OKF-verified form). +#} +{# VRF-scoped relay (DHCP relay over L3VPN): a client-facing relay interface + that's also a VRF member renders inside `ip vrf ` instead of the global form -- + OKF: pe1-dhcp-relay-agent.md (`ip vrf X / ip dhcp relay address / ip dhcp relay + uplink l3vpn`). No interface-level uplink search is needed here: BGP VPNv4 + reachability across the L3VPN core replaces the directly-connected "uplink" concept + the global form relies on. #} +{% set vrf_seen = [] %} +{% for l in netlab_interfaces if l.dhcp.relay.ipv4 is defined and l.vrf is defined %} +{% if l.vrf not in vrf_seen %} +{{ vrf_seen.append(l.vrf) or "" }} +ip vrf {{ l.vrf }} +{% for s in l.dhcp.relay.ipv4|unique %} + ip dhcp relay address {{ s }} +{% endfor %} + ip dhcp relay uplink l3vpn +! +{% endif %} +interface {{ l.ifname }} + ip dhcp relay +! +{% endfor %} +{% set relay_targets = [] %} +{% for l in netlab_interfaces if l.dhcp.relay.ipv4 is defined and l.vrf is not defined %} +{% for s in l.dhcp.relay.ipv4 %}{{ relay_targets.append(s) or "" }}{% endfor %} +{% endfor %} +{% if relay_targets %} +{% for s in relay_targets|unique %} +ip dhcp relay address {{ s }} +{% endfor %} +{% endif %} +{% for l in netlab_interfaces if l.dhcp.relay.ipv4 is defined and l.vrf is not defined %} +interface {{ l.ifname }} + ip dhcp relay +! +{% endfor %} +{# the SERVER-facing interface needs `ip dhcp relay uplink` (live-verified: without it + the relay silently drops client DISCOVERs — counters stay 0). Mark every L3 + interface whose subnet contains a relay target (directly-connected server); for a + ROUTED server none matches, so fall back to marking all non-client L3 interfaces. #} +{% if relay_targets %} +{% set ns = namespace(found=false) %} +{% for l in netlab_interfaces if l.ipv4 is defined and l.ipv4 is string and l.dhcp.relay.ipv4 is not defined and l.type != 'loopback' %} +{% set lpfx = l.ipv4 | ansible.utils.ipaddr('prefix') %} +{% set lnet = l.ipv4 | ansible.utils.ipaddr('network') %} +{% set hits = [] %} +{% for s in relay_targets if (s ~ '/' ~ lpfx) | ansible.utils.ipaddr('network') == lnet %} +{{ hits.append(1) or "" -}} +{% endfor %} +{% if hits %} +{% set ns.found = true %} +interface {{ l.ifname }} + ip dhcp relay uplink +! +{% endif %} +{% endfor %} +{% if not ns.found %} +{% for l in netlab_interfaces if l.ipv4 is defined and l.ipv4 is string and l.dhcp.relay.ipv4 is not defined and l.type != 'loopback' %} +interface {{ l.ifname }} + ip dhcp relay uplink +! +{% endfor %} +{% endif %} +{% endif %} +{% for l in netlab_interfaces if l.dhcp.client.ipv4|default(false) %} +interface {{ l.ifname }} + ip address dhcp +! +{% endfor %} diff --git a/netsim/ansible/templates/evpn/ocnos.j2 b/netsim/ansible/templates/evpn/ocnos.j2 new file mode 100644 index 0000000000..d437189821 --- /dev/null +++ b/netsim/ansible/templates/evpn/ocnos.j2 @@ -0,0 +1,40 @@ +{# + OcNOS EVPN template (EVPN-VXLAN control plane). + Per-VNI EVPN instance = `mac vrf ` with rd/route-targets (OKF); BGP gets + `address-family l2vpn evpn` with per-neighbor activate. +#} +{% if evpn.transport|default("vxlan") == "mpls" %} +{# --- EVPN-MPLS control plane: LDP/BGP-EVPN L2 EVI over MPLS. + Control-plane VERIFIED live (type-3 + network port up); the untagged-host AC + datapath is a confirmed access-model gap -- see OKF + blocker issue. --- #} +evpn mpls enable +evpn mpls vtep-ip-global {{ loopback.ipv4.split("/")[0] }} +! +{% for vname, v in vlans.items() if v.evpn.evi is defined %} +mac vrf {{ vname }} + rd {{ v.evpn.rd }} + route-target both {{ bgp.as }}:{{ v.evpn.evi }} + evpn-vlan-service vlan-based +! +evpn mpls id {{ v.evpn.evi }} + host-reachability-protocol evpn-bgp {{ vname }} +! +{% endfor %} +{# AC binding: untagged L2 subinterface per access port (control-plane binds; datapath gap) #} +{% for l in netlab_interfaces|default([]) if l.vlan.access is defined and vlans[l.vlan.access].evpn.evi is defined %} +interface {{ l.ifname }}.{{ l.vlan.access_id }} switchport + encapsulation untagged + access-if-evpn + map vpn-id {{ vlans[l.vlan.access].evpn.evi }} +! +{% endfor %} +{% endif %} +{% if bgp.as is defined %} +router bgp {{ bgp.as }} + address-family l2vpn evpn +{% for n in bgp.neighbors|default([]) if n.evpn|default(False) %} + neighbor {{ n.ipv4 }} activate +{% endfor %} + exit-address-family +! +{% endif %} diff --git a/netsim/ansible/templates/gateway/ocnos.j2 b/netsim/ansible/templates/gateway/ocnos.j2 new file mode 100644 index 0000000000..12f25f5610 --- /dev/null +++ b/netsim/ansible/templates/gateway/ocnos.j2 @@ -0,0 +1,42 @@ +{# + OcNOS first-hop gateway template (VRRP only). + OKF-verified block: `router vrrp ` -> `virtual-ip A.B.C.D` + + `priority ` + `enable`. (v2-compatible via the vrrp.version plugin; authentication not modeled; + preempt is the OcNOS default.) +#} +{% for l in netlab_interfaces if l.gateway.protocol|default('') == 'vrrp' and l.gateway.ipv4 is defined %} +router vrrp {{ l.gateway.vrrp.group|default(1) }} {{ l.ifname }} +{# NOTE: on a live instance, modifying params errors '%% You must disable this + session first' — the test runner deploys fresh (down/up) so no `disable` is + rendered (it fails on a brand-new instance: 'enable configuration not found'). + Re-deploy-without-down convergence of param changes is a documented caveat. #} + virtual-ip {{ l.gateway.ipv4 | ansible.utils.ipaddr('address') }} +{% if l.gateway.vrrp.version|default(3) == 2 %} + v2-compatible +{% endif %} +{% if l.gateway.vrrp.priority is defined %} + priority {{ l.gateway.vrrp.priority }} +{% endif %} +{% if not l.gateway.vrrp.preempt|default(True) %} + preempt-mode false +{% endif %} + enable +! +{% endfor %} +{% for l in netlab_interfaces if l.gateway.protocol|default('') == 'vrrp' and l.gateway.ipv6 is defined %} +router ipv6 vrrp {{ l.gateway.vrrp.group|default(1) }} {{ l.ifname }} +{# IPv6 VRRP (VRRPv3) needs a link-local PRIMARY virtual address before the global one + -- OcNOS rejects the global alone (%% No Primary VRRP IP address exists). netlab + supplies the VRRP link-local as l.gateway.lla; list it first, then the global. There + is no `secondary` keyword (rejected) and no v2-compatible for v6. #} + virtual-ipv6 {{ l.gateway.vrrp.lla }} + virtual-ipv6 {{ l.gateway.ipv6 | ansible.utils.ipaddr('address') }} +{% if l.gateway.vrrp.priority is defined %} + priority {{ l.gateway.vrrp.priority }} +{% endif %} +{% if not l.gateway.vrrp.preempt|default(True) %} + preempt-mode false +{% endif %} + enable +! +{% endfor %} diff --git a/netsim/ansible/templates/initial/ocnos.j2 b/netsim/ansible/templates/initial/ocnos.j2 new file mode 100644 index 0000000000..65baf9e0a6 --- /dev/null +++ b/netsim/ansible/templates/initial/ocnos.j2 @@ -0,0 +1,106 @@ +{# + OcNOS initial (base) config template + Renders netlab's per-node data model into OcNOS 7.0 CLI. Follows the netlab template + structure (netlab.tools/dev/config/initial): static -> system -> loopback -> mgmt -> data. + Syntax verified against demos/bisbee/20-target-ocnos.cfg + OKF wiki/okf/ocnos-sp-7.0/. + NOTE: no whitespace-stripping comment tags near "!" separators (they are OcNOS comment + markers — a stripped newline would fold the next command into a comment). + LLDP: OcNOS global enable is `lldp run` (verified against the live 7.0.0 parser + OKF). + The mgmt interface is configured by clab/vrnetlab, so netlab doesn't touch it. +#} +lldp run +! +{# gNMI dial-in server (:9339) + OpenConfig model translation — enables the gNMI validator #} +feature streaming-telemetry vrf management +! +netconf translation openconfig +! +hostname {{ inventory_hostname }} +! +{% if vrfs is defined %} +{% for vname, vdata in vrfs.items() %} +ip vrf {{ vname }} +{% if vdata.rd is defined %} + rd {{ vdata.rd }} +{% endif %} +{% for rt in vdata.import | default([]) %} + route-target import {{ rt }} +{% endfor %} +{% for rt in vdata.export | default([]) %} + route-target export {{ rt }} +{% endfor %} +! +{% endfor %} +{% endif %} +{# VLAN (Customer Bridge): create the VLAN-aware bridge + the VLAN database. Physical + ports get their switchport membership in vlan/ocnos.j2; SVIs are configured below. #} +{% if vlans is defined %} +{% set bproto = {'stp':'ieee','rstp':'rstp','mstp':'mstp'}[stp.protocol] if stp.protocol is defined else 'ieee' %} +{# MSTP takes NO 'vlan-bridge' suffix (parser: bridge protocol mstp -> ring| only); it's a VLAN-aware bridge by nature. ieee/rstp need the suffix. #} +bridge 1 protocol {{ bproto }}{{ ' vlan-bridge' if bproto != 'mstp' else '' }} +! +vlan database +{% for vname, vdata in vlans.items() %} + vlan {{ vdata.id }} bridge 1 state enable +{% endfor %} +! +{% endif %} +{% if loopback is defined %} +interface {{ loopback.ifname | default('lo') }} +{% if loopback.ipv4 is defined and loopback.ipv4 is string %} +{# the kernel "lo" owns 127.0.0.1 (address must be secondary); numbered loopbacks take primaries #} + ip address {{ loopback.ipv4 }}{{ ' secondary' if loopback.ifname | default('lo') == 'lo' else '' }} +{% endif %} +{% if loopback.ipv6 is defined and loopback.ipv6 is string %} + ipv6 address {{ loopback.ipv6 }} +{% endif %} +! +{% endif %} +{% for l in netlab_interfaces | default([]) if l.type | default('') == 'loopback' and l.ifname != loopback.ifname | default('lo') %} +interface {{ l.ifname }} +{% if l.name is defined %} + description {{ l.name }} +{% endif %} +{% if l.ipv4 is defined and l.ipv4 is string %} + ip address {{ l.ipv4 }} +{% endif %} +{% if l.ipv6 is defined and l.ipv6 is string %} + ipv6 address {{ l.ipv6 }} +{% endif %} +! +{% endfor %} +{% for l in netlab_interfaces | default([]) if l.type | default('') != 'loopback' %} +interface {{ l.ifname }} +{% if l.name is defined %} + description {{ l.name }}{{ " [" + l.role + "]" if l.role is defined else "" }} +{% endif %} +{% if l.type | default('') == 'lag' and l.vlan.access is defined and vlans is defined and vlans[l.vlan.access].vni is defined %} +{# L2/L3 mode can't be set on an aggregator once it has members ("cannot be explicitly + configured on aggregator interfaces with member") -- a VNI-mapped ES-LAG (evpn.multihoming) must become a switchport HERE, before lag module adds channel-group members #} + switchport +{% endif %} +{% if l.mtu is defined and not l.virtual_interface | default(false) and l.lag._parentindex is not defined %} +{# LAG members refuse interface-level config ("%% Cannot configure on LAG member"); + the aggregate po carries the MTU #} + mtu {{ l.mtu }} +{% endif %} +{# VRF membership must be set BEFORE the IP address (OcNOS clears the address otherwise) #} +{% if l.vrf is defined %} + ip vrf forwarding {{ l.vrf }} +{% endif %} +{% set _irbgw = l.vlan.mode|default('') == 'irb' and l.vrf is defined and vrfs is defined and vrfs[l.vrf].evpn.transit_vni is defined %} +{% if l.ipv4 is defined and l.ipv4 is string and '/' in l.ipv4 and not _irbgw %} + ip address {{ l.ipv4 }} +{% endif %} +{% if l.ipv6 is defined and l.ipv6 is string and '/' in l.ipv6 %} + ipv6 address {{ l.ipv6 }} +{% endif %} +{% if not l.virtual_interface | default(false) %} +{% if l.shutdown | default(false) %} + shutdown +{% else %} + no shutdown +{% endif %} +{% endif %} +! +{% endfor %} diff --git a/netsim/ansible/templates/isis/ocnos.j2 b/netsim/ansible/templates/isis/ocnos.j2 new file mode 100644 index 0000000000..d00d7194de --- /dev/null +++ b/netsim/ansible/templates/isis/ocnos.j2 @@ -0,0 +1,52 @@ +{# + OcNOS IS-IS config template + netlab data model: node.isis {instance (process tag), type (level-1/2/..), net, + af.ipv4/ipv6}; per-interface l.isis {network_type, passive}; loopback.isis {passive}. + OcNOS syntax verified vs demos/bisbee/20-target-ocnos.cfg: `router isis ` + `is-type` + + `net` + `metric-style wide`; interface `ip router isis ` + `isis network `. + No whitespace-strip tags near "!" (OcNOS comment marker). +#} +{% if isis.net is defined %} +router isis {{ isis.instance | default('1') }} +{% for s_proto in isis.import|default({}) %} + redistribute {{ s_proto }} level-2 +{% endfor %} +{% if isis.bfd.ipv4|default(False) or isis.bfd.ipv6|default(False) or isis.bfd|default(False) is true %} + bfd all-interfaces +{% endif %} + is-type {{ isis.type | default('level-2') }} + net {{ isis.net }} + metric-style wide + dynamic-hostname +{% if isis.af.ipv6 is defined %} + address-family ipv6 unicast + exit-address-family +{% endif %} +! +{% endif %} +{#- loopback: advertised via `ip router isis`; no neighbors -> no adjacency (no passive + keyword needed; OcNOS has no interface-level `isis passive`). -#} +{% if loopback.isis is defined %} +interface {{ loopback.ifname | default('lo') }} + ip router isis {{ isis.instance | default('1') }} +{% if loopback.ipv6 is defined and loopback.ipv6 is string %} + ipv6 router isis {{ isis.instance | default('1') }} +{% endif %} +! +{% endif %} +{% for l in netlab_interfaces if l.isis is defined and l.ifname != loopback.ifname|default('lo') %} +interface {{ l.ifname }} + ip router isis {{ isis.instance | default('1') }} +{% if l.ipv6 is defined and l.ipv6 is string %} + ipv6 router isis {{ isis.instance | default('1') }} +{% endif %} +{% if l.isis.network_type is defined %} + isis network {{ l.isis.network_type }} +{% endif %} +{% if l.isis.type is defined and l.isis.type != 'level-2' %} + isis circuit-type {{ l.isis.type }} +{% endif %} +{# Passive IS-IS interfaces are configured under `router isis` (passive-interface ), + not with an interface keyword; add when a passive-link topology needs it. #} +! +{% endfor %} diff --git a/netsim/ansible/templates/lag/ocnos.j2 b/netsim/ansible/templates/lag/ocnos.j2 new file mode 100644 index 0000000000..499ec5f6c7 --- /dev/null +++ b/netsim/ansible/templates/lag/ocnos.j2 @@ -0,0 +1,19 @@ +{# + OcNOS LAG template (LACP port-channels). + Parser-probed + OKF-verified syntax: member interface `channel-group mode + active|passive`; the aggregate is `po` (auto-created by `interface po`, which + the initial template already addresses). Static channels (`static-channel-group` -> + `sa`) use a DIFFERENT aggregate name and are not modeled — netlab lag.lacp=off + topologies would need lag_interface_name to switch patterns. +#} +{% for intf in interfaces if intf.type == 'lag' %} +{% for ch in interfaces if ch.lag._parentindex|default(None) == intf.lag.ifindex %} +interface {{ ch.ifname }} +{% if intf.lag.lacp|default('') == 'off' %} + static-channel-group {{ intf.lag.ifindex }} +{% else %} + channel-group {{ intf.lag.ifindex }} mode {{ 'passive' if intf.lag.lacp_mode|default('active') == 'passive' else 'active' }} +{% endif %} +! +{% endfor %} +{% endfor %} diff --git a/netsim/ansible/templates/mpls/ocnos.j2 b/netsim/ansible/templates/mpls/ocnos.j2 new file mode 100644 index 0000000000..5206d99cfe --- /dev/null +++ b/netsim/ansible/templates/mpls/ocnos.j2 @@ -0,0 +1,28 @@ +{# + OcNOS MPLS template (LDP + MPLS/VPN). + OKF-verified syntax (mpls guide): `router ldp` + `transport-address ipv4 `; + core interfaces need `label-switching` AND `enable-ldp ipv4`. + VPNv4: `address-family vpnv4` under router bgp + per-neighbor activate. +#} +{% if ldp is defined %} +router ldp +{% if loopback.ipv4 is defined and loopback.ipv4 is string %} + transport-address ipv4 {{ loopback.ipv4 | ansible.utils.ipaddr('address') }} +{% endif %} +! +{% for l in netlab_interfaces if 'ldp' in l and not l.ldp.passive|default(false) %} +interface {{ l.ifname }} + label-switching + enable-ldp ipv4 +! +{% endfor %} +{% endif %} +{% if mpls.vpn is defined and bgp.as is defined %} +router bgp {{ bgp.as }} + address-family vpnv4 +{% for n in bgp.neighbors|default([]) if n.vpnv4 is defined %} + neighbor {{ n.vpnv4 }} activate +{% endfor %} + exit-address-family +! +{% endif %} diff --git a/netsim/ansible/templates/ospf/ocnos.j2 b/netsim/ansible/templates/ospf/ocnos.j2 new file mode 100644 index 0000000000..430cb235ed --- /dev/null +++ b/netsim/ansible/templates/ospf/ocnos.j2 @@ -0,0 +1,123 @@ +{# + OcNOS OSPFv2 config template + netlab data model: node.ospf {process, router_id, af.ipv4}, per-interface l.ospf + {area, passive, network_type}, node.loopback.ospf. OcNOS uses network-statement style + (verified vs demos/bisbee/20-target-ocnos.cfg): `network area `, + `passive-interface enable`, and interface-level `ip ospf network `. + + OSPFv3 is a DIFFERENT config family: no network-statement style -- it's + per-interface enable, like IS-IS's `ip router isis`. OKF shows `router ipv6 ospf + []` / `router-id X.X.X.X`, then `interface X / ipv6 router ospf area ` + (layer-3/.../r1-124.md), but LIVE TESTING found the explicit tag is a trap: the + interface command always auto-appends `instance-id 0` (`show running-config` proves + it, regardless of what's typed), and that instance-id is NOT the same identifier as + the router-level tag -- `router ipv6 ospf 1` + `... area 0.0.0.0 instance-id 0` are + two DISCONNECTED contexts ("show ipv6 ospf" reports "Number of areas... 0" forever, + "show ipv6 ospf instance" says "No such process"). The router line MUST be bare + (`router ipv6 ospf`, no tag -> internally "(*null*)", which IS what instance-id 0 + correlates to) for the interface's area to actually attach to the process. + Also live-verified: on a P2P link the two neighbors stall at 2-Way/DROther forever + (no DR/BDR election completes cleanly) unless `ipv6 ospf network point-to-point` is + set too -- same fix OSPFv2 already applies via l.ospf.network_type, reused here. + + OPERATIONAL LANDMINE (do NOT try to self-heal this in the template -- three attempts + tried and reverted, see wt/ocnos-leftovers history): any OSPF (v2 or v3) + network-membership CHANGE makes "commit" reply with an informational notice -- + `Use "clear ip ospf process" command to take effect` / `%% Use clear ipv6 ospf + process command to take effect` -- and the `ocnos_config` ansible module treats ANY + non-empty commit response as fatal, aborting the whole ansible play for that host + (every module queued AFTER ospf -- isis, bgp, vrf -- silently never gets deployed). + Embedding `do clear ip/ipv6 ospf process` inside the same commit does NOT reliably + fix it: "!" is a COMMENT on OcNOS not a mode-exit (so `do clear` can fire while still + nested in "interface X", itself an error), and even with an explicit `exit` first, + `do clear` targets the RUNNING config while ocnos_config only calls the real `commit` + once at the very end -- so on a brand-new process `do clear` fails "OSPFv3 Routing + Process not enabled". Adding a manual mid-template `commit` just relocates the SAME + fatal-shaped notice earlier, netting nothing. The reliable fix is OPERATIONAL, not + templated: run `netlab initial` a SECOND time whenever OSPF v4 or v6 gets a NEW + network/area assignment for the first time on a node. Attempt 1 applies + commits + the ospf config (verified via running-config even though ansible reports the task + "failed") but skips isis/bgp/vrf for that host; attempt 2 re-sends the now-identical + ospf config (no new network-membership change -> no notice -> no failure) and lets + every subsequent module deploy normally. This is a one-time cost per fresh OSPF + network change, not a per-topology-boot cost. +#} +{% if ospf.af.ipv4 is defined %} +router ospf {{ ospf.process | default(1) }} +{% if ospf.router_id is defined %} + ospf router-id {{ ospf.router_id }} +{% endif %} +{% if ospf.bfd|default(False) %} + bfd all-interfaces +{% endif %} +{% if ospf.default is defined and ospf.default %} + default-information originate{{ ' always' if ospf.default.always is defined and ospf.default.always else '' }} +{% endif %} +{% if loopback.ospf is defined and loopback.ipv4 is defined and loopback.ipv4 is string %} + network {{ loopback.ipv4 }} area {{ loopback.ospf.area }} +{% endif %} +{% for l in netlab_interfaces if l.ospf is defined and l.ipv4 is defined and l.ipv4 is string and '/' in l.ipv4 and l.ifname != loopback.ifname|default('lo') %} + network {{ l.ipv4 }} area {{ l.ospf.area }} +{% endfor %} +{% if loopback.ospf is defined %} + passive-interface {{ loopback.ifname | default('lo') }} enable +{% endif %} +{% for l in netlab_interfaces if l.ospf is defined and l.ospf.passive | default(false) %} + passive-interface {{ l.ifname }} enable +{% endfor %} +! +{% endif %} +{% if ospf.af.ipv6 is defined %} +router ipv6 ospf +{% if ospf.router_id is defined %} + router-id {{ ospf.router_id }} +{% endif %} +! +{% endif %} +{% if loopback.ospf is defined and loopback.ipv6 is defined and loopback.ipv6 is string %} +interface {{ loopback.ifname | default('lo') }} + ipv6 router ospf area {{ loopback.ospf.area }} +! +{% endif %} +{% for l in netlab_interfaces if l.ospf is defined and l.ipv6 is defined and l.ipv6 is string and l.ifname != loopback.ifname|default('lo') %} +interface {{ l.ifname }} + ipv6 router ospf area {{ l.ospf.area }} +{% if l.ospf.network_type is defined %} + ipv6 ospf network {{ l.ospf.network_type }} +{% endif %} +! +{% endfor %} +{#- per-interface OSPFv2 attributes (network type, cost, timers, auth). Gated on the + interface actually having an IPv4 address so an IPv6-only (OSPFv3) topology does not + emit dead `ip ospf ...` lines for interfaces with no OSPFv2 process. -#} +{% for l in netlab_interfaces if l.ospf is defined and l.ipv4 is defined and l.ipv4 is string + and (l.ospf.network_type is defined + or l.ospf.cost is defined or l.ospf.timers is defined or l.ospf.priority is defined + or l.ospf.password is defined) %} +interface {{ l.ifname }} +{% if l.ospf.network_type is defined %} + ip ospf network {{ l.ospf.network_type }} +{% endif %} +{% if l.ospf.cost is defined %} + ip ospf cost {{ l.ospf.cost }} +{% endif %} +{% if l.ospf.timers.hello is defined %} + ip ospf hello-interval {{ l.ospf.timers.hello }} +{% endif %} +{% if l.ospf.timers.dead is defined %} + ip ospf dead-interval {{ l.ospf.timers.dead }} +{% endif %} +{% if l.ospf.priority is defined %} + ip ospf priority {{ l.ospf.priority }} +{% endif %} +{% if l.ospf.password is defined %} +{% if l.ospf.digest is defined %} + ip ospf message-digest-key {{ l.ospf.digest.id|default(1) }} md5 {{ l.ospf.password }} + ip ospf authentication message-digest +{% else %} + ip ospf authentication-key {{ l.ospf.password }} + ip ospf authentication +{% endif %} +{% endif %} +! +{% endfor %} diff --git a/netsim/ansible/templates/ripv2/ocnos.j2 b/netsim/ansible/templates/ripv2/ocnos.j2 new file mode 100644 index 0000000000..604c02bd7c --- /dev/null +++ b/netsim/ansible/templates/ripv2/ocnos.j2 @@ -0,0 +1,15 @@ +{# + OcNOS RIPv2 template. IOS-style: `router rip` + version 2 + + prefix-form network statements (parser-probed: router rip enters config-router). +#} +{% if ripv2.af.ipv4|default(True) %} +router rip + version 2 +{% for l in netlab_interfaces if 'ripv2' in l and l.ipv4 is defined and l.ipv4 is string %} + network {{ l.ipv4 }} +{% endfor %} +{% for l in netlab_interfaces if 'ripv2' in l and l.ripv2.passive|default(false) %} + passive-interface {{ l.ifname }} +{% endfor %} +! +{% endif %} diff --git a/netsim/ansible/templates/routing/ocnos.j2 b/netsim/ansible/templates/routing/ocnos.j2 new file mode 100644 index 0000000000..dc7bcbb2bd --- /dev/null +++ b/netsim/ansible/templates/routing/ocnos.j2 @@ -0,0 +1,47 @@ +{# + OcNOS generic-routing template (static routes, prefix-lists, route-maps, + as-path / community lists). + Syntax verified against the OKF (wiki/okf/ocnos-sp-7.0): OcNOS is IOS-family for the + policy objects (`ip prefix-list NAME seq N ...`, `route-map NAME permit N`, + `ip as-path access-list WORD ...`, `ip community-list standard|expanded WORD ...`) + but FRR-style for static routes (slash prefixes: `ip route 10.0.0.0/24 `). + The IOS macro includes are symlinked next to this template by install.sh. +#} +{% import '_route_map_ios.j2' as routemap with context %} +{% if routing.policy|default({}) %} +{% call(p_entry,af_list) routemap.create_route_maps(routing.policy) -%} +{{ routemap.common_route_map_entry(p_entry,af_list) }} +{%- endcall %} +{% endif %} +{% if routing.prefix|default({}) %} +{% include '_prefix_list_ios.j2' %} +{% endif %} +{% if routing.aspath|default({}) %} +{% for asp_name,asp_list in routing.aspath.items() %} +! +{% for asp_line in asp_list %} +ip as-path access-list {{ asp_name }} {{ asp_line.action }} {{ asp_line.path }} +{% endfor %} +{% endfor %} +{% endif %} +{% if routing.community|default({}) %} +{% for c_name,c_value in routing.community.items() %} +! +{% for c_line in c_value.value %} +ip community-list {{ c_value.cl_type }} {{ c_name }} {{ c_line.action }} {{ c_line._value }} +{% endfor %} +{% endfor %} +{% endif %} +{% if routing.static|default([]) %} +! +{% for sr_data in routing.static %} +{% set cmd_vrf = 'vrf ' + sr_data.vrf + ' ' if 'vrf' in sr_data else '' %} +{% for sr_af in ['ipv4','ipv6'] if sr_af in sr_data %} +{% if sr_data.nexthop.discard|default(false) %} +{{ 'ip' if sr_af == 'ipv4' else 'ipv6' }} route {{ cmd_vrf }}{{ sr_data[sr_af] }} Null0 +{% elif sr_data.nexthop[sr_af] is defined %} +{{ 'ip' if sr_af == 'ipv4' else 'ipv6' }} route {{ cmd_vrf }}{{ sr_data[sr_af] }} {{ sr_data.nexthop[sr_af] }} +{% endif %} +{% endfor %} +{% endfor %} +{% endif %} diff --git a/netsim/ansible/templates/sr/ocnos.j2 b/netsim/ansible/templates/sr/ocnos.j2 new file mode 100644 index 0000000000..4aa0405ac0 --- /dev/null +++ b/netsim/ansible/templates/sr/ocnos.j2 @@ -0,0 +1,24 @@ +{# + OcNOS Segment Routing (MPLS) template. + OKF-verified: `segment-routing global block ` + `segment-routing mpls` + under router isis; loopback `prefix-sid index `. +#} +{% if 'isis' in sr.protocol|default([]) %} +router isis {{ isis.instance | default('1') }} +{# OcNOS refuses segment-routing without TE on the level ("%% MPLS TE is not enabled + on this level") — OKF SR examples enable traffic-eng first #} +{% if loopback.ipv4 is defined and loopback.ipv4 is string %} + mpls traffic-eng router-id {{ loopback.ipv4 | ansible.utils.ipaddr('address') }} +{% endif %} + mpls traffic-eng {{ isis.type | default('level-2') }} +{% if sr.srgb.start is defined %} + isis segment-routing global block {{ sr.srgb.start }} {{ sr.srgb.start + sr.srgb.size|default(4000) - 1 }} +{% endif %} + segment-routing mpls +! +{% endif %} +{% if sr.node_sid.ipv4 is defined and loopback.ipv4 is defined %} +interface {{ loopback.ifname | default('lo') }} + prefix-sid index {{ sr.node_sid.ipv4 }} +! +{% endif %} diff --git a/netsim/ansible/templates/srv6/ocnos.j2 b/netsim/ansible/templates/srv6/ocnos.j2 new file mode 100644 index 0000000000..99d439b8be --- /dev/null +++ b/netsim/ansible/templates/srv6/ocnos.j2 @@ -0,0 +1,36 @@ +{# + OcNOS SRv6 template. ISIS-only for now (features.srv6.isis), matching + the ArcOS precedent (Arrcus is an SRv6 flagship vendor) and avoiding the OSPFv3 + tagged-process trap already found+fixed in templates/ospf/ocnos.j2 (a `router ipv6 + ospf ` + srv6 sub-mode combo is untested here and risks the same instance-id + mismatch). netlab data: srv6.locator (per-node /48 auto-allocated from the + srv6_locator pool), srv6.igp. + OKF-verified (segment-routing/.../isis-configuration-2.md + r1-configuration.md): + the locator is defined GLOBALLY under `segment-routing / srv6 / locators / locator + / prefix `, then ATTACHED to the IGP under `router isis / + address-family ipv6 / segment-routing srv6 / srv6-locator `. The device + auto-derives End/End.X SIDs from the locator once attached -- no manual prefix-sid + needed (`show segment-routing srv6 sid` is the verification command). +#} +{% if srv6.locator is defined %} +{% set locname = inventory_hostname ~ "-loc" %} +segment-routing + srv6 + locators + locator {{ locname }} + prefix {{ srv6.locator }} + exit-locator + exit-locators + exit-srv6 + exit +! +{% if 'isis' in srv6.igp|default([]) %} +router isis {{ isis.instance | default('1') }} + address-family ipv6 + segment-routing srv6 + srv6-locator {{ locname }} + exit-srv6 + exit +! +{% endif %} +{% endif %} diff --git a/netsim/ansible/templates/stp/ocnos.j2 b/netsim/ansible/templates/stp/ocnos.j2 new file mode 100644 index 0000000000..4d8aabe503 --- /dev/null +++ b/netsim/ansible/templates/stp/ocnos.j2 @@ -0,0 +1,22 @@ +{# + OcNOS STP template. Protocol selection (ieee/rstp/mstp) happens in + initial/ocnos.j2 (the Customer Bridge is created there). This renders per-port + options: edgeport for netlab port_type edge (parser-probed: spanning-tree + autoedge|bpdu-filter|bpdu-guard|edgeport ...). +#} +{# Bridge (CIST) priority for the customer bridge -- OcNOS: `bridge priority <0-61440>`, + steps of 4096. netlab sets node.stp.priority; the customer bridge is id 1 (initial/ocnos.j2). #} +{% if stp.priority is defined %} +bridge 1 priority {{ stp.priority }} +! +{% endif %} +{% for l in netlab_interfaces if l.stp.port_type|default('') == 'edge' %} +interface {{ l.ifname }} + spanning-tree edgeport +! +{% endfor %} +{% for l in netlab_interfaces if l.stp.enable is defined and not l.stp.enable %} +interface {{ l.ifname }} + spanning-tree disable +! +{% endfor %} diff --git a/netsim/ansible/templates/vlan/ocnos.j2 b/netsim/ansible/templates/vlan/ocnos.j2 new file mode 100644 index 0000000000..a56c72744e --- /dev/null +++ b/netsim/ansible/templates/vlan/ocnos.j2 @@ -0,0 +1,36 @@ +{# + OcNOS VLAN (Customer Bridge) — switchport config for physical access/trunk ports. + Bridge + VLAN database + SVIs are created in initial/ocnos.j2. Here we make each + VLAN-bearing physical interface a switchport on bridge 1 and set its access/trunk membership. + netlab model: interface l.vlan {access(name), access_id, trunk(names), trunk_id[]}. + OcNOS Customer-Bridge port syntax (OKF: netlab-verified-config.md). No strip tags near "!". +#} +{# vxlan-mapped access VLANs: the port becomes an NVO access-if (untagged) instead of + a customer-bridge switchport — mixing both trips "Invalid bridge mode" on commit #} +{% for l in netlab_interfaces | default([]) if l.type | default('') != 'lag' and l.vlan.access is defined and (vlans[l.vlan.access].vni is defined or vlans[l.vlan.access].evpn.evi is defined) %} +{# ...but the untagged access-if DEPENDS on enable-switchport — bare switchport only #} +interface {{ l.ifname }} + switchport +! +{% endfor %} +{# LAG aggregates already got `switchport` in initial/ocnos.j2 -- BEFORE lag module added + channel-group members (OcNOS rejects setting L2/L3 mode on an aggregator with members) #} +{% for l in netlab_interfaces | default([]) if l.vlan is defined and (l.vlan.access is defined or l.vlan.trunk is defined) + and not (l.vlan.access is defined and (vlans[l.vlan.access].vni is defined or vlans[l.vlan.access].evpn.evi is defined)) %} +interface {{ l.ifname }} + switchport + bridge-group 1 +{% if l.vlan.access is defined %} + switchport mode access + switchport access vlan {{ l.vlan.access_id }} +{% elif l.vlan.trunk is defined %} + switchport mode trunk +{% for tid in l.vlan.trunk_id | default([]) %} + switchport trunk allowed vlan add {{ tid }} +{% endfor %} +{% if l.vlan.native is defined %} + switchport trunk native vlan {{ l.vlan.access_id }} +{% endif %} +{% endif %} +! +{% endfor %} diff --git a/netsim/ansible/templates/vrf/ocnos.j2 b/netsim/ansible/templates/vrf/ocnos.j2 new file mode 100644 index 0000000000..296769f113 --- /dev/null +++ b/netsim/ansible/templates/vrf/ocnos.j2 @@ -0,0 +1,84 @@ +{# + OcNOS per-VRF routing template + VRF creation + interface membership happen in initial/ocnos.j2 (VRF before IP). This + handles per-VRF routing protocols. netlab model: vrfs[name].ospf {vrfidx, router_id, area, + interfaces[]}; vrfs[name].bgp. OcNOS: `router ospf vrf `. + No whitespace-strip tags near "!" (OcNOS comment marker). +#} +{% if vrfs is defined %} +{% for vname, vdata in vrfs.items() if vdata.ospf is defined %} +{# OcNOS per-VRF OSPF: VRF name is a positional arg, no `vrf` keyword #} +router ospf {{ vdata.vrfidx }} {{ vname }} +{% if vdata.ospf.router_id is defined %} + ospf router-id {{ vdata.ospf.router_id }} +{% endif %} +{% for l in vdata.ospf.interfaces | default([]) if l.ipv4 is defined and l.ipv4 is string and '/' in l.ipv4 %} + network {{ l.ipv4 }} area {{ l.ospf.area | default(vdata.ospf.area) }} +{% endfor %} +! +{% endfor %} +{% for vname, vdata in vrfs.items() if vdata.ospf is defined %} +{% for l in vdata.ospf.interfaces | default([]) if l.ospf is defined and l.ospf.network_type is defined %} +interface {{ l.ifname }} + ip ospf network {{ l.ospf.network_type }} +! +{% endfor %} +{% endfor %} +{% for vname, vdata in vrfs.items() if vdata.isis is defined %} +{# per-VRF IS-IS: VRF name positional like per-VRF OSPF. netlab emits a + full vdata.isis (instance/net/type/interfaces) when features.vrf.isis is set. #} +router isis {{ vdata.isis.instance | default(vname) }} {{ vname }} + is-type {{ vdata.isis.type | default('level-2') }} + net {{ vdata.isis.net }} + metric-style wide +! +{% for l in vdata.isis.interfaces | default([]) %} +interface {{ l.ifname }} + ip router isis {{ vdata.isis.instance | default(vname) }} +{% if l.isis.network_type is defined %} + isis network {{ l.isis.network_type }} +{% endif %} +! +{% endfor %} +{% endfor %} +{# PE side of MPLS/VPN: per-VRF BGP address-family redistributing the VRF routes. + This is also the intra-node v4/v6 ROUTE-LEAK mechanism (the + dual-stack version of the ocnos-mpls.yml vrf_leak test): each VRF's connected routes + land in ITS OWN per-VRF BGP table tagged with its RT export; a VRF that imports + another VRF's RT (vrfs..import) pulls those routes in locally -- no MPLS/LDP + transport needed since both VRFs live on the same node. + The v4 vrf_leak precedent (ocnos-mpls.yml) relies on mpls/ocnos.j2's `address-family + vpnv4` block for this to actually populate the per-VRF BGP tables and let RT import + work -- that block's `neighbor X activate` loop is keyed off `n.vpnv4`, an attribute + NO netlab core module ever sets, so it's always empty; the *bare* `address-family + vpnv4` (mode enabled, zero active neighbors) is what's doing the real work: it's a + LOCAL VPN-RIB scoping switch on OcNOS, not something that needs a real MPLS + transport or an actual BGP session to a remote PE. Reproducing the same bare-AF + pattern here for v6 (`address-family vpnv6`), so the v6 leak test doesn't need + mpls.vpn/LDP at all -- live-verified: WITHOUT this block, `show bgp ipv6 vrf red` + returns nothing, and the leaked prefix never reaches vrf blue's v6 RIB. #} +{% if bgp.as is defined %} +router bgp {{ bgp.as }} + address-family vpnv6 + exit-address-family +! +{% for vname, vdata in vrfs.items() %} +router bgp {{ bgp.as }} + address-family ipv4 vrf {{ vname }} + redistribute connected +{% if vdata.ospf is defined %} + redistribute ospf {{ vdata.vrfidx }} +{% endif %} + exit-address-family +{# unconditional: harmless no-op if the VRF has no v6 addressing to redistribute. + NOTE: no "redistribute ospf6/ospfv3 " here yet -- per-VRF OSPFv3 isn't modeled + (see the per-VRF OSPF block above, v4-only so far); "redistribute ospf6 " is + parser-rejected ("Invalid input") when there's no matching v6 process to redistribute + from. Add it alongside a per-VRF OSPFv3 template addition, not before. #} + address-family ipv6 vrf {{ vname }} + redistribute connected + exit-address-family +! +{% endfor %} +{% endif %} +{% endif %} diff --git a/netsim/ansible/templates/vxlan/ocnos.j2 b/netsim/ansible/templates/vxlan/ocnos.j2 new file mode 100644 index 0000000000..88980576cd --- /dev/null +++ b/netsim/ansible/templates/vxlan/ocnos.j2 @@ -0,0 +1,67 @@ +{# + OcNOS VXLAN template (EVPN-VXLAN, vlan-based service). + OKF-verified sequence: `nvo vxlan enable` + `nvo vxlan vtep-ip-global `; + per-VNI `nvo vxlan id ingress-replication` (+ host-reachability evpn-bgp + when EVPN runs); access mapping `nvo vxlan access-if port-vlan + ` + `map vnid `. Mode-exit lines are fine: the deploy uses match:none. +#} +{# the VNI commit requires its mac-vrf to exist ("Missing reference attribute + vrf-name") and the vxlan module deploys BEFORE evpn — so the EVPN instances + (mac vrf + rd/RTs) are created here; evpn/ocnos.j2 only handles the BGP AF #} +{% for vname, v in vlans.items() if v.evpn.evi is defined and v.vni is defined %} +mac vrf {{ vname }} + rd {{ v.evpn.rd }} +{% for irt in v.evpn.import|default([]) %} + route-target import {{ irt }} +{% endfor %} +{% for ert in v.evpn.export|default([]) %} + route-target export {{ ert }} +{% endfor %} +! +{% endfor %} +nvo vxlan enable +{# EVPN symmetric-IRB: global IRB + anycast-gw-mac + per-VRF L3VNI #} +{% set _irbvrfs = [] %} +{% if vrfs is defined %} +{% for _vn, _vd in vrfs.items() if _vd.evpn.transit_vni is defined %}{{ _irbvrfs.append(_vn) or "" }}{% endfor %} +{% endif %} +{% if _irbvrfs %} +nvo vxlan irb +evpn irb-forwarding anycast-gateway-mac 0000.5e00.0001 +! +{% for _vn in _irbvrfs %} +ip vrf {{ _vn }} + l3vni {{ vrfs[_vn].evpn.transit_vni }} +! +{% endfor %} +{% for _l in netlab_interfaces|default([]) if _l.vlan.mode|default('') == 'irb' and _l.vrf is defined and vrfs is defined and vrfs[_l.vrf].evpn.transit_vni is defined %} +interface {{ _l.ifname }} + evpn irb-if-forwarding anycast-gateway-mac +{% if _l.ipv4 is defined and _l.ipv4 is string and '/' in _l.ipv4 %} + ip address {{ _l.ipv4 }} +{% endif %} + exit +! +{% endfor %} +{% endif %} +{% if vxlan.vtep is defined %} +nvo vxlan vtep-ip-global {{ vxlan.vtep }} +{% endif %} +{% for vname in vxlan.vlans|default([]) if vlans[vname].vni is defined %} +{% set vlan = vlans[vname] %} +nvo vxlan id {{ vlan.vni }} ingress-replication +{% if vlan.evpn.evi is defined %} + vxlan host-reachability-protocol evpn-bgp {{ vname }} +{% endif %} + exit +! +{% endfor %} +{% for l in netlab_interfaces|default([]) if l.vlan.access_id is defined and vlans[l.vlan.access].vni is defined %} +{# untagged attachment (`access-if port`); the tagged port-vlan form requires the port + in hybrid/trunk bridge mode. These ports are deliberately NOT customer-bridge + switchports (see vlan/ocnos.j2) #} +nvo vxlan access-if port {{ l.ifname }} + map vnid {{ vlans[l.vlan.access].vni }} + exit +! +{% endfor %} diff --git a/netsim/devices/ocnos.yml b/netsim/devices/ocnos.yml new file mode 100644 index 0000000000..bd75b96592 --- /dev/null +++ b/netsim/devices/ocnos.yml @@ -0,0 +1,144 @@ +--- +description: IP Infusion OcNOS +support: + level: best-effort + caveats: + - OcNOS CLI (cmlsh) has no non-interactive exec mode; use the ipinfusion.ocnos + Ansible collection (network_cli) for config push/fetch, not raw SSH commands. + - An OSPF (v2 or v3) network-membership change on an already-running process can make + OcNOS's commit return a non-empty informational notice that the ocnos_config Ansible + module treats as a failed task, which aborts the modules queued after ospf in the same + netlab initial run. If a deploy stalls after an OSPF topology change, re-run + 'netlab initial' once; the second pass re-sends the unchanged OSPF config and lets the + remaining modules deploy. See docs/caveats.md for details. +interface_name: eth{ifindex} +mtu: 1500 # device default MTU (maintainer convention): declares OcNOS defaults to 1500 so + # netlab resolves a consistent per-link MTU across mixed-device links (OcNOS ifs default 1500) +ifindex_offset: 1 +mgmt_if: eth0 +loopback_interface_name: '{"lo" if not ifindex else "loopback" + str(ifindex)}' +tunnel_interface_name: Tunnel{ifindex} +lag_interface_name: '{"sa" if lag.get("lacp","") == "off" else "po"}{lag.ifindex}' +role: router +# Validation data source: OcNOS cmlsh has no non-interactive SSH exec, so `netlab validate` +# fetches show output through the ipinfusion.ocnos Ansible module (the 'ansible' action). +netlab_validate: + ansible_module: ipinfusion.ocnos.ocnos_command +group_vars: + ansible_network_os: ipinfusion.ocnos.ocnos + ansible_connection: network_cli + netlab_device_type: ocnos + netlab_ready: [ ansible ] + netlab_check_retries: 60 # OcNOS vrnetlab boots ~9 min; default 20x5=100s is far too + netlab_check_delay: 12 # short, so config-deploy SSH-times-out. 60x12 = 720s wait. + collections: + - ipinfusion.ocnos +features: + initial: + ipv4: + unnumbered: false + ipv6: + lla: true + roles: [ router ] + system_mtu: true + collect: true + bfd: true + tunnel: + gre: true + dhcp: + client: + ipv4: true + relay: true + gateway: + protocol: [ vrrp ] + vrrp: + version: true + lag: + passive: true + routing: + static: + vrf: true + prefix: true + aspath: true + community: + standard: true + policy: + match: + prefix: true + aspath: true + community: + standard: true + set: + locpref: true + med: true + prepend: true + weight: true + community: + standard: true + ospf: + default: true + password: true + digest: true + priority: true + timers: true + areas: + external_range: OcNOS does not model NSSA type-7 ranges + external_filter: OcNOS does not model NSSA type-7 range suppression + stp: + supported_protocols: [ stp, rstp, mstp ] + enable_per_port: true + ripv2: + ipv4: true + isis: + circuit_type: true + import: [ connected, bgp, ospf ] + mpls: + ldp: true + vpn: true + sr: + af: [ ipv4 ] + protocol: [ isis ] + srv6: + isis: true + vxlan: true + evpn: + transport: [ vxlan, mpls ] + irb: true + multihoming: + lag: true + interface: false + esi_auto: true + modes: [ all-active ] + vrf: + ospfv2: true + bgp: true + isis: true + vlan: + model: l3-switch + svi_interface_name: "vlan1.{vlan}" + subif_name: "{ifname}.{subif_index}" + native_routed: true + bgp: + activate_af: true + ipv6_lla: false + local_as: true + password: true + timers: true + multihop: true + import: [ connected, static, isis, ospf, vrf ] + community: + standard: [ standard ] + extended: [ standard, extended ] +clab: + image: vrnetlab/ipinfusion_ocnos:7.0.0-262 + mtu: 1500 + node: + kind: ipinfusion_ocnos + group_vars: + ansible_user: ocnos + ansible_ssh_pass: ocnos + netlab_console_connection: ssh + netlab_show_command: [ show ] +external: + image: none +graphite.icon: router diff --git a/netsim/extra/bgp/policy/ocnos.j2 b/netsim/extra/bgp/policy/ocnos.j2 new file mode 100644 index 0000000000..662730f469 --- /dev/null +++ b/netsim/extra/bgp/policy/ocnos.j2 @@ -0,0 +1,27 @@ +{# + OcNOS bgp.policy plugin template. Attaches an already-defined route-map + (rendered by the `routing` module: routing/ocnos.j2 -> `route-map - ...`, + OKF-verified) to a BGP neighbor's inbound/outbound policy. + OKF-verified (layer-3/.../configure-route-map-continue-on-r1.md): `neighbor X route-map + Y in|out` is an ADDRESS-FAMILY command (config-router-af) -- rejected at the top-level + router-bgp context, same as `neighbor X activate` in the base bgp template. Re-entering + `router bgp ` / `address-family unicast` in a later, separate commit (this + module deploys after bgp + bgp.session) is the same pattern bgp.session/ocnos.j2 uses. +#} +{% if bgp.as is defined %} +router bgp {{ bgp.as }} +{% for af in ['ipv4','ipv6'] if bgp[af] is defined %} +{% for n in bgp.neighbors|default([]) if n[af] is defined and n[af] is string and n.policy is defined %} +{% if loop.first %} + address-family {{ af }} unicast +{% endif %} +{% for direction in ['in','out'] if direction in n.policy %} + neighbor {{ n[af] }} route-map {{ n.policy[direction] }}-{{ af }} {{ direction }} +{% endfor %} +{% if loop.last %} + exit-address-family +{% endif %} +{% endfor %} +{% endfor %} +! +{% endif %} diff --git a/netsim/extra/bgp/session/ocnos.j2 b/netsim/extra/bgp/session/ocnos.j2 new file mode 100644 index 0000000000..e22c1e3999 --- /dev/null +++ b/netsim/extra/bgp/session/ocnos.j2 @@ -0,0 +1,24 @@ +{# + OcNOS bgp.session plugin template. + OKF-verified (differs from IOS): password = `neighbor X authentication-key 0 ` + (stored 0x-encrypted); timers = `neighbor X timers `. GTSM = + `neighbor X ttl-security hops ` (parser-probe before relying). bfd/passive already + handled by the base bgp template / bfd module. Router-level neighbor commands. +#} +{% if bgp.as is defined %} +router bgp {{ bgp.as }} +{% for n in bgp.neighbors|default([]) %} +{% for af in ['ipv4'] if n[af] is defined and n[af] is string %} +{% if n.password is defined %} + neighbor {{ n[af] }} authentication-key 0 {{ n.password }} +{% endif %} +{% if n.timers is defined %} + neighbor {{ n[af] }} timers {{ n.timers.keepalive|default(60) }} {{ n.timers.hold|default(180) }} +{% endif %} +{% if n.gtsm is defined %} + neighbor {{ n[af] }} ttl-security hops {{ n.gtsm }} +{% endif %} +{% endfor %} +{% endfor %} +! +{% endif %} diff --git a/netsim/extra/ebgp/multihop/ocnos.j2 b/netsim/extra/ebgp/multihop/ocnos.j2 new file mode 100644 index 0000000000..0bfbb8d0dc --- /dev/null +++ b/netsim/extra/ebgp/multihop/ocnos.j2 @@ -0,0 +1,14 @@ +{# + OcNOS ebgp.multihop plugin template. + OKF-verified: `neighbor X ebgp-multihop ` (router level). netlab sets + neighbor.multihop = the TTL/hop count on the (loopback) eBGP session. +#} +{% if bgp.as is defined %} +router bgp {{ bgp.as }} +{% for n in bgp.neighbors|default([]) if n.multihop is defined %} +{% for af in ['ipv4'] if n[af] is defined and n[af] is string %} + neighbor {{ n[af] }} ebgp-multihop {{ n.multihop }} +{% endfor %} +{% endfor %} +! +{% endif %} diff --git a/netsim/extra/evpn/multihoming/ocnos.j2 b/netsim/extra/evpn/multihoming/ocnos.j2 new file mode 100644 index 0000000000..29e35a61a9 --- /dev/null +++ b/netsim/extra/evpn/multihoming/ocnos.j2 @@ -0,0 +1,24 @@ +{# + OcNOS evpn.multihoming plugin template. ESI-LAG only (features.evpn.multihoming.lag); + physical/static-lag `esi <9-octet>` form (features.evpn.multihoming.interface) not modeled. + OKF-verified sequence (wiki/okf/.../vxlan/04-vxlan-multi-homing-configuration/ + + 20-vxlan-commands/): `evpn vxlan multihoming enable` globally, then per dynamic-LAG (po) + `evpn multi-homed system-mac ` — OcNOS reuses the LACP System ID as the ESI value for + a dynamic aggregate. intf.lag.lacp_system_id is already netlab's colon-hex MAC form (netaddr + mac_unix_expanded), which OcNOS accepts directly as the "XX:XX:XX:XX:XX:XX" system-mac option. + NOT modeled: `hardware-profile filter vxlan-mh enable` — the OKF doc says to run this + BEFORE `evpn vxlan multihoming enable` on real hardware, but it's rejected live on the + vrnetlab virtual image ("Invalid input detected" — parser-proven, config: `hardware-profile + filter vxlan-mh enable`): a real DUNE-ASIC hardware-profile knob the virtual switch fabric + doesn't implement, same needs-hardware category as the EVPN-IRB/MPLS L3 dataplane. + The control-plane (ES / ESI / Type-1 / Type-4) does not depend on it. +#} +{% for intf in interfaces if intf.type == 'lag' and intf.evpn._esi is defined %} +{% if loop.first %} +evpn vxlan multihoming enable +! +{% endif %} +interface {{ intf.ifname }} + evpn multi-homed system-mac {{ intf.lag.lacp_system_id }} +! +{% endfor %} diff --git a/netsim/extra/ospf/areas/ocnos.j2 b/netsim/extra/ospf/areas/ocnos.j2 new file mode 100644 index 0000000000..f013ab8c39 --- /dev/null +++ b/netsim/extra/ospf/areas/ocnos.j2 @@ -0,0 +1,35 @@ +{# + OcNOS ospf.areas plugin template. + Parser-verified (live) under `router ospf `: + area stub [no-summary] + area nssa [no-summary|default-information-originate [metric N metric-type T]] + area default-cost <0-16777215> + area range A.B.C.D/M + netlab ospf.areas plugin data: ospf.areas[] = {area(dotted), kind, inter_area, + default.cost, range[]}. inter_area defaults True for stub/nssa (advertise summaries); + False => totally-stubby (`no-summary`). IPv4 only. +#} +{% macro area_config(adata, abr) %} +{% if adata.kind == 'stub' %} + area {{ adata.area }} stub{{ ' no-summary' if not adata.inter_area else '' }} +{% elif adata.kind == 'nssa' %} + area {{ adata.area }} nssa{{ ' no-summary' if not adata.inter_area else '' }} +{% if abr and adata.default|default(false) %} + area {{ adata.area }} nssa default-information-originate{% if adata.default.cost is defined %} metric {{ adata.default.cost }} metric-type 1{% endif +%} +{% endif %} +{% endif %} +{% if adata.kind in ['stub','nssa'] and adata.default.cost is defined %} + area {{ adata.area }} default-cost {{ adata.default.cost }} +{% endif %} +{% if abr %} +{% for range in adata.range|default([]) if range.ipv4 is defined %} + area {{ adata.area }} range {{ range.ipv4 }}{% if range.cost is defined %} cost {{ range.cost }}{% endif +%} +{% endfor %} +{% endif %} +{% endmacro %} +{% if ospf.areas is defined %} +router ospf {{ ospf.process|default(1) }} +{% for adata in ospf.areas %} +{{ area_config(adata, ospf._abr|default(false)) }} +{% endfor %} +{% endif %} diff --git a/netsim/extra/tunnel/gre/ocnos.j2 b/netsim/extra/tunnel/gre/ocnos.j2 new file mode 100644 index 0000000000..feacd64cc2 --- /dev/null +++ b/netsim/extra/tunnel/gre/ocnos.j2 @@ -0,0 +1,14 @@ +{# + OcNOS GRE tunnel template. Parser-verified: interface Tunnel + (capital T), tunnel source/destination take IP addresses, NO mode line + (GRE is implicit on OcNOS; only `tunnel mode vxlan` exists). +#} +{% for intf in netlab_interfaces if intf.tunnel.mode|default("") == "gre" %} +interface {{ intf.ifname }} + tunnel source {{ intf.tunnel._source.ipv4 | ansible.utils.ipaddr("address") }} + tunnel destination {{ intf.tunnel._destination[intf.tunnel.af] | ansible.utils.ipaddr("address") }} +{% if intf.ipv4 is defined and intf.ipv4 is string %} + ip address {{ intf.ipv4 }} +{% endif %} +! +{% endfor %} diff --git a/netsim/extra/vrrp/version/ocnos.j2 b/netsim/extra/vrrp/version/ocnos.j2 new file mode 100644 index 0000000000..9753ec20f7 --- /dev/null +++ b/netsim/extra/vrrp/version/ocnos.j2 @@ -0,0 +1,2 @@ +{# vrrp.version handled inside the gateway template (v2-compatible must sit in the + router vrrp block). This no-op satisfies the plugin's per-device template lookup. #} diff --git a/netsim/validate/bgp/ocnos.py b/netsim/validate/bgp/ocnos.py new file mode 100644 index 0000000000..624c9854f5 --- /dev/null +++ b/netsim/validate/bgp/ocnos.py @@ -0,0 +1,127 @@ +""" +OcNOS BGP validation via the `ansible` validation action (netsim/cli/validate/ansible.py). + +`ansible_()` returns the OcNOS show command, netlab runs it through the +ipinfusion.ocnos Ansible module, and `valid_()` asserts on the CLI text. +Function signatures mirror netsim/validate/bgp/frr.py so the stock BGP integration +tests (plugin: bgp_neighbor(node.bgp.neighbors,'dut'), bgp_prefix('...')) work when +the OcNOS node is the target of the check. OcNOS emits CLI text, so we parse the +`show ip bgp summary` / `show bgp summary` tables rather than JSON. +""" + +import re +import typing + +from box import Box + +from netsim.data import global_vars + +from .. import _common + +# OcNOS `show ip bgp summary` non-Established states (the State/PfxRcd column shows +# one of these words instead of a numeric prefix count). +_BGP_STATE_WORDS = {'Idle', 'Active', 'Connect', 'OpenSent', 'OpenConfirm', 'Idle(Admin)'} + + +def _row_established(row: typing.List[str]) -> bool: + # Row layout: Neighbor V AS MsgRcv MsgSen TblVer InQ OutQ Up/Down State/PfxRcd [Desc] + # Established -> the State/PfxRcd column is a numeric prefix count. The trailing + # Desc column (peer description) means the last token is NOT reliably the state, + # so anchor on the Up/Down time column and read the token right after it. + if any(t in _BGP_STATE_WORDS for t in row): + return False + for i, t in enumerate(row): + if re.fullmatch(r'\d+:\d+:\d+', t) or re.fullmatch(r'\d+[dwmy]\d*[hms]?', t) or t == 'never': + return i + 1 < len(row) and row[i + 1].replace('+', '').isdigit() + return False + + +def _text(_result: typing.Any) -> str: + if isinstance(_result, (Box, dict)): + out = _result.get('stdout', '') + return '\n'.join(out) if isinstance(out, list) else str(out) + return str(_result or '') + + +# af -> OcNOS "show" summary command +_AF_SUMMARY = { + 'ipv4': 'show ip bgp summary', + 'ipv6': 'show bgp ipv6 unicast summary', +} +_AF_TABLE = { + 'ipv4': 'show ip bgp', + 'ipv6': 'show bgp ipv6 unicast', +} + + +def ansible_bgp_neighbor(ngb: list, n_id: str, af: str = 'ipv4', *, + vrf: str = 'default', activate: str = '', + **kwargs: typing.Any) -> str: + a = activate or af + cmd = _AF_SUMMARY.get(a, _AF_SUMMARY['ipv4']) + if vrf != 'default' and a == 'ipv4': + cmd = f'show ip bgp vrf {vrf} summary' + return cmd + + +def valid_bgp_neighbor(ngb: list, n_id: str, af: str = 'ipv4', *, + vrf: str = 'default', state: str = 'Established', + activate: str = '', intf: str = '', + **kwargs: typing.Any) -> str: + text = _text(global_vars.get_result_dict('_result')) + a = activate or af + n_addr = _common.get_bgp_neighbor_id(ngb, n_id, a) + if n_addr is True: # unnumbered EBGP peer + if not intf: + raise Exception('Need an interface name for an unnumbered BGP neighbor') + n_addr = intf + if not n_addr: + raise Exception(f'Cannot find the {a} address of BGP neighbor {n_id}') + + # OcNOS summary rows: " 4 ... " + # Established -> the last column is a numeric prefix count; otherwise it is + # a text state (Idle/Active/Connect/OpenSent...). + row = None + for ln in text.splitlines(): + toks = ln.split() + if toks and toks[0] == str(n_addr): + row = toks + break + + established = row is not None and _row_established(row) + + if state == 'missing': + if not established: + return f'BGP neighbor {n_addr} ({n_id}) is correctly not Established' + raise Exception(f'Unexpected established BGP neighbor {n_addr} ({n_id})') + + if row is None: + raise Exception(f'The router has no BGP neighbor {n_addr} ({n_id}) in address family {a}') + if not established: + raise Exception(f'BGP neighbor {n_addr} ({n_id}) is not Established (row: {" ".join(row)})') + return f'BGP neighbor {n_addr} ({n_id}) is Established' + + +def ansible_bgp_prefix(pfx: str, af: str = 'ipv4', vrf: str = 'default', + **kwargs: typing.Any) -> str: + cmd = _AF_TABLE.get(af, _AF_TABLE['ipv4']) + if vrf != 'default' and af == 'ipv4': + cmd = f'show ip bgp vrf {vrf}' + return cmd + + +def valid_bgp_prefix(pfx: str, af: str = 'ipv4', vrf: str = 'default', + state: str = 'present', **kwargs: typing.Any) -> str: + text = _text(global_vars.get_result_dict('_result')) + pfx = pfx if isinstance(pfx, str) else str(pfx) + # A prefix may be printed with or without its mask in the BGP table; match the + # network portion so 172.42.42.0/24 also matches a "172.42.42.0" table entry. + net = pfx.split('/')[0] + seen = any(net in ln for ln in text.splitlines()) + if state in ('missing', 'absent'): + if seen: + raise Exception(f'Prefix {pfx} unexpectedly present in the BGP table') + return f'Prefix {pfx} is correctly absent from the BGP table' + if not seen: + raise Exception(f'Prefix {pfx} is not in the BGP table') + return f'Prefix {pfx} is in the BGP table' diff --git a/netsim/validate/isis/ocnos.py b/netsim/validate/isis/ocnos.py new file mode 100644 index 0000000000..6c2742d20f --- /dev/null +++ b/netsim/validate/isis/ocnos.py @@ -0,0 +1,73 @@ +""" +OcNOS IS-IS validation via the `ansible` validation action (netsim/cli/validate/ansible.py). + +`ansible_()` returns the OcNOS show command, netlab runs it through the +ipinfusion.ocnos Ansible module, and `valid_()` asserts on the CLI text. +Signatures mirror netsim/validate/isis/frr.py. Note: OcNOS `show isis neighbor` +trips the ipinfusion.ocnos.ocnos_command module (like `show route-map` / `ping`), +so we read adjacencies from `show clns neighbors`, whose rows carry the neighbor +hostname, state and level (Type = L1/L2/L1L2); this needs isis dynamic-hostname. +""" + +import typing + +from box import Box + +from netsim.data import global_vars + + +def _text(_result: typing.Any) -> str: + if isinstance(_result, (Box, dict)): + out = _result.get('stdout', '') + return '\n'.join(out) if isinstance(out, list) else str(out) + return str(_result or '') + + +def ansible_isis_neighbor(id: str, **kwargs: typing.Any) -> str: + return 'show clns neighbors' + + +def valid_isis_neighbor(id: str, present: bool = True, state: str = 'Up', + level: str = '', area: str = '') -> str: + text = _text(global_vars.get_result_dict('_result')) + # `show clns neighbors` rows: + # System Id Interface SNPA State Holdtime Type Protocol + # x1 eth1 ... Up 27 L1 IS-IS + rows = [ln for ln in text.splitlines() if id in ln.split()] + + if not present: + if rows: + raise Exception(f'Unexpected IS-IS neighbor {id}') + return f'IS-IS neighbor {id} is correctly absent' + + if not rows: + raise Exception(f'There is no IS-IS neighbor {id}') + if not any(state in ln.split() for ln in rows): + raise Exception(f'IS-IS neighbor {id} is not in state {state}') + + if level: + # Type column carries L1 / L2 / L1L2; accept an L1 match inside L1L2 too. + lv = level.upper() + if not any(any(lv in tok.upper() for tok in ln.split()) for ln in rows): + raise Exception(f'IS-IS neighbor {id} is not at level {level}') + + return f'IS-IS neighbor {id} is {state}' + (f' ({level})' if level else '') + + +def ansible_isis_prefix(pfx: str, level: str = '2', **kwargs: typing.Any) -> str: + af = 'ipv6' if ':' in str(pfx) else 'ip' + return f'show {af} route isis' + + +def valid_isis_prefix(pfx: str, level: str = '2', state: str = 'present', + **kwargs: typing.Any) -> str: + text = _text(global_vars.get_result_dict('_result')) + pfx = pfx if isinstance(pfx, str) else str(pfx) + seen = any(pfx in ln for ln in text.splitlines()) + if state in ('missing', 'absent'): + if seen: + raise Exception(f'Prefix {pfx} unexpectedly present in the IS-IS routing table') + return f'Prefix {pfx} is correctly absent from the IS-IS routing table' + if not seen: + raise Exception(f'Prefix {pfx} is not in the IS-IS routing table') + return f'Prefix {pfx} is in the IS-IS routing table' diff --git a/netsim/validate/ocnos.py b/netsim/validate/ocnos.py new file mode 100644 index 0000000000..e749447f2f --- /dev/null +++ b/netsim/validate/ocnos.py @@ -0,0 +1,10 @@ +# Top-level OcNOS validation plugin +# +# OcNOS show output is CLI text (not JSON). Because OcNOS cmlsh rejects +# non-interactive SSH commands, the OSPF/BGP/IS-IS validators fetch it through the ansible +# validation action (netsim/cli/validate/ansible.py, via the ipinfusion.ocnos +# Ansible module); the per-module validators screen-scrape the CLI text. +# See netsim/validate//ocnos.py. +from netsim.validate.bgp.ocnos import * +from netsim.validate.isis.ocnos import * +from netsim.validate.ospf.ocnos import * diff --git a/netsim/validate/ospf/ocnos.py b/netsim/validate/ospf/ocnos.py new file mode 100644 index 0000000000..436d1fc658 --- /dev/null +++ b/netsim/validate/ospf/ocnos.py @@ -0,0 +1,90 @@ +""" +OcNOS OSPFv2 / OSPFv3 validation plugin. + +Uses the `ansible` validation action (netsim/cli/validate/ansible.py): `ansible_()` +returns the OcNOS show command, netlab runs it via the ipinfusion.ocnos Ansible module, +and `valid_()` asserts on the result. OcNOS `show ip ospf neighbor` is CLI text, +so the validators screen-scrape `_result.stdout`; signatures match the FRR/EOS plugins +(`ospf_neighbor`, `ospf_prefix`) so the stock integration tests can target an OcNOS DUT. +""" + +import ipaddress +import typing + +from box import Box + +from netsim.data import global_vars + + +def _text(_result: typing.Any) -> str: + if isinstance(_result, (Box, dict)): + out = _result.get('stdout', '') + return '\n'.join(out) if isinstance(out, list) else str(out) + return str(_result or '') + + +def ansible_ospf_neighbor(id: str, present: bool = True, vrf: str = 'default', + proto_name: str = 'OSPFv2', **kwargs: typing.Any) -> str: + scope = '' if vrf == 'default' else f' vrf {vrf}' + af = 'ipv6' if proto_name == 'OSPFv3' else 'ip' + return f'show {af} ospf{scope} neighbor' + + +def valid_ospf_neighbor(id: str, present: bool = True, vrf: str = 'default', + proto_name: str = 'OSPFv2', **kwargs: typing.Any) -> str: + try: + ipaddress.IPv4Address(id) + except Exception as exc: + raise Exception(f'OSPF router ID {id} is not a valid IPv4 address') from exc + text = _text(global_vars.get_result_dict('_result')) + rows = [ln for ln in text.splitlines() if str(id) in ln.split()] + + if not present: + if rows: + raise Exception(f'Unexpected {proto_name} neighbor {id}') + return f'{proto_name} neighbor {id} is correctly absent' + if not rows: + raise Exception(f'There is no {proto_name} neighbor {id} in VRF {vrf}') + if not any('Full' in ln for ln in rows): + raise Exception(f'{proto_name} neighbor {id} is not in state Full') + return f'{proto_name} neighbor {id} is Full' + + +def ansible_ospf6_neighbor(id: str, **kwargs: typing.Any) -> str: + return ansible_ospf_neighbor(id, proto_name='OSPFv3') + + +def valid_ospf6_neighbor(id: str, present: bool = True, vrf: str = 'default', + **kwargs: typing.Any) -> str: + return valid_ospf_neighbor(id, present=present, vrf=vrf, proto_name='OSPFv3') + + +def ansible_ospf_prefix(pfx: str, vrf: str = 'default', **kwargs: typing.Any) -> str: + scope = '' if vrf == 'default' else f' vrf {vrf}' + af = 'ipv6' if ':' in str(pfx) else 'ip' + return f'show {af} route{scope} ospf' + + +def valid_ospf_prefix(pfx: str, state: str = 'present', **kwargs: typing.Any) -> str: + text = _text(global_vars.get_result_dict('_result')) + pfx = pfx if isinstance(pfx, str) else str(pfx) + # Match on the address portion, not the literal mask: an OcNOS loopback such as + # 2001:db8:1:2::1/64 is installed as a host route (.../128) plus the network + # (.../64), so the raw '/' string appears in neither table row. + needle = pfx.split('/')[0] + seen = any(needle in ln for ln in text.splitlines()) + if state in ('missing', 'absent'): + if seen: + raise Exception(f'Prefix {pfx} unexpectedly present in the OSPF routing table') + return f'Prefix {pfx} is correctly absent from the OSPF routing table' + if not seen: + raise Exception(f'Prefix {pfx} is not in the OSPF routing table') + return f'Prefix {pfx} is in the OSPF routing table' + + +def ansible_ospf6_prefix(pfx: str, **kwargs: typing.Any) -> str: + return ansible_ospf_prefix(pfx) + + +def valid_ospf6_prefix(pfx: str, state: str = 'present', **kwargs: typing.Any) -> str: + return valid_ospf_prefix(pfx, state=state) diff --git a/tests/integration/platform/ocnos/README.md b/tests/integration/platform/ocnos/README.md new file mode 100644 index 0000000000..5941eebc9b --- /dev/null +++ b/tests/integration/platform/ocnos/README.md @@ -0,0 +1,32 @@ +# OcNOS integration test + +`ospf-bgp.yml` -- minimal two-node OcNOS smoke test (OSPFv2 + iBGP over one +point-to-point link). Exercises the `ocnos` device definition +(`netsim/devices/ocnos.yml`), the `initial`/`ospf`/`bgp` templates, and the +`ipinfusion.ocnos` + `ansible.netcommon.network_cli` config-push path +(`netsim/ansible/tasks/deploy-config/ocnos.yml`). + +Requires the `ipinfusion.ocnos` Ansible collection and a real OcNOS clab image +(commercial NOS -- users provide their own, see `netsim/devices/ocnos.yml` clab +image reference): + +``` +ansible-galaxy collection install ipinfusion.ocnos +export ANSIBLE_COLLECTIONS_PATH=~/.ansible/collections:$(python -c "import netsim,os;print(os.path.dirname(netsim.__file__))")/../ansible_collections + +netlab up tests/integration/platform/ocnos/ospf-bgp.yml +``` + +Verify: OSPF adjacency Full and BGP session Established, e.g. + +``` +ansible -i hosts.yml r1 -m ipinfusion.ocnos.ocnos_command -a 'commands="show ip ospf neighbor"' +ansible -i hosts.yml r1 -m ipinfusion.ocnos.ocnos_command -a 'commands="show ip bgp summary"' +``` + +This topology intentionally has no `validate:` block -- see the comment in +`ospf-bgp.yml` for why (OcNOS's cmlsh restricted shell has no non-interactive +exec mode, so netlab's native device-side `show`-command validation path does +not apply; the existing generic module suites in `tests/integration/ospf/` and +`tests/integration/bgp/` validate a device-under-test via probe-node plugin +checks instead, and run unmodified against `-d ocnos`). diff --git a/tests/integration/platform/ocnos/ospf-bgp.yml b/tests/integration/platform/ocnos/ospf-bgp.yml new file mode 100644 index 0000000000..d29611d777 --- /dev/null +++ b/tests/integration/platform/ocnos/ospf-bgp.yml @@ -0,0 +1,32 @@ +--- +message: | + Minimal OcNOS smoke test: two OcNOS nodes running OSPFv2 and iBGP over + a single point-to-point link. Verifies the device definition, initial/ospf/bgp + templates, and the Ansible network_cli + ipinfusion.ocnos connection/deploy model + end to end against a real OcNOS container (vrnetlab/ipinfusion_ocnos). + + Live-verified (2026-07-17): OSPF adjacency reaches Full and the BGP session + reaches Established after 'netlab initial'. + +provider: clab +defaults.device: ocnos +module: [ ospf, bgp ] +bgp.as: 65000 + +nodes: + r1: + r2: + +links: +- r1: + r2: + +# No 'validate:' block: netlab's generic module test suites (tests/integration/ospf, +# tests/integration/bgp) validate a device-under-test via plugin checks run from +# attached FRR/Linux probe nodes (ospf_neighbor(), bgp_neighbor()), which only need +# SSH to the PROBE, not to the DUT -- so they work unmodified against OcNOS. A +# validate: block that runs 'show ...' directly against an OcNOS node would need +# netlab's native (non-interactive-SSH) show-command path, which does not work here: +# OcNOS's cmlsh restricted shell has no non-interactive exec mode (see device +# support.caveats in netsim/devices/ocnos.yml). Confirmed live with an ansible ad-hoc +# ipinfusion.ocnos.ocnos_command call instead -- see the OcNOS device PR notes.