Make the shared-listen-include pattern work with `reuseport` and the other socket-level listen options. Nginx core enforces at-most-once per sockaddr on options that set lsopt.set=1 (reuseport, bind, backlog=, rcvbuf=, sndbuf=, setfib=, fastopen=, accept_filter=, deferred, ipv6only=, so_keepalive=) and emits "duplicate listen options for <addr>" otherwise. That rule collides with a single listens.conf included from every vhost — each vhost's include re-submits the same options. The listen wrapper now detects the cross-cscf case, strips those options from cf->args before delegating to the core handler, and logs one notice per stripped listen. The first cscf owns the options on the kernel socket; later cscfs merge cleanly via ngx_http_add_server. Protocol-level flags (ssl, http2, quic, proxy_protocol) pass through untouched since nginx OR-merges those across cscfs. This unblocks `reuseport` for deployments that want better new-connection spread across workers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
364 lines
15 KiB
Plaintext
364 lines
15 KiB
Plaintext
# SPDX-License-Identifier: Apache-2.0
|
||
*** Settings ***
|
||
Documentation End-to-end tests for ngx_http_ipng_stats_module.
|
||
... Deploys a 3-node containerlab topology and validates
|
||
... attribution, counters, histograms, filters, variables,
|
||
... and reload semantics.
|
||
Library OperatingSystem
|
||
Library String
|
||
Suite Setup Deploy Lab
|
||
Suite Teardown Cleanup Lab
|
||
|
||
*** Variables ***
|
||
${lab-name} ipng-stats-test
|
||
${lab-file} lab/ipng-stats.clab.yml
|
||
${runtime} docker
|
||
${CLAB_BIN} sudo containerlab
|
||
${SERVER} clab-${lab-name}-server
|
||
${CLIENT1} clab-${lab-name}-client1
|
||
${CLIENT2} clab-${lab-name}-client2
|
||
${SCRAPE_URL} http://172.20.40.2:9113/.well-known/ipng/statsz
|
||
${SERVER_MGMT} http://172.20.40.2:9180
|
||
|
||
*** Test Cases ***
|
||
|
||
# --- Basic functionality ---
|
||
|
||
Module loads
|
||
[Documentation] nginx -t passes with the module loaded.
|
||
${output} = Docker Exec ${SERVER} nginx -t 2>&1
|
||
Should Contain ${output} syntax is ok
|
||
|
||
Shared-listen-include across multiple server blocks
|
||
[Documentation] Three server blocks all pull in the same
|
||
... ipng-listens.inc (see docs/user-guide.md). The
|
||
... include also carries `reuseport` on every listen
|
||
... — nginx core would normally reject the second
|
||
... server block with "duplicate listen options", but
|
||
... the wrapper strips socket-level options on a
|
||
... repeat (cross-cscf) sockaddr so the first cscf
|
||
... owns the reuseport-cloned socket and the rest
|
||
... merge cleanly. With worker_processes unset
|
||
... (default 1), reuseport produces one socket per
|
||
... (worker × family), i.e. 2 on :8080 here.
|
||
${output} = Docker Exec ${SERVER} nginx -t 2>&1
|
||
Should Not Contain ${output} conflicting server name
|
||
Should Not Contain ${output} duplicate listen
|
||
${listens} = Docker Exec ${SERVER} ss -tlnH
|
||
${count} = Get Regexp Matches ${listens} :8080\\s
|
||
Length Should Be ${count} 2
|
||
... Expected 2 listening sockets on port 8080 (v4+v6 wildcards); got ${count}
|
||
# Proves the cross-cscf option-stripping path actually fired for
|
||
# the 2nd and 3rd server blocks. `nginx -t` replays the whole
|
||
# config and emits the wrapper's NOTICE each time it strips.
|
||
Should Contain ${output} stripped socket options from duplicate listen
|
||
|
||
Prometheus scrape
|
||
[Documentation] Scrape returns HELP/TYPE preamble.
|
||
${output} = Scrape Prometheus
|
||
Should Contain ${output} nginx-ipng-stats-plugin
|
||
Should Contain ${output} nginx_ipng_requests_total
|
||
|
||
JSON scrape
|
||
[Documentation] Accept: application/json returns valid JSON with schema.
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... curl -sf -H 'Accept: application/json' ${SCRAPE_URL} | python3 -m json.tool
|
||
Should Be Equal As Integers ${rc} 0
|
||
Should Contain ${output} "schema": 2
|
||
|
||
# --- Per-device attribution ---
|
||
|
||
Attribute tag1 via eth1 (v4)
|
||
[Documentation] IPv4 traffic on server:eth1 carries source_tag=tag1.
|
||
Send Fast Requests ${CLIENT1} 10.0.1.1 5
|
||
Wait For Flush
|
||
${output} = Scrape Prometheus
|
||
Should Contain ${output} source_tag="tag1"
|
||
Should Contain ${output} vip="10.0.1.1"
|
||
|
||
Attribute tag2-v4 via eth2 (v4)
|
||
[Documentation] IPv4 traffic on server:eth2 carries source_tag=tag2-v4.
|
||
Send Fast Requests ${CLIENT2} 10.0.2.1 5
|
||
Wait For Flush
|
||
${output} = Scrape Prometheus
|
||
Should Contain ${output} source_tag="tag2-v4"
|
||
Should Contain ${output} vip="10.0.2.1"
|
||
|
||
Attribute tag1 via eth1 (v6)
|
||
[Documentation] IPv6 traffic on server:eth1 carries source_tag=tag1
|
||
... — same tag as v4, demonstrating that tag= can be
|
||
... shared across address families for one device.
|
||
Send Fast Requests v6 ${CLIENT1} 2001:db8:1::1 5
|
||
Wait For Flush
|
||
${output} = Scrape With Filter source_tag=tag1
|
||
Should Contain ${output} source_tag="tag1"
|
||
Should Contain ${output} vip="2001:db8:1::1"
|
||
|
||
Attribute tag2-v6 via eth2 (v6)
|
||
[Documentation] IPv6 traffic on server:eth2 carries source_tag=tag2-v6
|
||
... — distinct from the eth2 v4 tag, demonstrating
|
||
... per-(device, family) attribution.
|
||
Send Fast Requests v6 ${CLIENT2} 2001:db8:2::1 5
|
||
Wait For Flush
|
||
${output} = Scrape Prometheus
|
||
Should Contain ${output} source_tag="tag2-v6"
|
||
Should Contain ${output} vip="2001:db8:2::1"
|
||
|
||
Direct traffic tagged
|
||
[Documentation] Mgmt-interface traffic carries source_tag=direct.
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... curl -sf ${SERVER_MGMT}/
|
||
Should Be Equal As Integers ${rc} 0
|
||
Wait For Flush
|
||
${output} = Scrape Prometheus
|
||
Should Contain ${output} source_tag="direct"
|
||
|
||
# --- Status code tracking ---
|
||
|
||
Per-class code counters
|
||
[Documentation] 4xx and 2xx appear as class-bucketed code= labels.
|
||
Docker Exec Ignore Rc ${CLIENT1} curl -s http://10.0.1.1:8080/notfound
|
||
Docker Exec Ignore Rc ${CLIENT1} curl -s http://10.0.1.1:8080/notfound
|
||
Wait For Flush
|
||
${output} = Scrape With Filter source_tag=tag1
|
||
Should Contain ${output} code="4xx"
|
||
Should Contain ${output} code="2xx"
|
||
|
||
# --- Duration histogram ---
|
||
|
||
Duration histogram
|
||
[Documentation] proxy_pass to a 50 ms backend populates sum and buckets.
|
||
Send Slow Requests ${CLIENT1} 10.0.1.1 3
|
||
Wait For Flush
|
||
${prom} = Scrape With Filter source_tag=tag1
|
||
Should Match Regexp ${prom} request_duration_seconds_sum\\{[^}]*\\}\\s+\\d+\\.\\d*[1-9]
|
||
|
||
${rc} ${json} = Run And Return Rc And Output
|
||
... curl -sf -H 'Accept: application/json' '${SCRAPE_URL}?source_tag=tag1' | python3 -m json.tool
|
||
Should Be Equal As Integers ${rc} 0
|
||
Should Contain ${json} request_duration_ms
|
||
Should Contain ${json} buckets
|
||
|
||
# --- Scrape filters ---
|
||
|
||
Filter by source_tag
|
||
[Documentation] ?source_tag=tag1 returns tag1 only; tag2-v4 only.
|
||
${output} = Scrape With Filter source_tag=tag1
|
||
Should Contain ${output} source_tag="tag1"
|
||
Should Not Contain ${output} source_tag="tag2-v4"
|
||
|
||
${output} = Scrape With Filter source_tag=tag2-v4
|
||
Should Contain ${output} source_tag="tag2-v4"
|
||
Should Not Contain ${output} source_tag="tag1"
|
||
|
||
Filter by VIP
|
||
[Documentation] ?vip=10.0.1.1 excludes 10.0.2.1.
|
||
${output} = Scrape With Filter vip=10.0.1.1
|
||
Should Contain ${output} vip="10.0.1.1"
|
||
Should Not Contain ${output} vip="10.0.2.1"
|
||
|
||
Filter combined
|
||
[Documentation] source_tag + vip intersection.
|
||
${output} = Scrape With Filter source_tag=tag1&vip=10.0.1.1
|
||
Should Contain ${output} source_tag="tag1"
|
||
Should Contain ${output} vip="10.0.1.1"
|
||
Should Not Contain ${output} source_tag="tag2-v4"
|
||
|
||
Filter unknown tag
|
||
[Documentation] Unknown source_tag returns empty data set.
|
||
${output} = Scrape With Filter source_tag=nonexistent
|
||
Should Not Contain ${output} nginx_ipng_requests_total{
|
||
|
||
# --- nginx variable ---
|
||
|
||
Variable in access log
|
||
[Documentation] $ipng_source_tag appears as tag1, tag2-v4, direct in log.
|
||
${output} = Docker Exec ${SERVER} cat /var/log/nginx/access.log
|
||
Should Match Regexp ${output} src=tag1
|
||
Should Match Regexp ${output} src=tag2-v4
|
||
Should Match Regexp ${output} src=direct
|
||
|
||
UDP logtail
|
||
[Documentation] ipng_stats_logtail udp:// sends log lines to a local
|
||
... nc listener; captured file has all sources and VIPs.
|
||
${output} = Docker Exec ${SERVER} cat /var/log/nginx/logtail-udp.log
|
||
Should Match Regexp ${output} tag1
|
||
Should Match Regexp ${output} tag2-v4
|
||
Should Match Regexp ${output} direct
|
||
Should Match Regexp ${output} 10\\.0\\.1\\.1
|
||
Should Match Regexp ${output} 10\\.0\\.2\\.1
|
||
# Tab-separated format
|
||
Should Match Regexp ${output} \\t
|
||
|
||
Logtail if= filter
|
||
[Documentation] Requests to /notfound are suppressed from logtail by
|
||
... the if=$logtail_enabled condition, but still counted.
|
||
${output} = Docker Exec ${SERVER} cat /var/log/nginx/logtail-udp.log
|
||
Should Not Contain ${output} /notfound
|
||
# But /notfound IS in the regular access log (not filtered there).
|
||
${access} = Docker Exec ${SERVER} cat /var/log/nginx/access.log
|
||
Should Contain ${access} /notfound
|
||
|
||
VIP in access log
|
||
[Documentation] $server_addr resolves to real IPs, not 0.0.0.0.
|
||
${output} = Docker Exec ${SERVER} cat /var/log/nginx/access.log
|
||
Should Contain ${output} vip=10.0.1.1
|
||
Should Contain ${output} vip=10.0.2.1
|
||
Should Not Contain ${output} vip=0.0.0.0
|
||
|
||
# --- Reload resilience ---
|
||
|
||
Counters survive reload
|
||
[Documentation] Shared-memory zone persists across nginx -s reload.
|
||
${before} = Get Request Count tag1
|
||
Docker Exec ${SERVER} nginx -s reload
|
||
Sleep 2s Wait for new workers
|
||
${after} = Get Request Count tag1
|
||
Should Be True ${after} >= ${before}
|
||
... Counters dropped after reload: before=${before} after=${after}
|
||
|
||
Traffic after reload
|
||
[Documentation] New requests are counted after reload.
|
||
Send Fast Requests ${CLIENT1} 10.0.1.1 3
|
||
Wait For Flush
|
||
${output} = Scrape With Filter source_tag=tag1
|
||
Should Contain ${output} source_tag="tag1"
|
||
|
||
# --- Counter correctness ---
|
||
|
||
Per-(device, family) request count accuracy
|
||
[Documentation] 10 requests on each of the four (device, family)
|
||
... combinations yields tag1=20, tag2-v4=10, tag2-v6=10.
|
||
... Demonstrates that one device can combine v4+v6 under
|
||
... a single tag while another device can split them.
|
||
${before_tag1} = Get Request Count tag1
|
||
${before_tag2v4} = Get Request Count tag2-v4
|
||
${before_tag2v6} = Get Request Count tag2-v6
|
||
|
||
Send Fast Requests ${CLIENT1} 10.0.1.1 10
|
||
Send Fast Requests v6 ${CLIENT1} 2001:db8:1::1 10
|
||
Send Fast Requests ${CLIENT2} 10.0.2.1 10
|
||
Send Fast Requests v6 ${CLIENT2} 2001:db8:2::1 10
|
||
Wait For Flush
|
||
|
||
${after_tag1} = Get Request Count tag1
|
||
${after_tag2v4} = Get Request Count tag2-v4
|
||
${after_tag2v6} = Get Request Count tag2-v6
|
||
|
||
${delta_tag1} = Evaluate ${after_tag1} - ${before_tag1}
|
||
${delta_tag2v4} = Evaluate ${after_tag2v4} - ${before_tag2v4}
|
||
${delta_tag2v6} = Evaluate ${after_tag2v6} - ${before_tag2v6}
|
||
|
||
Should Be Equal As Integers ${delta_tag1} 20
|
||
Should Be Equal As Integers ${delta_tag2v4} 10
|
||
Should Be Equal As Integers ${delta_tag2v6} 10
|
||
|
||
*** Keywords ***
|
||
|
||
# --- Lab lifecycle ---
|
||
|
||
Deploy Lab
|
||
Require Deb Build
|
||
Run ${CLAB_BIN} --runtime ${runtime} destroy -t ${CURDIR}/${lab-file} --cleanup 2>&1 || true
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... ${CLAB_BIN} --runtime ${runtime} deploy -t ${CURDIR}/${lab-file}
|
||
Log ${output}
|
||
Should Be Equal As Integers ${rc} 0
|
||
Wait Until Keyword Succeeds 90s 3s Server Is Ready
|
||
Wait Until Keyword Succeeds 60s 3s Client Can Reach Server ${CLIENT1} 10.0.1.1
|
||
Wait Until Keyword Succeeds 60s 3s Client Can Reach Server ${CLIENT2} 10.0.2.1
|
||
|
||
Require Deb Build
|
||
[Documentation] Fail fast with an actionable message if the user
|
||
... forgot to run `make pkg-deb` before invoking this
|
||
... suite. The server container dpkg-installs the
|
||
... built .deb via its bind-mount of build/.
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... bash -c 'ls ${EXECDIR}/build/libnginx-mod-http-ipng-stats_*.deb 2>/dev/null'
|
||
Run Keyword If ${rc} != 0
|
||
... Fail Module .deb not found — run `make pkg-deb` first.
|
||
|
||
Server Is Ready
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... curl -sf ${SCRAPE_URL}
|
||
Should Be Equal As Integers ${rc} 0
|
||
|
||
Client Can Reach Server
|
||
[Arguments] ${client} ${server_ip}
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... docker exec ${client} curl -sf http://${server_ip}:8080/
|
||
Should Be Equal As Integers ${rc} 0
|
||
|
||
Cleanup Lab
|
||
Run docker logs ${SERVER} > ${EXECDIR}/tests/out/server-docker.log 2>&1
|
||
Run docker exec ${SERVER} cat /var/log/nginx/access.log > ${EXECDIR}/tests/out/server-access.log 2>&1
|
||
Run docker exec ${SERVER} cat /var/log/nginx/error.log > ${EXECDIR}/tests/out/server-error.log 2>&1
|
||
Run docker exec ${SERVER} cat /var/log/nginx/logtail-udp.log > ${EXECDIR}/tests/out/server-logtail-udp.log 2>&1
|
||
Run docker exec ${SERVER} ip addr > ${EXECDIR}/tests/out/server-ip-addr.log 2>&1
|
||
Run docker exec ${SERVER} ip route > ${EXECDIR}/tests/out/server-ip-route.log 2>&1
|
||
Run ${CLAB_BIN} --runtime ${runtime} destroy -t ${CURDIR}/${lab-file} --cleanup
|
||
|
||
# --- Traffic generation ---
|
||
|
||
Send Fast Requests
|
||
[Arguments] ${client} ${server_ip} ${count}
|
||
FOR ${i} IN RANGE ${count}
|
||
Docker Exec ${client} curl -sf http://${server_ip}:8080/
|
||
END
|
||
|
||
Send Fast Requests v6
|
||
[Arguments] ${client} ${server_ip} ${count}
|
||
FOR ${i} IN RANGE ${count}
|
||
Docker Exec ${client} curl -sf http://[${server_ip}]:8080/
|
||
END
|
||
|
||
Send Slow Requests
|
||
[Arguments] ${client} ${server_ip} ${count}
|
||
FOR ${i} IN RANGE ${count}
|
||
Docker Exec ${client} curl -sf http://${server_ip}:8080/slow
|
||
END
|
||
|
||
Wait For Flush
|
||
Sleep 2s
|
||
|
||
# --- Scraping ---
|
||
|
||
Scrape Prometheus
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... curl -sf ${SCRAPE_URL}
|
||
Should Be Equal As Integers ${rc} 0
|
||
RETURN ${output}
|
||
|
||
Scrape With Filter
|
||
[Arguments] ${filter}
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... curl -sf '${SCRAPE_URL}?${filter}'
|
||
Should Be Equal As Integers ${rc} 0
|
||
RETURN ${output}
|
||
|
||
Get Request Count
|
||
[Arguments] ${source}
|
||
${output} = Scrape With Filter source_tag=${source}
|
||
${matches} = Get Regexp Matches ${output}
|
||
... nginx_ipng_requests_total\\{[^}]*\\}\\s+(\\d+) 1
|
||
${total} = Set Variable 0
|
||
FOR ${m} IN @{matches}
|
||
${total} = Evaluate ${total} + ${m}
|
||
END
|
||
RETURN ${total}
|
||
|
||
# --- Container helpers ---
|
||
|
||
Docker Exec
|
||
[Arguments] ${container} ${cmd}
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... docker exec ${container} ${cmd}
|
||
Should Be Equal As Integers ${rc} 0
|
||
RETURN ${output}
|
||
|
||
Docker Exec Ignore Rc
|
||
[Arguments] ${container} ${cmd}
|
||
${rc} ${output} = Run And Return Rc And Output
|
||
... docker exec ${container} ${cmd}
|
||
RETURN ${output}
|