From 89f793d6797ec7ae0a535e97ef3d6e9c6c4f523f Mon Sep 17 00:00:00 2001 From: zzw4257 Date: Mon, 15 Jun 2026 00:47:14 +0800 Subject: [PATCH] Add A14 control-plane regression example --- .github/workflows/on-pull-request.yaml | 12 + .../A14_control_plane_regression/README.md | 26 +++ .../control_plane_regression.py | 205 ++++++++++++++++++ .../A14_control_plane_regression/example.yaml | 94 ++++++++ .../test_runtime.py | 153 +++++++++++++ 5 files changed, 490 insertions(+) create mode 100644 examples/basic/A14_control_plane_regression/README.md create mode 100644 examples/basic/A14_control_plane_regression/control_plane_regression.py create mode 100644 examples/basic/A14_control_plane_regression/example.yaml create mode 100644 examples/basic/A14_control_plane_regression/test_runtime.py diff --git a/.github/workflows/on-pull-request.yaml b/.github/workflows/on-pull-request.yaml index 4a2b509c3..5ddf81071 100644 --- a/.github/workflows/on-pull-request.yaml +++ b/.github/workflows/on-pull-request.yaml @@ -22,6 +22,7 @@ on: - A03b - A05 - A06 + - A14 - A62 - B00 - B02 @@ -104,6 +105,8 @@ jobs: examples/basic/A05_components/test_runtime.py \ examples/basic/A06_merge_emulation/merge_emulation.py \ examples/basic/A06_merge_emulation/test_runtime.py \ + examples/basic/A14_control_plane_regression/control_plane_regression.py \ + examples/basic/A14_control_plane_regression/test_runtime.py \ examples/basic/A62_route_reflector/route_reflector.py \ examples/basic/A62_route_reflector/test_runtime.py \ examples/internet/B00_mini_internet/mini_internet.py \ @@ -124,6 +127,7 @@ jobs: python seedemu/testing/cli.py clean examples/basic/A03b_from_real_world/example.yaml python seedemu/testing/cli.py clean examples/basic/A05_components/example.yaml python seedemu/testing/cli.py clean examples/basic/A06_merge_emulation/example.yaml + python seedemu/testing/cli.py clean examples/basic/A14_control_plane_regression/example.yaml python seedemu/testing/cli.py clean examples/basic/A62_route_reflector/example.yaml python seedemu/testing/cli.py clean examples/internet/B00_mini_internet/example.yaml python seedemu/testing/cli.py clean examples/internet/B02_mini_internet_with_dns/example.yaml @@ -168,6 +172,10 @@ jobs: category: basic requires_mpls: false manifest: examples/basic/A06_merge_emulation/example.yaml + - code: A14 + category: basic + requires_mpls: false + manifest: examples/basic/A14_control_plane_regression/example.yaml - code: A62 category: basic requires_mpls: false @@ -273,6 +281,10 @@ jobs: category: basic requires_mpls: false manifest: examples/basic/A06_merge_emulation/example.yaml + - code: A14 + category: basic + requires_mpls: false + manifest: examples/basic/A14_control_plane_regression/example.yaml - code: A62 category: basic requires_mpls: false diff --git a/examples/basic/A14_control_plane_regression/README.md b/examples/basic/A14_control_plane_regression/README.md new file mode 100644 index 000000000..2cdc62e08 --- /dev/null +++ b/examples/basic/A14_control_plane_regression/README.md @@ -0,0 +1,26 @@ +# A14 Control-Plane Regression + +This example is the compact runtime regression entry for the control-plane +foundation work. It intentionally keeps several independent slices in one +emulation so a reviewer can run one example after changes to Routing, BGP +intent metadata, FRR rendering, ExaBGP service binding, or MPLS readiness. + +Covered slices: + +- IX100 uses the legacy BIRD route server path with AS150/AS151 clients. +- AS2 mixes a BIRD router and an FRR router from the same OSPF/iBGP/eBGP intent. +- AS3 uses an FRR route reflector and an FRR RR client. +- AS180 installs ExaBGP through `ExaBgpService + Binding` and peers with an FRR + router in AS4. ExaBGP is not a router backend. +- AS20 enables MPLS/LDP readiness on a small transit chain. The default runtime + test checks generated MPLS interface and FRR LDP config. Full MPLS dataplane + validation remains host-gated because GitHub hosted runners may not provide + MPLS kernel modules. + +Run it locally: + +```bash +python -m seedemu.testing.cli clean examples/basic/A14_control_plane_regression/example.yaml +python -m seedemu.testing.cli compile examples/basic/A14_control_plane_regression/example.yaml +COMPOSE_PROJECT_NAME=seedemu-a14-control-plane python -m seedemu.testing.cli all examples/basic/A14_control_plane_regression/example.yaml +``` diff --git a/examples/basic/A14_control_plane_regression/control_plane_regression.py b/examples/basic/A14_control_plane_regression/control_plane_regression.py new file mode 100644 index 000000000..a0d26eada --- /dev/null +++ b/examples/basic/A14_control_plane_regression/control_plane_regression.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from seedemu.compiler import Docker, Platform +from seedemu.core import Binding, Emulator, Filter +from seedemu.layers import Base, Ebgp, Ibgp, Mpls, Ospf, PeerRelationship, Routing +from seedemu.services import ExaBgpService, WebService + + +AS3_CLUSTER_ID = "10.3.0.1" +EXABGP_PREFIX = "198.51.100.0/24" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the A14 control-plane regression example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def build_route_server_slice(emu: Emulator, base: Base, ebgp: Ebgp, web: WebService) -> None: + """Keep the legacy BIRD route-server path visible in the regression topology.""" + + base.createInternetExchange(100) + + for asn in [150, 151]: + current_as = base.createAutonomousSystem(asn) + current_as.createNetwork("net0") + current_as.createRouter("router0").joinNetwork("net0").joinNetwork("ix100") + current_as.createHost("web").joinNetwork("net0") + + vnode = "web{}".format(asn) + web.install(vnode) + emu.addBinding(Binding(vnode, filter=Filter(nodeName="web", asn=asn))) + ebgp.addRsPeer(100, asn) + + +def build_mixed_backend_slice(emu: Emulator, base: Base, ebgp: Ebgp, web: WebService) -> None: + """Exercise one transit AS with BIRD and FRR routers from shared BGP/OSPF intent.""" + + base.createInternetExchange(101) + base.createInternetExchange(102) + + as2 = base.createAutonomousSystem(2) + as2.createNetwork("net0") + as2.createRouter("r1").joinNetwork("net0").joinNetwork("ix101") + as2.createRouter("r2", routingBackend="frr").joinNetwork("net0").joinNetwork("ix102") + + as152 = base.createAutonomousSystem(152) + as152.createNetwork("net0") + as152.createRouter("router0", routingBackend="frr").joinNetwork("net0").joinNetwork("ix101") + as152.createHost("web").joinNetwork("net0") + web.install("web152") + emu.addBinding(Binding("web152", filter=Filter(nodeName="web", asn=152))) + + as153 = base.createAutonomousSystem(153) + as153.createNetwork("net0") + as153.createRouter("router0").joinNetwork("net0").joinNetwork("ix102") + as153.createHost("web").joinNetwork("net0") + web.install("web153") + emu.addBinding(Binding("web153", filter=Filter(nodeName="web", asn=153))) + + ebgp.addPrivatePeering(101, 2, 152, abRelationship=PeerRelationship.Provider) + ebgp.addPrivatePeering(102, 2, 153, abRelationship=PeerRelationship.Provider) + + +def build_frr_route_reflector_slice(base: Base, ebgp: Ebgp) -> None: + """Validate FRR route-reflector rendering and runtime route propagation.""" + + base.createInternetExchange(103) + + as3 = base.createAutonomousSystem(3) + as3.createNetwork("net0") + as3.createBgpCluster(AS3_CLUSTER_ID) + as3.createRouter("rr", routingBackend="frr").joinNetwork("net0").joinNetwork("ix103").joinBgpCluster(AS3_CLUSTER_ID).makeRouteReflector() + as3.createRouter("client", routingBackend="frr").joinNetwork("net0").joinBgpCluster(AS3_CLUSTER_ID) + + as154 = base.createAutonomousSystem(154) + as154.createNetwork("net0") + as154.createRouter("router0").joinNetwork("net0").joinNetwork("ix103") + as154.createHost("host").joinNetwork("net0") + + ebgp.addPrivatePeering(103, 3, 154, abRelationship=PeerRelationship.Provider) + + +def build_exabgp_slice(base: Base, exabgp: ExaBgpService, emu: Emulator) -> None: + """Install ExaBGP as a Service + Binding speaker, never as a router backend.""" + + base.createInternetExchange(104) + + as4 = base.createAutonomousSystem(4) + as4.createNetwork("net0") + as4.createRouter("router0", routingBackend="frr").joinNetwork("net0").joinNetwork("ix104") + + as180 = base.createAutonomousSystem(180) + as180.createHost("exabgp").joinNetwork("ix104", address="10.104.0.180") + + exabgp.install("as180_exabgp") \ + .setLocalAsn(180) \ + .addPeer("router0", router_asn=4, router_relationship="customer") \ + .addAnnouncement(EXABGP_PREFIX) + emu.addBinding(Binding("as180_exabgp", filter=Filter(asn=180, nodeName="exabgp"))) + + +def build_mpls_readiness_slice(base: Base, ebgp: Ebgp, mpls: Mpls) -> None: + """Keep MPLS config/readiness in scope while leaving dataplane checks host-gated.""" + + base.createInternetExchange(105) + base.createInternetExchange(106) + + as20 = base.createAutonomousSystem(20) + as20.createNetwork("net0") + as20.createNetwork("net1") + as20.createNetwork("net2") + as20.createRouter("r1").joinNetwork("net0").joinNetwork("ix105") + as20.createRouter("r2").joinNetwork("net0").joinNetwork("net1") + as20.createRouter("r3").joinNetwork("net1").joinNetwork("net2") + as20.createRouter("r4").joinNetwork("net2").joinNetwork("ix106") + mpls.enableOn(20) + + as155 = base.createAutonomousSystem(155) + as155.createNetwork("net0") + as155.createRouter("router0").joinNetwork("net0").joinNetwork("ix105") + + as156 = base.createAutonomousSystem(156) + as156.createNetwork("net0") + as156.createRouter("router0").joinNetwork("net0").joinNetwork("ix106") + + ebgp.addPrivatePeering(105, 20, 155, abRelationship=PeerRelationship.Provider) + ebgp.addPrivatePeering(106, 20, 156, abRelationship=PeerRelationship.Provider) + + +def build_emulator() -> Emulator: + emu = Emulator() + + base = Base() + routing = Routing() + ospf = Ospf() + ibgp = Ibgp() + ebgp = Ebgp() + mpls = Mpls() + exabgp = ExaBgpService() + web = WebService() + + build_route_server_slice(emu, base, ebgp, web) + build_mixed_backend_slice(emu, base, ebgp, web) + build_frr_route_reflector_slice(base, ebgp) + build_exabgp_slice(base, exabgp, emu) + build_mpls_readiness_slice(base, ebgp, mpls) + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.addLayer(ibgp) + emu.addLayer(ebgp) + emu.addLayer(mpls) + emu.addLayer(exabgp) + emu.addLayer(web) + return emu + + +def main() -> int: + args = parse_args() + emu = build_emulator() + + if args.dumpfile: + emu.dump(args.dumpfile) + print("Saved A14 emulator to {}".format(args.dumpfile)) + return 0 + + if args.render: + emu.render() + + output_dir = Path(args.output).resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + emu.compile(Docker(platform=resolve_platform(args.platform)), str(output_dir), override=args.override) + print("Generated A14 Docker output in {}".format(output_dir)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/basic/A14_control_plane_regression/example.yaml b/examples/basic/A14_control_plane_regression/example.yaml new file mode 100644 index 000000000..68477af06 --- /dev/null +++ b/examples/basic/A14_control_plane_regression/example.yaml @@ -0,0 +1,94 @@ +id: basic-a14-control-plane-regression +name: Control-Plane Regression +description: A compact regression topology for route-server, mixed BIRD/FRR, FRR route-reflector, ExaBGP service, and MPLS readiness coverage. +runner: internet +script: control_plane_regression.py +platform: amd +features: + - ipv4-default + - ebgp-route-server + - ebgp-private-peering + - routing-backend + - bird + - frr + - ibgp + - ibgp-route-reflector + - ospf + - exabgp-service + - mpls-readiness + +compile: + enabled: true + output: output + clean: + - output + expected: + - output/docker-compose.yml + timeout: 600 + +build: + enabled: true + timeout: 1800 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: A14 control-plane services are running + type: compose-ps + services: + - rs_ix_ix100 + - brdnode_150_router0 + - brdnode_151_router0 + - brdnode_2_r1 + - brdnode_2_r2 + - brdnode_152_router0 + - brdnode_153_router0 + - brdnode_3_rr + - rnode_3_client + - brdnode_154_router0 + - brdnode_4_router0 + - hnode_180_exabgp + - brdnode_20_r1 + - rnode_20_r2 + - rnode_20_r3 + - brdnode_20_r4 + retries: 45 + interval: 3 + +probes: + - name: AS151 learns AS150 prefix through the BIRD route server + type: exec + service: brdnode_151_router0 + command: birdc show route | grep -q '10.150.0.0/24' + expect_exit: 0 + retries: 40 + interval: 5 + + - name: AS152 FRR router learns AS153 route through mixed-backend iBGP + type: exec + service: brdnode_152_router0 + command: vtysh -c "show ip bgp" | grep -q '10.153.0.0/24' + expect_exit: 0 + retries: 40 + interval: 5 + + - name: AS3 FRR route-reflector client learns AS154 route + type: exec + service: rnode_3_client + command: vtysh -c "show ip bgp" | grep -q '10.154.0.0/24' + expect_exit: 0 + retries: 40 + interval: 5 + + - name: AS4 FRR router learns ExaBGP static route + type: exec + service: brdnode_4_router0 + command: vtysh -c "show ip bgp 198.51.100.0/24" | grep -q '198.51.100.0/24' + expect_exit: 0 + retries: 40 + interval: 5 + +test_programs: + - name: A14 control-plane regression runtime validation + script: test_runtime.py + timeout: 1200 diff --git a/examples/basic/A14_control_plane_regression/test_runtime.py b/examples/basic/A14_control_plane_regression/test_runtime.py new file mode 100644 index 000000000..51fb9165b --- /dev/null +++ b/examples/basic/A14_control_plane_regression/test_runtime.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from seedemu.testing import ComposeRuntimeTest, ComposeService + + +ROUTING_BACKEND_LABEL = "org.seedsecuritylabs.seedemu.meta.seedemu_routing_backend" +ASN_LABEL = "org.seedsecuritylabs.seedemu.meta.asn" +NODE_LABEL = "org.seedsecuritylabs.seedemu.meta.nodename" + + +def require_backend(test: ComposeRuntimeTest, service: ComposeService, backend: str) -> None: + actual = service.labels.get(ROUTING_BACKEND_LABEL) + label = "AS{} {} uses {} routing backend".format( + service.labels.get(ASN_LABEL), + service.labels.get(NODE_LABEL), + backend, + ) + test.structural_check(label, actual == backend, "expected {}, found {}".format(backend, actual)) + + +def record_mpls_host_capability(test: ComposeRuntimeTest, service: ComposeService) -> None: + result = test.exec(service, "test -d /proc/sys/net/mpls || test -e /proc/modules", timeout=20) + available = result["exit"] == 0 + test.structural_check( + "MPLS dataplane probe is host-gated", + True, + "host kernel capability visible" if available else "host kernel capability not visible; dataplane probe skipped", + ) + + +def main() -> int: + test = ComposeRuntimeTest(__file__) + + rs100 = test.require_service(100, "ix100", "IX100 BIRD route server is generated") + as150_router = test.require_service(150, "router0") + as151_router = test.require_service(151, "router0") + as2_r1 = test.require_service(2, "r1") + as2_r2 = test.require_service(2, "r2") + as152_router = test.require_service(152, "router0") + as153_router = test.require_service(153, "router0") + + as3_rr = test.require_service(3, "rr") + as3_client = test.require_service(3, "client") + as154_router = test.require_service(154, "router0") + + as4_router = test.require_service(4, "router0") + speaker = test.require_service(180, "exabgp") + + as20_r1 = test.require_service(20, "r1") + as20_r2 = test.require_service(20, "r2") + as20_r3 = test.require_service(20, "r3") + as20_r4 = test.require_service(20, "r4") + + if rs100: + require_backend(test, rs100, "bird") + test.exec_check("IX100 route server starts BIRD", rs100, "pgrep -x bird >/dev/null") + test.exec_check("IX100 route server peers with AS150", rs100, "grep -q 'neighbor 10.100.0.150 as 150' /etc/bird/bird.conf") + test.exec_check("IX100 route server peers with AS151", rs100, "grep -q 'neighbor 10.100.0.151 as 151' /etc/bird/bird.conf") + test.exec_check("IX100 route server renders rs client", rs100, "grep -q 'rs client' /etc/bird/bird.conf") + + if as150_router: + require_backend(test, as150_router, "bird") + test.exec_check("AS150 route-server client starts BIRD", as150_router, "pgrep -x bird >/dev/null") + test.exec_check("AS150 route-server session is established", as150_router, "birdc show protocols | grep -q 'p_rs100.*Established'", retries=15) + + if as151_router: + require_backend(test, as151_router, "bird") + test.exec_check("AS151 route-server client starts BIRD", as151_router, "pgrep -x bird >/dev/null") + test.exec_check("AS151 route-server session is established", as151_router, "birdc show protocols | grep -q 'p_rs100.*Established'", retries=15) + + if as151_router: + test.exec_check("AS151 learns AS150 route through route server", as151_router, "birdc show route | grep -q '10.150.0.0/24'", retries=15) + + if as2_r1: + require_backend(test, as2_r1, "bird") + test.exec_check("AS2 r1 starts BIRD", as2_r1, "pgrep -x bird >/dev/null") + test.exec_check("AS2 r1 does not carry FRR config", as2_r1, "test ! -e /etc/frr/frr.conf") + test.exec_check("AS2 r1 renders OSPF intent", as2_r1, "grep -q 'protocol ospf ospf1' /etc/bird/bird.conf") + test.exec_check("AS2 r1 iBGP to FRR r2 is established", as2_r1, "birdc show protocols | grep -q 'ibgp1.*Established'", retries=15) + test.exec_check("AS2 r1 learns AS153 route", as2_r1, "birdc show route | grep -q '10.153.0.0/24'", retries=15) + + if as2_r2: + require_backend(test, as2_r2, "frr") + test.exec_check("AS2 r2 starts FRR bgpd", as2_r2, "pgrep -x bgpd >/dev/null") + test.exec_check("AS2 r2 does not start BIRD", as2_r2, "! pgrep -x bird >/dev/null") + test.exec_check("AS2 r2 renders FRR OSPF intent", as2_r2, "grep -q 'router ospf' /etc/frr/frr.conf") + test.exec_check("AS2 r2 learns AS152 route through iBGP", as2_r2, "vtysh -c 'show ip bgp' | grep -q '10.152.0.0/24'", retries=15) + + if as152_router: + require_backend(test, as152_router, "frr") + test.exec_check("AS152 FRR router learns AS153 route", as152_router, "vtysh -c 'show ip bgp' | grep -q '10.153.0.0/24'", retries=15) + + if as153_router: + require_backend(test, as153_router, "bird") + test.exec_check("AS153 BIRD router learns AS152 route", as153_router, "birdc show route | grep -q '10.152.0.0/24'", retries=15) + + if as3_rr: + require_backend(test, as3_rr, "frr") + test.exec_check("AS3 RR starts FRR bgpd", as3_rr, "pgrep -x bgpd >/dev/null") + test.exec_check("AS3 RR renders cluster-id", as3_rr, "grep -q 'bgp cluster-id 10.3.0.1' /etc/frr/frr.conf") + test.exec_check("AS3 RR renders route-reflector client", as3_rr, "grep -q 'route-reflector-client' /etc/frr/frr.conf") + + if as3_client: + require_backend(test, as3_client, "frr") + test.exec_check("AS3 RR client starts FRR bgpd", as3_client, "pgrep -x bgpd >/dev/null") + test.exec_check("AS3 RR client learns AS154 route", as3_client, "vtysh -c 'show ip bgp' | grep -q '10.154.0.0/24'", retries=15) + + if as154_router: + require_backend(test, as154_router, "bird") + test.exec_check("AS154 BIRD router peers with AS3 RR", as154_router, "birdc show protocols | grep -q 'Established'", retries=15) + + if as4_router: + require_backend(test, as4_router, "frr") + test.exec_check("AS4 ExaBGP peer is an FRR router", as4_router, "grep -q 'neighbor 10.104.0.180 remote-as 180' /etc/frr/frr.conf") + test.exec_check("AS4 router does not start BIRD", as4_router, "! pgrep -x bird >/dev/null") + test.exec_check("AS4 learns ExaBGP static route", as4_router, "vtysh -c 'show ip bgp 198.51.100.0/24' | grep -q '198.51.100.0/24'", retries=15) + + if speaker: + test.exec_check("ExaBGP speaker process is running", speaker, "pgrep -f 'exabgp /etc/exabgp/exabgp.conf' >/dev/null", retries=15) + test.exec_check("ExaBGP manual control FIFO is available", speaker, "test -p /run/exabgp/manual.in") + test.exec_check("ExaBGP config peers with AS4 router", speaker, "grep -q 'neighbor 10.104.0.4' /etc/exabgp/exabgp.conf") + test.exec_check("ExaBGP config announces static IPv4 route", speaker, "grep -q 'route 198.51.100.0/24 next-hop self' /etc/exabgp/exabgp.conf") + + if as20_r1: + test.exec_check("AS20 r1 has MPLS/LDP on net0", as20_r1, "grep -q '^net0$' /mpls_ifaces.txt && grep -q 'mpls ldp' /etc/frr/frr.conf") + record_mpls_host_capability(test, as20_r1) + + if as20_r2: + test.exec_check("AS20 r2 has MPLS/LDP on net0 and net1", as20_r2, "grep -q '^net0$' /mpls_ifaces.txt && grep -q '^net1$' /mpls_ifaces.txt && grep -q 'mpls ldp' /etc/frr/frr.conf") + + if as20_r3: + test.exec_check("AS20 r3 has MPLS/LDP on net1 and net2", as20_r3, "grep -q '^net1$' /mpls_ifaces.txt && grep -q '^net2$' /mpls_ifaces.txt && grep -q 'mpls ldp' /etc/frr/frr.conf") + + if as20_r4: + test.exec_check("AS20 r4 has MPLS/LDP on net2", as20_r4, "grep -q '^net2$' /mpls_ifaces.txt && grep -q 'mpls ldp' /etc/frr/frr.conf") + + test.write_summary("a14-control-plane-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main())