From 5e7171623b82651c0a73102bff0c603e63a565e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 14 Oct 2026 08:11:42 +0200 Subject: [PATCH 1/6] test: add igmp_basic_3duts, IGMP basic spread over three DUTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same check as igmp_basic, but each DUT needs only one host data link and one mgmt link, so it maps onto rigs where DUTs have a single host-facing port. The receiver sits at the far end so the membership report has to cross two switches before the middle DUT prunes its own host port. Signed-off-by: Mattias Walström --- test/case/interfaces/Readme.adoc | 4 + test/case/interfaces/igmp.yaml | 3 + .../interfaces/igmp_basic_3duts/Readme.adoc | 1 + .../interfaces/igmp_basic_3duts/test.adoc | 49 +++++++ test/case/interfaces/igmp_basic_3duts/test.py | 123 ++++++++++++++++++ .../interfaces/igmp_basic_3duts/topology.dot | 43 ++++++ .../interfaces/igmp_basic_3duts/topology.svg | 115 ++++++++++++++++ 7 files changed, 338 insertions(+) create mode 120000 test/case/interfaces/igmp_basic_3duts/Readme.adoc create mode 100644 test/case/interfaces/igmp_basic_3duts/test.adoc create mode 100755 test/case/interfaces/igmp_basic_3duts/test.py create mode 100644 test/case/interfaces/igmp_basic_3duts/topology.dot create mode 100644 test/case/interfaces/igmp_basic_3duts/topology.svg diff --git a/test/case/interfaces/Readme.adoc b/test/case/interfaces/Readme.adoc index cee1f2aacb..23d7c34564 100644 --- a/test/case/interfaces/Readme.adoc +++ b/test/case/interfaces/Readme.adoc @@ -102,6 +102,10 @@ include::igmp_vlan/Readme.adoc[] <<< +include::igmp_basic_3duts/Readme.adoc[] + +<<< + include::static_multicast_filters/Readme.adoc[] <<< diff --git a/test/case/interfaces/igmp.yaml b/test/case/interfaces/igmp.yaml index 232e60437f..9a4d7ad52b 100644 --- a/test/case/interfaces/igmp.yaml +++ b/test/case/interfaces/igmp.yaml @@ -5,5 +5,8 @@ - name: IGMP VLAN case: igmp_vlan/test.py +- name: IGMP Basic 3 DUTs + case: igmp_basic_3duts/test.py + - name: Static Multicast Filters case: static_multicast_filters/test.py diff --git a/test/case/interfaces/igmp_basic_3duts/Readme.adoc b/test/case/interfaces/igmp_basic_3duts/Readme.adoc new file mode 120000 index 0000000000..ae32c84120 --- /dev/null +++ b/test/case/interfaces/igmp_basic_3duts/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/igmp_basic_3duts/test.adoc b/test/case/interfaces/igmp_basic_3duts/test.adoc new file mode 100644 index 0000000000..f4921303fb --- /dev/null +++ b/test/case/interfaces/igmp_basic_3duts/test.adoc @@ -0,0 +1,49 @@ +=== IGMP Basic 3 DUTs + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/igmp_basic_3duts] + +==== Description + +The same check as IGMP basic, spread over three bridges in a chain so +that each DUT only needs a single port facing the host. A multicast sender sits +behind dut1, a non-member behind dut2, and the receiver behind dut3. +The membership report from the receiver must propagate through dut3 +and dut2 up to the querier on dut1 before dut2 prunes the group from +its own host port. + +.... + 10.0.0.1 + .-----------. .----------------. .-----------. + | dut1 | | dut2 | | dut3 | + '-data-link-'-------'-west-data-east-'-------'-link-data-' + | | | 10.0.0.0/24 + .-data1-. .-data2-. .-data3-. + | msend | | !memb | | mrecv | + '-------' '-------' '-------' + .2 .3 .4 + HOST +.... + +With no membership the group 224.1.1.1 is flooded to both `!memb` and +`mrecv`. Once `mrecv` joins, dut2 and dut1 learn the group on their +links toward dut3, and `!memb` no longer receives it. + +==== Topology + +image::topology.svg[IGMP Basic 3 DUTs topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure bridges with IGMP snooping on all DUTs, querier on dut1 with 10.0.0.1/24 +. Verify host:data1, host:data2 and host:data3 can reach 10.0.0.1 +. Start multicast sender on host:data1, group 224.1.1.1 +. Verify group 224.1.1.1 is flooded to host:data2 and host:data3 +. Join multicast group 224.1.1.1 on host:data3 +. Verify dut3 has learned group 224.1.1.1 on dut3:data +. Verify dut2 has learned group 224.1.1.1 on dut2:east +. Verify dut1 has learned group 224.1.1.1 on dut1:link +. Verify group 224.1.1.1 is still received on host:data3 +. Verify group 224.1.1.1 is no longer received on host:data2 + + diff --git a/test/case/interfaces/igmp_basic_3duts/test.py b/test/case/interfaces/igmp_basic_3duts/test.py new file mode 100755 index 0000000000..e33a307405 --- /dev/null +++ b/test/case/interfaces/igmp_basic_3duts/test.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""IGMP basic with three DUTs + +The same check as IGMP basic, spread over three bridges in a chain so +that each DUT only needs a single port facing the host. A multicast sender sits +behind dut1, a non-member behind dut2, and the receiver behind dut3. +The membership report from the receiver must propagate through dut3 +and dut2 up to the querier on dut1 before dut2 prunes the group from +its own host port. + +.... + 10.0.0.1 + .-----------. .----------------. .-----------. + | dut1 | | dut2 | | dut3 | + '-data-link-'-------'-west-data-east-'-------'-link-data-' + | | | 10.0.0.0/24 + .-data1-. .-data2-. .-data3-. + | msend | | !memb | | mrecv | + '-------' '-------' '-------' + .2 .3 .4 + HOST +.... + +With no membership the group 224.1.1.1 is flooded to both `!memb` and +`mrecv`. Once `mrecv` joins, dut2 and dut1 learn the group on their +links toward dut3, and `!memb` no longer receives it. +""" + +import infamy +import infamy.iface as iface +import infamy.multicast as mcast +from infamy.util import until, parallel + +query_interval = 4 + + +def bridge(ports, address=None): + br0 = { + "name": "br0", + "enabled": True, + "type": "infix-if-type:bridge", + "bridge": { + "multicast": { + "snooping": True, + "query-interval": query_interval + } + } + } + if address: + br0["ipv4"] = {"address": [{"ip": address, "prefix-length": 24}]} + + return { + "ietf-interfaces": { + "interfaces": { + "interface": [br0] + [{ + "name": port, + "enabled": True, + "bridge-port": {"bridge": "br0"} + } for port in ports] + } + } + } + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUTs"): + env = infamy.Env() + dut1, dut2, dut3 = parallel(lambda: env.attach("dut1", "mgmt"), + lambda: env.attach("dut2", "mgmt"), + lambda: env.attach("dut3", "mgmt")) + _, d1data = env.ltop.xlate("dut1", "data") + _, d1link = env.ltop.xlate("dut1", "link") + _, d2data = env.ltop.xlate("dut2", "data") + _, d2west = env.ltop.xlate("dut2", "west") + _, d2east = env.ltop.xlate("dut2", "east") + _, d3data = env.ltop.xlate("dut3", "data") + _, d3link = env.ltop.xlate("dut3", "link") + + _, hsend = env.ltop.xlate("host", "data1") + _, hnojoin = env.ltop.xlate("host", "data2") + _, hreceive = env.ltop.xlate("host", "data3") + + with test.step("Configure bridges with IGMP snooping on all DUTs, querier on dut1 with 10.0.0.1/24"): + parallel(lambda: dut1.put_config_dicts(bridge([d1data, d1link], "10.0.0.1")), + lambda: dut2.put_config_dicts(bridge([d2data, d2west, d2east])), + lambda: dut3.put_config_dicts(bridge([d3data, d3link]))) + + with infamy.IsolatedMacVlan(hsend) as send_ns, \ + infamy.IsolatedMacVlan(hnojoin) as nojoin_ns, \ + infamy.IsolatedMacVlan(hreceive) as receive_ns: + + with test.step("Verify host:data1, host:data2 and host:data3 can reach 10.0.0.1"): + send_ns.addip("10.0.0.2") + nojoin_ns.addip("10.0.0.3") + receive_ns.addip("10.0.0.4") + parallel(lambda: send_ns.must_reach("10.0.0.1"), + lambda: nojoin_ns.must_reach("10.0.0.1"), + lambda: receive_ns.must_reach("10.0.0.1")) + + with test.step("Start multicast sender on host:data1, group 224.1.1.1"): + with mcast.MCastSender(send_ns, "224.1.1.1"): + with test.step("Verify group 224.1.1.1 is flooded to host:data2 and host:data3"): + parallel(lambda: nojoin_ns.must_receive("ip dst 224.1.1.1"), + lambda: receive_ns.must_receive("ip dst 224.1.1.1")) + + with test.step("Join multicast group 224.1.1.1 on host:data3"): + with mcast.MCastReceiver(receive_ns, "224.1.1.1"): + with test.step("Verify dut3 has learned group 224.1.1.1 on dut3:data"): + until(lambda: iface.exist_bridge_multicast_filter(dut3, "224.1.1.1", d3data, "br0"), attempts=10) + + with test.step("Verify dut2 has learned group 224.1.1.1 on dut2:east"): + until(lambda: iface.exist_bridge_multicast_filter(dut2, "224.1.1.1", d2east, "br0"), attempts=10) + + with test.step("Verify dut1 has learned group 224.1.1.1 on dut1:link"): + until(lambda: iface.exist_bridge_multicast_filter(dut1, "224.1.1.1", d1link, "br0"), attempts=10) + + with test.step("Verify group 224.1.1.1 is still received on host:data3"): + receive_ns.must_receive("ip dst 224.1.1.1") + + with test.step("Verify group 224.1.1.1 is no longer received on host:data2"): + nojoin_ns.must_not_receive("ip dst 224.1.1.1") + + test.succeed() diff --git a/test/case/interfaces/igmp_basic_3duts/topology.dot b/test/case/interfaces/igmp_basic_3duts/topology.dot new file mode 100644 index 0000000000..93a2852a74 --- /dev/null +++ b/test/case/interfaces/igmp_basic_3duts/topology.dot @@ -0,0 +1,43 @@ +graph "igmp-basic-3duts" { + layout="neato"; + overlap="false"; + esep="+40"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | data1 | mgmt2 | data2 | mgmt3 | data3 }", + pos="0,10!", + requires="controller", + ]; + + dut1 [ + label="{ mgmt | data | link } | dut1 \n\n(br0)\n10.0.0.1/24", + pos="12,20!", + requires="infix", + ]; + + dut2 [ + label="{ west | mgmt | data | east } | dut2 \n\n(br0)", + pos="12,10!", + requires="infix", + ]; + + dut3 [ + label="{ link | mgmt | data } | dut3 \n\n(br0)", + pos="12,0!", + requires="infix", + ]; + + host:mgmt1 -- dut1:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- dut2:mgmt [requires="mgmt", color="lightgray"] + host:mgmt3 -- dut3:mgmt [requires="mgmt", color="lightgray"] + + host:data1 -- dut1:data [color=black, fontcolor=black, taillabel="10.0.0.2/24"] + host:data2 -- dut2:data [color=black, fontcolor=black, taillabel="10.0.0.3/24"] + host:data3 -- dut3:data [color=black, fontcolor=black, taillabel="10.0.0.4/24"] + + dut1:link -- dut2:west [color=black] + dut2:east -- dut3:link [color=black] +} diff --git a/test/case/interfaces/igmp_basic_3duts/topology.svg b/test/case/interfaces/igmp_basic_3duts/topology.svg new file mode 100644 index 0000000000..82e44582df --- /dev/null +++ b/test/case/interfaces/igmp_basic_3duts/topology.svg @@ -0,0 +1,115 @@ + + + + + + +igmp-basic-3duts + + + +host + +host + +mgmt1 + +data1 + +mgmt2 + +data2 + +mgmt3 + +data3 + + + +dut1 + +mgmt + +data + +link + +dut1 +(br0) +10.0.0.1/24 + + + +host:mgmt1--dut1:mgmt + + + + +host:data1--dut1:data + +10.0.0.2/24 + + + +dut2 + +west + +mgmt + +data + +east + +dut2 +(br0) + + + +host:mgmt2--dut2:mgmt + + + + +host:data2--dut2:data + +10.0.0.3/24 + + + +dut3 + +link + +mgmt + +data + +dut3 +(br0) + + + +host:mgmt3--dut3:mgmt + + + + +host:data3--dut3:data + +10.0.0.4/24 + + + +dut1:link--dut2:west + + + + +dut2:east--dut3:link + + + + From b1d85ea9db8c0a46d58eb354b705d8ea04cbb1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 14 Oct 2026 08:11:42 +0200 Subject: [PATCH 2/6] test: ospf_multiarea: one step per neighbor adjacency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single step waiting for five adjacencies could sit for 17 minutes and then fail without saying which pair never reached full. Signed-off-by: Mattias Walström --- test/case/routing/ospf_multiarea/ospfv2.adoc | 6 +++++- test/case/routing/ospf_multiarea/ospfv3.adoc | 6 +++++- test/case/routing/ospf_multiarea/test.py | 11 +++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/test/case/routing/ospf_multiarea/ospfv2.adoc b/test/case/routing/ospf_multiarea/ospfv2.adoc index 540ab41618..4327989426 100644 --- a/test/case/routing/ospf_multiarea/ospfv2.adoc +++ b/test/case/routing/ospf_multiarea/ospfv2.adoc @@ -62,7 +62,11 @@ image::topology.svg[OSPFv2 with Multiple Areas topology, align=center, scaledwid . Set up topology and attach to target DUTs . Configure targets -. Wait for all neighbors to peer +. Wait for R1 to reach full adjacency with R2 (router-id 1.1.1.1) on R1:ring1 in area 0.0.0.0 +. Wait for R1 to reach full adjacency with R3 (router-id 10.0.0.3) on R1:cross in area 0.0.0.1 +. Wait for R2 to reach full adjacency with R3 (router-id 10.0.0.3) on R2:ring1 in area 0.0.0.1 +. Wait for R2 to reach full adjacency with R1 (router-id 10.0.0.1) on R2:ring2 in area 0.0.0.0 +. Wait for R2 to reach full adjacency with R4 (router-id 10.0.0.4) on R2:cross in area 0.0.0.2 . Wait for routes from OSPFv2 on all routers . Verify Area 0.0.0.1 on R3 is NSSA area . Verify R1:ring2 is of type point-to-point diff --git a/test/case/routing/ospf_multiarea/ospfv3.adoc b/test/case/routing/ospf_multiarea/ospfv3.adoc index e46f8234b6..17be7c49a9 100644 --- a/test/case/routing/ospf_multiarea/ospfv3.adoc +++ b/test/case/routing/ospf_multiarea/ospfv3.adoc @@ -62,7 +62,11 @@ image::topology.svg[OSPFv3 with Multiple Areas topology, align=center, scaledwid . Set up topology and attach to target DUTs . Configure targets -. Wait for all neighbors to peer +. Wait for R1 to reach full adjacency with R2 (router-id 1.1.1.1) on R1:ring1 in area 0.0.0.0 +. Wait for R1 to reach full adjacency with R3 (router-id 10.0.0.3) on R1:cross in area 0.0.0.1 +. Wait for R2 to reach full adjacency with R3 (router-id 10.0.0.3) on R2:ring1 in area 0.0.0.1 +. Wait for R2 to reach full adjacency with R1 (router-id 10.0.0.1) on R2:ring2 in area 0.0.0.0 +. Wait for R2 to reach full adjacency with R4 (router-id 10.0.0.4) on R2:cross in area 0.0.0.2 . Wait for routes from OSPFv3 on all routers . Verify Area 0.0.0.1 on R3 is NSSA area . Verify R1:ring2 is of type point-to-point diff --git a/test/case/routing/ospf_multiarea/test.py b/test/case/routing/ospf_multiarea/test.py index b48cb9e7b1..ade6dfbcf8 100755 --- a/test/case/routing/ospf_multiarea/test.py +++ b/test/case/routing/ospf_multiarea/test.py @@ -330,12 +330,19 @@ def lo(name): lambda: config_target3(R3, R3ring2, R3cross, R3data, param), lambda: config_target4(R4, R4ring1, R4cross, R4data, param)) - with test.step("Wait for all neighbors to peer"): - print("Waiting for neighbors to peer") + with test.step("Wait for R1 to reach full adjacency with R2 (router-id 1.1.1.1) on R1:ring1 in area 0.0.0.0"): until(lambda: route.ospf_get_neighbor(R1, "0.0.0.0", R1ring1, "1.1.1.1", proto=instance), attempts=200) + + with test.step("Wait for R1 to reach full adjacency with R3 (router-id 10.0.0.3) on R1:cross in area 0.0.0.1"): until(lambda: route.ospf_get_neighbor(R1, "0.0.0.1", R1cross, "10.0.0.3", proto=instance), attempts=200) + + with test.step("Wait for R2 to reach full adjacency with R3 (router-id 10.0.0.3) on R2:ring1 in area 0.0.0.1"): until(lambda: route.ospf_get_neighbor(R2, "0.0.0.1", R2ring1, "10.0.0.3", proto=instance), attempts=200) + + with test.step("Wait for R2 to reach full adjacency with R1 (router-id 10.0.0.1) on R2:ring2 in area 0.0.0.0"): until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2ring2, "10.0.0.1", proto=instance), attempts=200) + + with test.step("Wait for R2 to reach full adjacency with R4 (router-id 10.0.0.4) on R2:cross in area 0.0.0.2"): until(lambda: route.ospf_get_neighbor(R2, "0.0.0.2", R2cross, "10.0.0.4", proto=instance), attempts=200) with test.step(f"Wait for routes from {version} on all routers"): From 9120e4cf323e62dbc9e41fdaca7cf64d3162f3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 14 Oct 2026 08:11:42 +0200 Subject: [PATCH 3/6] test: dhcp/server_boot: retry the host MAC change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on the BPi-R3 test system: the full suite failed only here, with "RTNETLINK answers: Resource temporarily unavailable" from the MAC change. On a passthru macvlan the change is forwarded to the parent interface, and the kernel returned EAGAIN for it. The macvlan keeps its old MAC when that fails, and unchecked the step failed later on the lease. With check=True the command raises, and until() retries it. Signed-off-by: Mattias Walström --- test/case/dhcp/server_boot/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/case/dhcp/server_boot/test.py b/test/case/dhcp/server_boot/test.py index d4f580cfec..5c6880d16e 100755 --- a/test/case/dhcp/server_boot/test.py +++ b/test/case/dhcp/server_boot/test.py @@ -80,7 +80,7 @@ until(lambda: client.lease() == (SERVER, SUBNET_FILE)) with test.step("Verify static host gets its own boot file"): - ns.run(["ip", "link", "set", "iface", "address", HOST_MAC]) + until(lambda: ns.run(["ip", "link", "set", "iface", "address", HOST_MAC], check=True)) until(lambda: client.lease() == (SERVER, HOST_FILE)) with test.step("Remove subnet and host boot, verify client falls back to global boot file and server"): From 42def34ca5da10baefea50bd78d210b07637bb29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 14 Oct 2026 08:29:41 +0200 Subject: [PATCH 4/6] test: drop wifi_station_from_scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not needed, the case it covers is not a big risk. Signed-off-by: Mattias Walström --- test/case/interfaces/wifi.yaml | 3 - .../wifi_station_from_scan/Readme.adoc | 1 - .../wifi_station_from_scan/test.adoc | 32 ------- .../interfaces/wifi_station_from_scan/test.py | 92 ------------------- .../wifi_station_from_scan/topology.dot | 44 --------- .../wifi_station_from_scan/topology.svg | 68 -------------- 6 files changed, 240 deletions(-) delete mode 120000 test/case/interfaces/wifi_station_from_scan/Readme.adoc delete mode 100644 test/case/interfaces/wifi_station_from_scan/test.adoc delete mode 100755 test/case/interfaces/wifi_station_from_scan/test.py delete mode 100644 test/case/interfaces/wifi_station_from_scan/topology.dot delete mode 100644 test/case/interfaces/wifi_station_from_scan/topology.svg diff --git a/test/case/interfaces/wifi.yaml b/test/case/interfaces/wifi.yaml index 55efbeaf47..824e2fa697 100644 --- a/test/case/interfaces/wifi.yaml +++ b/test/case/interfaces/wifi.yaml @@ -17,8 +17,5 @@ - name: WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) backhaul case: wifi_wds_repeater/test.py -- name: WiFi station set up from a scan-only interface - case: wifi_station_from_scan/test.py - - name: WiFi channel survey on a connected radio case: wifi_channel_survey/test.py diff --git a/test/case/interfaces/wifi_station_from_scan/Readme.adoc b/test/case/interfaces/wifi_station_from_scan/Readme.adoc deleted file mode 120000 index ae32c84120..0000000000 --- a/test/case/interfaces/wifi_station_from_scan/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_station_from_scan/test.adoc b/test/case/interfaces/wifi_station_from_scan/test.adoc deleted file mode 100644 index 3eb98e127d..0000000000 --- a/test/case/interfaces/wifi_station_from_scan/test.adoc +++ /dev/null @@ -1,32 +0,0 @@ -=== WiFi station set up from a scan-only interface - -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_station_from_scan] - -==== Description - -A WiFi interface with only a radio, no station or access point, is in -scan-only mode. That is how the factory configuration of boards with a -built-in radio ships, so the usual way to get online is to add the station -settings to that existing interface. The connection has to come up from -that change alone, without a reboot or a service restart. - -Topology: -.... - host ==(mgmt)== ap ))) ~ cell ~ ((( station ==(mgmt)== host -.... - -==== Topology - -image::topology.svg[WiFi station set up from a scan-only interface topology, align=center, scaledwidth=75%] - -==== Sequence - -. Set up topology and attach to the ap and the station -. Configure the ap with access point 'infix-scan' and a DHCP server on 192.168.21.1 -. Configure wifi0 on the station with only radio0, scan-only mode -. Verify the station sees 'infix-scan' in its scan results -. Add station settings for 'infix-scan' to wifi0 on the station -. Verify the station associates to 'infix-scan' without a restart -. Verify the station leases 192.168.21.100 over wifi - - diff --git a/test/case/interfaces/wifi_station_from_scan/test.py b/test/case/interfaces/wifi_station_from_scan/test.py deleted file mode 100755 index b48488ad96..0000000000 --- a/test/case/interfaces/wifi_station_from_scan/test.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -r""" -WiFi station set up from a scan-only interface - -A WiFi interface with only a radio, no station or access point, is in -scan-only mode. That is how the factory configuration of boards with a -built-in radio ships, so the usual way to get online is to add the station -settings to that existing interface. The connection has to come up from -that change alone, without a reboot or a service restart. - -Topology: -.... - host ==(mgmt)== ap ))) ~ cell ~ ((( station ==(mgmt)== host -.... -""" -import infamy -import infamy.iface as iface -import infamy.wifi as wifi -from infamy.util import until, parallel - -SSID = "infix-scan" -PSK = "infixinfix" - -SUBNET = "192.168.21.0/24" -AP_IP = "192.168.21.1" -LEASE = "192.168.21.100" - - -with infamy.Test() as test: - with test.step("Set up topology and attach to the ap and the station"): - env = infamy.Env() - ap, station = parallel( - lambda: env.attach("ap", "mgmt"), - lambda: env.attach("station", "mgmt"), - ) - wifi.skip_unless_supported(test, ap, station) - - with test.step("Configure the ap with access point 'infix-scan' and a DHCP server on 192.168.21.1"): - ap.put_config_dicts({ - "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), - "ietf-keystore": wifi.keystore({"wifi": PSK}), - "ietf-interfaces": {"interfaces": {"interface": [ - wifi.iface("wifi0", { - "radio": "radio0", - "access-point": { - "ssid": SSID, - "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"}, - }, - }, ipv4={"address": [{"ip": AP_IP, "prefix-length": 24}]}), - ]}}, - "infix-dhcp-server": {"dhcp-server": {"subnet": [{ - "subnet": SUBNET, - "pool": {"start-address": LEASE, "end-address": LEASE}, - }]}}, - }) - - with test.step("Configure wifi0 on the station with only radio0, scan-only mode"): - station.put_config_dicts({ - "ietf-hardware": wifi.hardware(wifi.radio("radio0")), - "ietf-interfaces": {"interfaces": {"interface": [ - wifi.iface("wifi0", {"radio": "radio0"}, - ipv4={"infix-dhcp-client:dhcp": {}}), - ]}}, - }) - - with test.step("Verify the station sees 'infix-scan' in its scan results"): - until(lambda: SSID in {n.get("ssid") for n in - wifi.station(station).get("scan-results") or []}, - attempts=60, interval=2) - - with test.step("Add station settings for 'infix-scan' to wifi0 on the station"): - station.put_config_dicts({ - "ietf-keystore": wifi.keystore({"wifi": PSK}), - "ietf-interfaces": {"interfaces": {"interface": [ - {"name": "wifi0", "infix-interfaces:wifi": { - "radio": "radio0", - "station": { - "ssid": SSID, - "security": {"mode": "auto", "secret": "wifi"}, - }, - }}, - ]}}, - }) - - with test.step("Verify the station associates to 'infix-scan' without a restart"): - until(lambda: wifi.associated(station, SSID), attempts=60, interval=2) - - with test.step("Verify the station leases 192.168.21.100 over wifi"): - until(lambda: iface.address_exist(station, "wifi0", LEASE), - attempts=60, interval=2) - - test.succeed() diff --git a/test/case/interfaces/wifi_station_from_scan/topology.dot b/test/case/interfaces/wifi_station_from_scan/topology.dot deleted file mode 100644 index 7a05357a1d..0000000000 --- a/test/case/interfaces/wifi_station_from_scan/topology.dot +++ /dev/null @@ -1,44 +0,0 @@ -graph "wifi-station-from-scan" { - layout="neato"; - overlap="false"; - esep="+40"; - - node [shape=record, fontname="DejaVu Sans Mono, Book"]; - edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; - - host [ - label="host | { mgmt1 | mgmt2 }", - pos="0,0!", - requires="controller", - ]; - - ap [ - label="{ mgmt | wifi } | ap", - pos="6,2!", - requires="infix", - ]; - - station [ - label="{ mgmt | wifi } | station", - pos="6,-2!", - requires="infix", - ]; - - // The wireless cell the ap and the station share, modelled as a medium - // node both join (one radio each, same index -> same cell): - // * physical: maps onto a real over-the-air RF cell; - // * virtual (qeneth): maps onto one of the multicast cells over which - // the wifimedium relay bridges mac80211_hwsim frames. - // The mapper guarantees the two DUTs actually share this medium. - cell [ - label="cell", - pos="9,0!", - requires="wifi-radio", - ]; - - host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"] - host:mgmt2 -- station:mgmt [requires="mgmt", color="lightgray"] - - ap:wifi -- cell [requires="wifi2.4GHz", style="dashed"] - station:wifi -- cell [requires="wifi2.4GHz", style="dashed"] -} diff --git a/test/case/interfaces/wifi_station_from_scan/topology.svg b/test/case/interfaces/wifi_station_from_scan/topology.svg deleted file mode 100644 index 9c8741affb..0000000000 --- a/test/case/interfaces/wifi_station_from_scan/topology.svg +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - -wifi-station-from-scan - - - -host - -host - -mgmt1 - -mgmt2 - - - -ap - -mgmt - -wifi - -ap - - - -host:mgmt1--ap:mgmt - - - - -station - -mgmt - -wifi - -station - - - -host:mgmt2--station:mgmt - - - - -cell - -cell - - - -ap:wifi--cell - - - - -station:wifi--cell - - - - From 29f6ac24f99e0fcc50fff143b46c5c8a4d92f5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 14 Oct 2026 10:13:30 +0200 Subject: [PATCH 5/6] statd: report the installer's last-error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rauc-installation-status already emits it, statd dropped it on the way into /system-state/software/installer. Signed-off-by: Mattias Walström --- src/statd/python/yanger/ietf_system.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 671c4beca0..c94de06c38 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -266,6 +266,8 @@ def add_software(out): installer_status = HOST.run_json(["rauc-installation-status"], {}) if installer_status.get("operation", {}): installer["operation"] = installer_status["operation"] + if installer_status.get("last-error"): + installer["last-error"] = installer_status["last-error"] if "progress" in installer_status: progress = {} From f692e585f4cb0077d8da4f39d1907e87aa541f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 14 Oct 2026 10:13:30 +0200 Subject: [PATCH 6/6] test: system/upgrade: fail on install error with the reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on the BPi-R3 rig: the installer went idle with "Installing failed." and the test still passed the wait step, only to trip on the boot order. Signed-off-by: Mattias Walström --- test/case/system/upgrade/test.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/case/system/upgrade/test.py b/test/case/system/upgrade/test.py index 80931dcc76..48faec740c 100755 --- a/test/case/system/upgrade/test.py +++ b/test/case/system/upgrade/test.py @@ -112,9 +112,10 @@ def cleanup(env, old_bootorder): installer = oper["system-state"]["software"]["installer"] if installer["operation"] == "idle": print(installer) - if "last-error" in installer: - print("Install failed:", installer["last-error"]) - test.fail() + error = installer.get("last-error") + message = installer.get("progress", {}).get("message", "") + if error or "failed" in message.lower(): + test.fail(f"Install failed: {error or message}") break time.sleep(1)