From 49c0eca80553befe0e43b9ca70aa5c30dfcb5bfe Mon Sep 17 00:00:00 2001 From: Alexandre Fiset Date: Wed, 29 Jul 2026 13:26:01 +0200 Subject: [PATCH] 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 --- .../017-dhcpv4-force-option.t | 8 +++--- .../dhcpv4-client-options/README.md | 11 ++++++++ .../dhcpv4_option_probe.py | 27 +++++++++++-------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t index 785ab4a650..513597585a 100644 --- a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t +++ b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t @@ -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: diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/README.md b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/README.md index 9808644adb..aedc094275 100644 --- a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/README.md +++ b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/README.md @@ -66,7 +66,18 @@ Use a higher timeout when network delays are expected: sudo -E python3 dhcpv4_option_probe.py --iface --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. diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py index db0a58b40d..0bade41228 100644 --- a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py +++ b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py @@ -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= 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)