ci: cram: dhcpv4 force option: retransmit and report probe failures

Problem: 017-dhcpv4-force-option.t intermittently fails with a bare [2] on
the two probes that follow the Force=0 write, while the Force=1 probe just
before them passes.

Writing a Device.DHCPv4.Server. parameter makes dhcpv4-manager rewrite the
dnsmasq configuration and restart dnsmasq. The test only waited one second
afterwards and dhcpv4_option_probe.py sent its DHCPDISCOVER exactly once,
so a request issued during that restart window was lost and the probe gave
up after the timeout instead of reporting the option state under test.

The reason was invisible: all five probe error paths wrote to stderr, which
the test discards with 2>/dev/null, so every one of them looked identical in
the .t.err artifact.

Solution:
- retransmit an unanswered DHCPDISCOVER/DHCPREQUEST, --retries (default 2)
- print the ERROR line on stdout so it stays visible in the Cram diff
- release the lease on the last probe so no lease is left behind

A passing run is unaffected: retransmission stops as soon as the reply
arrives and nothing is written to stderr on success.

References: PPW-2188
Signed-off-by: Alexandre Fiset <alexandre.fiset@softathome.com>
This commit is contained in:
Alexandre Fiset 2026-07-29 13:26:01 +02:00 committed by Hela Saadani
parent 48a2562bb4
commit 49c0eca805
3 changed files with 32 additions and 14 deletions

View File

@ -13,6 +13,7 @@ Pick test constants:
$ REQ_WITHOUT_TAG=1,3,6,15
$ REQ_WITH_TAG=1,3,6,15,42
$ PROBE_TIMEOUT=12
$ PROBE_RETRIES=2
Create pool option with Force=true:
@ -23,7 +24,7 @@ Create pool option with Force=true:
Force=true, tag 42 absent from DHCPREQUEST PRL -> tag must be present in final DHCPACK:
$ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITHOUT_TAG" --timeout "$PROBE_TIMEOUT" 2>/dev/null
$ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITHOUT_TAG" --timeout "$PROBE_TIMEOUT" --retries "$PROBE_RETRIES" 2>/dev/null
CLIENT_MAC=aa:bb:cc:dd:ee:ff
RECEIVED_TAGS=.*(=|,)42(,|$).* (re)
@ -36,15 +37,16 @@ Set Force=false:
Force=false, tag 42 absent from DHCPREQUEST PRL -> tag must be absent from final DHCPACK:
$ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITHOUT_TAG" --timeout "$PROBE_TIMEOUT" 2>/dev/null
$ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITHOUT_TAG" --timeout "$PROBE_TIMEOUT" --retries "$PROBE_RETRIES" 2>/dev/null
CLIENT_MAC=aa:bb:cc:dd:ee:ff
RECEIVED_TAGS=(?!.*(=|,)42(,|$)).* (re)
Force=false, tag 42 present in DHCPREQUEST PRL -> tag must be present in final DHCPACK:
$ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITH_TAG" --timeout "$PROBE_TIMEOUT" 2>/dev/null
$ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITH_TAG" --timeout "$PROBE_TIMEOUT" --retries "$PROBE_RETRIES" --release 2>/dev/null
CLIENT_MAC=aa:bb:cc:dd:ee:ff
RECEIVED_TAGS=.*(=|,)42(,|$).* (re)
RELEASED=.* (re)
Cleanup:

View File

@ -66,7 +66,18 @@ Use a higher timeout when network delays are expected:
sudo -E python3 dhcpv4_option_probe.py --iface <interface> --request-options "1,3,6,15,42" --timeout 12
```
## Retransmission
`--timeout` is the time waited for a reply per attempt, `--retries` (default `2`)
is how often an unanswered `DHCPDISCOVER`/`DHCPREQUEST` is retransmitted. Writing
a `Device.DHCPv4.Server.` parameter makes `dhcpv4-manager` restart `dnsmasq`, so a
request sent right after such a write can be lost; without retransmission the probe
reports a missing reply instead of the option state it is meant to check.
## Exit codes
- `0`: success (`DHCPACK` received and parsed)
- `2`: error (`DHCPOFFER`/`DHCPACK` missing, `DHCPNAK`, or invalid DHCP response)
The `ERROR:` line explaining a non-zero exit is printed on stdout so it stays
visible in the Cram diff, which discards stderr.

View File

@ -3,8 +3,10 @@
Minimal DHCPv4 probe for Cram:
- Sends DHCPDISCOVER with configurable Parameter Request List (PRL)
- Sends DHCPREQUEST using the offered address and server identifier
- Retransmits unanswered DHCPDISCOVER/DHCPREQUEST (--retries)
- Prints CLIENT_MAC=<mac> then RECEIVED_TAGS=1,3,6,...
- Optional: custom MAC (--chaddr) and hostname option 12 (--hostname)
- Errors are reported on stdout, packet dumps (--debug) on stderr
"""
import argparse
@ -19,7 +21,8 @@ def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--iface", required=True, help="Interface used to send/receive DHCP")
parser.add_argument("--request-options", default="", help="CSV option tags in PRL, e.g. 1,3,6,15,42")
parser.add_argument("--timeout", type=int, default=8)
parser.add_argument("--timeout", type=int, default=8, help="Seconds waited for a reply per attempt")
parser.add_argument("--retries", type=int, default=2, help="Retransmissions of an unanswered request")
parser.add_argument("--debug", action="store_true")
parser.add_argument("--chaddr", default="", help="MAC as aa:bb:cc:dd:ee:ff (default: aa:bb:cc:dd:ee:ff)")
parser.add_argument("--hostname", default="", help="Hostname string for DHCP option 12")
@ -39,13 +42,13 @@ def parse_chaddr(mac_str):
try:
octets = [int(b, 16) for b in mac_str.strip().split(":")]
except ValueError:
print("ERROR: invalid --chaddr value: " + mac_str, file=sys.stderr)
print("ERROR: invalid --chaddr value: " + mac_str)
sys.exit(2)
if len(octets) != 6:
print("ERROR: --chaddr must have exactly 6 octets, got " + str(len(octets)), file=sys.stderr)
print("ERROR: --chaddr must have exactly 6 octets, got " + str(len(octets)))
sys.exit(2)
if any(o < 0 or o > 255 for o in octets):
print("ERROR: --chaddr octet out of range 0-255: " + mac_str, file=sys.stderr)
print("ERROR: --chaddr octet out of range 0-255: " + mac_str)
sys.exit(2)
return bytes(octets)
@ -118,9 +121,10 @@ def main():
print("--- DHCPDISCOVER sent ---", file=sys.stderr)
print(discover_packet.show(dump=True), file=sys.stderr)
offer = srp1(discover_packet, iface=args.iface, timeout=args.timeout, verbose=False)
offer = srp1(discover_packet, iface=args.iface, timeout=args.timeout,
retry=args.retries, verbose=False)
if offer is None or not offer.haslayer(DHCP):
print("ERROR: no DHCPOFFER received", file=sys.stderr)
print("ERROR: no DHCPOFFER received")
return 2
if args.debug:
@ -128,7 +132,7 @@ def main():
print(offer.show(dump=True), file=sys.stderr)
if get_message_type(offer[DHCP].options) != 2:
print("ERROR: received DHCP response is not DHCPOFFER", file=sys.stderr)
print("ERROR: received DHCP response is not DHCPOFFER")
return 2
offered_ip = offer[BOOTP].yiaddr
@ -159,9 +163,10 @@ def main():
print("--- DHCPREQUEST sent ---", file=sys.stderr)
print(request_packet.show(dump=True), file=sys.stderr)
final_reply = srp1(request_packet, iface=args.iface, timeout=args.timeout, verbose=False)
final_reply = srp1(request_packet, iface=args.iface, timeout=args.timeout,
retry=args.retries, verbose=False)
if final_reply is None or not final_reply.haslayer(DHCP):
print("ERROR: no DHCPACK received", file=sys.stderr)
print("ERROR: no DHCPACK received")
return 2
if args.debug:
@ -170,10 +175,10 @@ def main():
message_type = get_message_type(final_reply[DHCP].options)
if message_type == 6:
print("ERROR: DHCPNAK received", file=sys.stderr)
print("ERROR: DHCPNAK received")
return 2
if message_type != 5:
print("ERROR: final DHCP response is not DHCPACK", file=sys.stderr)
print("ERROR: final DHCP response is not DHCPACK")
return 2
tags = extract_option_tags(final_reply[DHCP].options)