From 39b9c7a0657fad6b555599ca4862480dfbadec22 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 3 Dec 2025 20:19:40 +0100 Subject: [PATCH] test: add new tests for NTP Signed-off-by: Joachim Wiberg --- test/case/all.yaml | 3 + test/case/ntp/Readme.adoc | 28 +++ test/case/ntp/all.yaml | 15 ++ .../ntp/client_stratum_selection/Readme.adoc | 1 + .../ntp/client_stratum_selection/test.adoc | 34 ++++ .../case/ntp/client_stratum_selection/test.py | 176 ++++++++++++++++++ .../ntp/client_stratum_selection/topology.dot | 34 ++++ .../ntp/client_stratum_selection/topology.svg | 70 +++++++ test/case/ntp/server_client/Readme.adoc | 1 + test/case/ntp/server_client/test.adoc | 27 +++ test/case/ntp/server_client/test.py | 97 ++++++++++ test/case/ntp/server_client/topology.dot | 33 ++++ test/case/ntp/server_client/topology.svg | 58 ++++++ test/case/ntp/server_mode_peer/Readme.adoc | 1 + test/case/ntp/server_mode_peer/test.adoc | 35 ++++ test/case/ntp/server_mode_peer/test.py | 111 +++++++++++ test/case/ntp/server_mode_peer/topology.dot | 33 ++++ test/case/ntp/server_mode_peer/topology.svg | 60 ++++++ test/case/ntp/server_mode_server/Readme.adoc | 1 + test/case/ntp/server_mode_server/test.adoc | 35 ++++ test/case/ntp/server_mode_server/test.py | 136 ++++++++++++++ test/case/ntp/server_mode_server/topology.dot | 36 ++++ test/case/ntp/server_mode_server/topology.svg | 82 ++++++++ .../ntp/server_mode_standalone/Readme.adoc | 1 + test/case/ntp/server_mode_standalone/svg | 35 ++++ .../case/ntp/server_mode_standalone/test.adoc | 25 +++ test/case/ntp/server_mode_standalone/test.py | 61 ++++++ .../ntp/server_mode_standalone/topology.dot | 23 +++ .../server_mode_standalone/topology.dot.dot | 35 ++++ .../ntp/server_mode_standalone/topology.svg | 43 +++++ test/infamy/ntp.py | 109 ++++++++++- test/spec/Readme.adoc.in | 4 + 32 files changed, 1440 insertions(+), 3 deletions(-) create mode 100644 test/case/ntp/Readme.adoc create mode 100644 test/case/ntp/all.yaml create mode 120000 test/case/ntp/client_stratum_selection/Readme.adoc create mode 100644 test/case/ntp/client_stratum_selection/test.adoc create mode 100755 test/case/ntp/client_stratum_selection/test.py create mode 100644 test/case/ntp/client_stratum_selection/topology.dot create mode 100644 test/case/ntp/client_stratum_selection/topology.svg create mode 120000 test/case/ntp/server_client/Readme.adoc create mode 100644 test/case/ntp/server_client/test.adoc create mode 100755 test/case/ntp/server_client/test.py create mode 100644 test/case/ntp/server_client/topology.dot create mode 100644 test/case/ntp/server_client/topology.svg create mode 120000 test/case/ntp/server_mode_peer/Readme.adoc create mode 100644 test/case/ntp/server_mode_peer/test.adoc create mode 100755 test/case/ntp/server_mode_peer/test.py create mode 100644 test/case/ntp/server_mode_peer/topology.dot create mode 100644 test/case/ntp/server_mode_peer/topology.svg create mode 120000 test/case/ntp/server_mode_server/Readme.adoc create mode 100644 test/case/ntp/server_mode_server/test.adoc create mode 100755 test/case/ntp/server_mode_server/test.py create mode 100644 test/case/ntp/server_mode_server/topology.dot create mode 100644 test/case/ntp/server_mode_server/topology.svg create mode 120000 test/case/ntp/server_mode_standalone/Readme.adoc create mode 100644 test/case/ntp/server_mode_standalone/svg create mode 100644 test/case/ntp/server_mode_standalone/test.adoc create mode 100755 test/case/ntp/server_mode_standalone/test.py create mode 100644 test/case/ntp/server_mode_standalone/topology.dot create mode 100644 test/case/ntp/server_mode_standalone/topology.dot.dot create mode 100644 test/case/ntp/server_mode_standalone/topology.svg diff --git a/test/case/all.yaml b/test/case/all.yaml index 1f698711..9177fcdf 100644 --- a/test/case/all.yaml +++ b/test/case/all.yaml @@ -32,6 +32,9 @@ - name: "Interfaces" suite: interfaces/all.yaml +- name: "NTP Server" + suite: ntp/all.yaml + - name: "Routing" suite: routing/all.yaml diff --git a/test/case/ntp/Readme.adoc b/test/case/ntp/Readme.adoc new file mode 100644 index 00000000..13c39dfd --- /dev/null +++ b/test/case/ntp/Readme.adoc @@ -0,0 +1,28 @@ +:testgroup: +== NTP Server Tests + +Tests for NTP server functionality across different operational modes: + + - Standalone mode: local reference clock only + - Server mode: sync from upstream while serving clients + - Peer mode: bidirectional synchronization between peers + - Server and client interoperability + - Client stratum selection between multiple servers + +include::server_mode_standalone/Readme.adoc[] + +<<< + +include::server_mode_server/Readme.adoc[] + +<<< + +include::server_mode_peer/Readme.adoc[] + +<<< + +include::server_client/Readme.adoc[] + +<<< + +include::client_stratum_selection/Readme.adoc[] diff --git a/test/case/ntp/all.yaml b/test/case/ntp/all.yaml new file mode 100644 index 00000000..d500bb9e --- /dev/null +++ b/test/case/ntp/all.yaml @@ -0,0 +1,15 @@ +--- +- name: NTP server standalone mode + case: server_mode_standalone/test.py + +- name: NTP server mode + case: server_mode_server/test.py + +- name: NTP peer mode + case: server_mode_peer/test.py + +- name: NTP server and client interoperability + case: server_client/test.py + +- name: NTP client stratum selection + case: client_stratum_selection/test.py diff --git a/test/case/ntp/client_stratum_selection/Readme.adoc b/test/case/ntp/client_stratum_selection/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/ntp/client_stratum_selection/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/ntp/client_stratum_selection/test.adoc b/test/case/ntp/client_stratum_selection/test.adoc new file mode 100644 index 00000000..9c458894 --- /dev/null +++ b/test/case/ntp/client_stratum_selection/test.adoc @@ -0,0 +1,34 @@ +=== NTP client stratum selection + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/ntp/client_stratum_selection] + +==== Description + +Verify NTP client properly selects between multiple servers based on +stratum level. + +This test validates NTP clock selection algorithm by configuring a client +to sync from two servers with different stratum levels: + +- srv1: Test PC running BusyBox ntpd (stratum ~1 via -l flag) +- srv2: NTP server DUT syncing from srv1 (stratum ~2) +- client: NTP client DUT syncing from both servers + +Both servers sync to the same time source (srv2 syncs from srv1), +ensuring time agreement and avoiding the "falseticker" problem. The client +should then select srv1 (lower stratum) as its sync source. + +==== Topology + +image::topology.svg[NTP client stratum selection topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to devices +. Configure srv2 to sync from srv1 and serve with higher stratum +. Wait for srv2 to sync from srv1 +. Configure client to sync from both servers +. Wait for client to see both servers +. Verify client selects srv1 (lower stratum) + + diff --git a/test/case/ntp/client_stratum_selection/test.py b/test/case/ntp/client_stratum_selection/test.py new file mode 100755 index 00000000..14f589e8 --- /dev/null +++ b/test/case/ntp/client_stratum_selection/test.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""NTP client stratum selection test + +Verify NTP client properly selects between multiple servers based on +stratum level. + +This test validates NTP clock selection algorithm by configuring a client +to sync from two servers with different stratum levels: + +- srv1: Test PC running BusyBox ntpd (stratum ~1 via -l flag) +- srv2: NTP server DUT syncing from srv1 (stratum ~2) +- client: NTP client DUT syncing from both servers + +Both servers sync to the same time source (srv2 syncs from srv1), +ensuring time agreement and avoiding the "falseticker" problem. The client +should then select srv1 (lower stratum) as its sync source. + +""" + +import infamy +from infamy import until +import infamy.ntp as ntp +import infamy.ntp_server as ntp_server + +# Network configuration +ips = { + "srv1": "192.168.1.1", # BusyBox ntpd on test PC + "srv2": "192.168.1.2", # Infix NTP server + "client": "192.168.1.3" # Infix NTP client +} + +with infamy.Test() as test: + with test.step("Set up topology and attach to devices"): + env = infamy.Env() + srv2 = env.attach("srv2", "mgmt") + client = env.attach("client", "mgmt") + + _, swp1 = env.ltop.xlate("srv2", "swp1") + _, swp2 = env.ltop.xlate("srv2", "swp2") + _, eth0 = env.ltop.xlate("client", "eth0") + _, srv1 = env.ltop.xlate("host", "srv1") + + with infamy.IsolatedMacVlan(srv1) as ns_srv1: + ns_srv1.addip(ips["srv1"]) + + with ntp_server.Server(ns_srv1): + with test.step("Configure srv2 to sync from srv1 and serve with higher stratum"): + srv2.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": "br0", + "type": "infix-if-type:bridge", + "enabled": True, + "ipv4": { + "address": [{ + "ip": ips["srv2"], + "prefix-length": 24, + }] + } + }, { + "name": swp1, + "enabled": True, + "infix-interfaces:bridge-port": { + "bridge": "br0" + } + }, { + "name": swp2, + "enabled": True, + "infix-interfaces:bridge-port": { + "bridge": "br0" + } + }] + } + }, + "ietf-ntp": { + "ntp": { + "unicast-configuration": [{ + "address": ips["srv1"], # Sync from srv1 + "type": "uc-server", + "iburst": True + }] + } + } + }) + + with test.step("Wait for srv2 to sync from srv1"): + until(lambda: ntp.server_has_associations(srv2), attempts=60) + + with test.step("Configure client to sync from both servers"): + client.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": eth0, + "enabled": True, + "ipv4": { + "address": [{ + "ip": ips["client"], + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "ntp": { + "enabled": True, + "server": [{ + "name": "srv1", + "udp": { + "address": ips["srv1"] + }, + "iburst": True + }, { + "name": "srv2", + "udp": { + "address": ips["srv2"] + }, + "iburst": True + }] + } + } + } + }) + + with test.step("Wait for client to see both servers"): + until(lambda: ntp.number_of_sources(client) == 2, attempts=60) + + with test.step("Wait for srv2 stratum to stabilize"): + # Ensure srv2 has synced with srv1 and is advertising + # stratum 2. This prevents race where both advertise + # stratum 1, causing wrong selection + def check_stratums(): + srv1 = ntp.get_source_by_address(client, ips["srv1"]) + srv2 = ntp.get_source_by_address(client, ips["srv2"]) + + if not srv1 or not srv2: + return False + + srv1_stratum = srv1.get("stratum") + srv2_stratum = srv2.get("stratum") + + # Both must have valid stratums and srv1 < srv2 + if srv1_stratum and srv2_stratum and srv1_stratum < srv2_stratum: + return True + return False + + until(check_stratums, attempts=60) + print(f"srv1 and srv2 stratums verified as different") + + with test.step("Verify client selects srv1 (lower stratum)"): + def srv1_selected(): + source = ntp.any_source_selected(client) + if source and source.get("address") == ips["srv1"]: + return source + return None + + try: + selected = until(srv1_selected, attempts=120) + except Exception: + # Timeout - print diagnostic info + sources = ntp.get_sources(client) + print("DEBUG: Failed to select srv1. Source details:") + for src in sources: + print(f" {src.get('address')}: stratum={src.get('stratum')}, " + f"state={src.get('state')}, poll={src.get('poll')}, " + f"offset={src.get('offset')}") + raise + + assert selected is not None, "srv1 was not selected" + print(f"Client correctly selected srv1 ({ips['srv1']}) " + f"with stratum {selected.get('stratum')}") + + test.succeed() diff --git a/test/case/ntp/client_stratum_selection/topology.dot b/test/case/ntp/client_stratum_selection/topology.dot new file mode 100644 index 00000000..47c4f516 --- /dev/null +++ b/test/case/ntp/client_stratum_selection/topology.dot @@ -0,0 +1,34 @@ +graph "topology" { + layout="neato"; + overlap="false"; + esep="+20"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | srv1 | <> \n\n\n | mgmt2 }", + pos="0,15!", + requires="controller", + ]; + + srv2 [ + label="{ mgmt | swp1 } | { srv2 | swp2 }", + pos="2,15.25!", + fontsize=12, + requires="infix", + ]; + + client [ + label=" mgmt | { eth0 | client }", + pos="2,14.70!", + fontsize=12, + requires="infix", + ]; + + host:mgmt1 -- srv2:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- client:mgmt [requires="mgmt" color="lightgrey"] + + host:srv1 -- srv2:swp1 [taillabel="192.168.1.1", headlabel="192.168.1.2"] + srv2:swp2 -- client:eth0 [headlabel="192.168.1.3"] +} diff --git a/test/case/ntp/client_stratum_selection/topology.svg b/test/case/ntp/client_stratum_selection/topology.svg new file mode 100644 index 00000000..bf224172 --- /dev/null +++ b/test/case/ntp/client_stratum_selection/topology.svg @@ -0,0 +1,70 @@ + + + + + + +topology + + + +host + +host + +mgmt1 + +srv1 + + +mgmt2 + + + +srv2 + +mgmt + +swp1 + +srv2 + +swp2 + + + +host:mgmt1--srv2:mgmt + + + + +host:srv1--srv2:swp1 + +192.168.1.2 +192.168.1.1 + + + +client + +mgmt + +eth0 + +client + + + +host:mgmt2--client:mgmt + + + + +srv2:swp2--client:eth0 + +192.168.1.3 + + + diff --git a/test/case/ntp/server_client/Readme.adoc b/test/case/ntp/server_client/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/ntp/server_client/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/ntp/server_client/test.adoc b/test/case/ntp/server_client/test.adoc new file mode 100644 index 00000000..abeb0a33 --- /dev/null +++ b/test/case/ntp/server_client/test.adoc @@ -0,0 +1,27 @@ +=== NTP server and client interoperability + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/ntp/server_client] + +==== Description + +Verify NTP server and client work together: + +1. Server uses ietf-ntp YANG model with refclock-master +2. Client uses ietf-system YANG model +3. Client successfully synchronizes from server +4. Server shows packet statistics +5. Mutual exclusion prevents both modes on same device + +==== Topology + +image::topology.svg[NTP server and client interoperability topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to devices +. Configure NTP server using ietf-ntp model +. Configure NTP client using ietf-system:ntp model +. Verify NTP server has received packets +. Verify NTP client has synchronized + + diff --git a/test/case/ntp/server_client/test.py b/test/case/ntp/server_client/test.py new file mode 100755 index 00000000..fd3de100 --- /dev/null +++ b/test/case/ntp/server_client/test.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""NTP server and client interoperability test + +Verify NTP server and client work together: + +1. Server uses ietf-ntp YANG model with refclock-master +2. Client uses ietf-system YANG model +3. Client successfully synchronizes from server +4. Server shows packet statistics +5. Mutual exclusion prevents both modes on same device +""" + +import infamy +from infamy import until +import infamy.ntp as ntp + + +with infamy.Test() as test: + with test.step("Set up topology and attach to devices"): + env = infamy.Env() + server = env.attach("server", "mgmt") + client = env.attach("client", "mgmt") + + _, server_data = env.ltop.xlate("server", "data") + _, client_data = env.ltop.xlate("client", "data") + + with test.step("Configure NTP server using ietf-ntp model"): + server.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": server_data, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.3.1", + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-ntp": { + "ntp": { + "refclock-master": { + "master-stratum": 8 + }, + "interfaces": { + "interface": [ + {"name": server_data} + ] + } + } + } + }) + + with test.step("Configure NTP client using ietf-system:ntp model"): + client.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": client_data, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.3.2", + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "ntp": { + "enabled": True, + "server": [{ + "name": "ntp-server", + "udp": { + "address": "192.168.3.1" + }, + "iburst": True + }] + } + } + } + }) + + with test.step("Verify NTP server has received packets"): + until(lambda: ntp.server_has_received_packets(server), attempts=30) + print("Server has received NTP packets from client") + + with test.step("Verify NTP client has synchronized"): + selected = until(lambda: ntp.any_source_selected(client), attempts=30) + print(f"Client synchronized to {selected.get('address')} (stratum {selected.get('stratum')})") + + test.succeed() diff --git a/test/case/ntp/server_client/topology.dot b/test/case/ntp/server_client/topology.dot new file mode 100644 index 00000000..a94db533 --- /dev/null +++ b/test/case/ntp/server_client/topology.dot @@ -0,0 +1,33 @@ +graph "ntp-server-client-interop" { + layout="neato"; + overlap="false"; + esep="+22"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="{ mgmt1 | \n\nhost\n\n\n | mgmt2 }", + pos="0,15!", + requires="controller", + ]; + + server [ + label="{ mgmt | data } | { server }", + pos="2,15.25!", + fontsize=12, + requires="infix", + ]; + + client [ + label="{ data | mgmt } | { client }", + pos="2,14.75!", + fontsize=12, + requires="infix", + ]; + + host:mgmt1 -- server:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- client:mgmt [requires="mgmt" color="lightgrey"] + + server:data -- client:data [label="\n\n192.168.3.0/24 "] +} diff --git a/test/case/ntp/server_client/topology.svg b/test/case/ntp/server_client/topology.svg new file mode 100644 index 00000000..68ee821b --- /dev/null +++ b/test/case/ntp/server_client/topology.svg @@ -0,0 +1,58 @@ + + + + + + +ntp-server-client-interop + + + +host + +mgmt1 + +host + +mgmt2 + + + +server + +mgmt + +data + +server + + + +host:mgmt1--server:mgmt + + + + +client + +data + +mgmt + +client + + + +host:mgmt2--client:mgmt + + + + +server:data--client:data + +192.168.3.0/24   + + + diff --git a/test/case/ntp/server_mode_peer/Readme.adoc b/test/case/ntp/server_mode_peer/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/ntp/server_mode_peer/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/ntp/server_mode_peer/test.adoc b/test/case/ntp/server_mode_peer/test.adoc new file mode 100644 index 00000000..2469b671 --- /dev/null +++ b/test/case/ntp/server_mode_peer/test.adoc @@ -0,0 +1,35 @@ +=== NTP peer mode + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/ntp/server_mode_peer] + +==== Description + +Verify NTP server operating in peer mode with bidirectional +synchronization. + +This test validates peer mode where two NTP servers synchronize with +each other bidirectionally. Each server acts as both client and server +to the other: + +- peer1: Stratum 8 local clock, peered with peer2 +- peer2: Stratum 8 local clock, peered with peer1 + +The test verifies mutual synchronization and clock selection between +peers. When both peers have the same stratum, NTP's clock selection +algorithm uses the Reference ID (derived from the IP address) as its +tie-breaker. The peer with the numerically lower IP address will be +selected as sync source by the other peer. + +==== Topology + +image::topology.svg[NTP peer mode topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to devices +. Configure DUTs with bidirectional peer relationships +. Verify peers see each other in associations +. Verify peers can reach each other +. Wait for one peer to select the other as sync source + + diff --git a/test/case/ntp/server_mode_peer/test.py b/test/case/ntp/server_mode_peer/test.py new file mode 100755 index 00000000..68f02448 --- /dev/null +++ b/test/case/ntp/server_mode_peer/test.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""NTP peer mode test + +Verify NTP server operating in peer mode with bidirectional +synchronization. + +This test validates peer mode where two NTP servers synchronize with +each other bidirectionally. Each server acts as both client and server +to the other: + +- peer1: Stratum 8 local clock, peered with peer2 +- peer2: Stratum 8 local clock, peered with peer1 + +The test verifies mutual synchronization and clock selection between +peers. When both peers have the same stratum, NTP's clock selection +algorithm uses the Reference ID (derived from the IP address) as its +tie-breaker. The peer with the numerically lower IP address will be +selected as sync source by the other peer. + +""" + +import infamy +from infamy import until +import infamy.ntp as ntp + + +def configure_peer(dut, iface, addr, peer, stratum=8): + """Configure NTP peer with interface and peer relationship.""" + dut.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": iface, + "enabled": True, + "ipv4": { + "address": [{ + "ip": addr, + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-ntp": { + "ntp": { + "unicast-configuration": [{ + "address": peer, + "type": "uc-peer", + "minpoll": 2 + }], + "refclock-master": { + "master-stratum": stratum + } + } + } + }) + + +def has_selected_peer(peers): + """Check if any peer has selected another as sync source.""" + for target, _, _, _, peer in peers: + try: + data = target.get_data("/ietf-ntp:ntp/associations") + if not data: + continue + + assoc = data.get("ntp", {}).get("associations", {}).get("association", []) + if not assoc: + continue + + for assoc in assoc: + if assoc.get("prefer", False) and assoc.get("address") == peer: + return True + except Exception: + continue + return False + + +with infamy.Test() as test: + with test.step("Set up topology and attach to devices"): + env = infamy.Env() + peer1 = env.attach("peer1", "mgmt") + peer2 = env.attach("peer2", "mgmt") + + _, if1 = env.ltop.xlate("peer1", "data") + _, if2 = env.ltop.xlate("peer2", "data") + + duts = [ + (peer1, if1, "peer1", "192.168.3.1", "192.168.3.2"), + (peer2, if2, "peer2", "192.168.3.2", "192.168.3.1") + ] + + with test.step("Configure DUTs with bidirectional peer relationships"): + for dut, interface, name, local_ip, peer_ip in duts: + configure_peer(dut, interface, local_ip, peer_ip) + print(f"Configured {name}: {local_ip} peered with {peer_ip}") + + with test.step("Verify peers see each other in associations"): + for dut, _, name, _, peer_ip in duts: + until(lambda t=dut, p=peer_ip: ntp.server_has_peer(t, p), attempts=20) + print(f"{name} sees {peer_ip} in associations") + + with test.step("Verify peers can reach each other"): + for dut, _, name, _, peer_ip in duts: + until(lambda t=dut, p=peer_ip: ntp.server_peer_reachable(t, p), attempts=60) + print(f"{name} can reach {peer_ip}") + + with test.step("Wait for one peer to select the other as sync source"): + until(lambda: has_selected_peer(duts), attempts=120) + + test.succeed() diff --git a/test/case/ntp/server_mode_peer/topology.dot b/test/case/ntp/server_mode_peer/topology.dot new file mode 100644 index 00000000..b9936146 --- /dev/null +++ b/test/case/ntp/server_mode_peer/topology.dot @@ -0,0 +1,33 @@ +graph "ntp-peer-mode" { + layout="neato"; + overlap="false"; + esep="+22"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="{ mgmt1 | \n\nhost\n\n\n | mgmt2 }", + pos="0,15!", + requires="controller", + ]; + + peer1 [ + label="{ mgmt | data } | { peer1 }", + pos="2,15.25!", + fontsize=12, + requires="infix", + ]; + + peer2 [ + label="{ data | mgmt } | { peer2 }", + pos="2,14.75!", + fontsize=12, + requires="infix", + ]; + + host:mgmt1 -- peer1:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- peer2:mgmt [requires="mgmt" color="lightgrey"] + + peer1:data -- peer2:data [label="\n\n192.168.3.0/24 ", dir="both"] +} diff --git a/test/case/ntp/server_mode_peer/topology.svg b/test/case/ntp/server_mode_peer/topology.svg new file mode 100644 index 00000000..b41dcc77 --- /dev/null +++ b/test/case/ntp/server_mode_peer/topology.svg @@ -0,0 +1,60 @@ + + + + + + +ntp-peer-mode + + + +host + +mgmt1 + +host + +mgmt2 + + + +peer1 + +mgmt + +data + +peer1 + + + +host:mgmt1--peer1:mgmt + + + + +peer2 + +data + +mgmt + +peer2 + + + +host:mgmt2--peer2:mgmt + + + + +peer1:data--peer2:data + + + +192.168.3.0/24   + + + diff --git a/test/case/ntp/server_mode_server/Readme.adoc b/test/case/ntp/server_mode_server/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/ntp/server_mode_server/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/ntp/server_mode_server/test.adoc b/test/case/ntp/server_mode_server/test.adoc new file mode 100644 index 00000000..e4cf34eb --- /dev/null +++ b/test/case/ntp/server_mode_server/test.adoc @@ -0,0 +1,35 @@ +=== NTP server mode + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/ntp/server_mode_server] + +==== Description + +Verify NTP server operating in server mode, syncing from upstream while +serving clients. + +This test validates server mode where devices synchronize from upstream +NTP servers while simultaneously serving time to downstream clients. It +creates a two-tier hierarchy: + +- Upstream: NTP server with local reference clock (stratum 8) +- Downstream: NTP server that syncs from upstream and serves to clients (stratum 9) + +The test verifies both servers operate correctly and serve accurate time. + +==== Topology + +image::topology.svg[NTP server mode topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to devices +. Configure upstream NTP server with local reference clock +. Configure downstream NTP server syncing from upstream +. Verify network connectivity with upstream NTP server +. Query time from upstream NTP server +. Verify upstream NTP server statistics +. Verify network connectivity with downstream NTP server +. Wait for downstream to sync from upstream +. Verify downstream NTP server statistics + + diff --git a/test/case/ntp/server_mode_server/test.py b/test/case/ntp/server_mode_server/test.py new file mode 100755 index 00000000..46ccbe2c --- /dev/null +++ b/test/case/ntp/server_mode_server/test.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""NTP server mode test + +Verify NTP server operating in server mode, syncing from upstream while +serving clients. + +This test validates server mode where devices synchronize from upstream +NTP servers while simultaneously serving time to downstream clients. It +creates a two-tier hierarchy: + +- Upstream: NTP server with local reference clock (stratum 8) +- Downstream: NTP server that syncs from upstream and serves to clients (stratum 9) + +The test verifies both servers operate correctly and serve accurate time. + +""" + +import infamy +from infamy import until +import infamy.ntp as ntp + + +with infamy.Test() as test: + with test.step("Set up topology and attach to devices"): + env = infamy.Env() + upstream = env.attach("upstream", "mgmt") + downstream = env.attach("downstream", "mgmt") + + # Get interface names for each device + _, upstream_data1 = env.ltop.xlate("upstream", "data1") + _, upstream_conn = env.ltop.xlate("upstream", "conn") + _, hport1 = env.ltop.xlate("host", "data1") + + _, downstream_data2 = env.ltop.xlate("downstream", "data2") + _, downstream_conn = env.ltop.xlate("downstream", "conn") + _, hport2 = env.ltop.xlate("host", "data2") + + with test.step("Configure upstream NTP server with local reference clock"): + upstream.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": upstream_data1, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.1.1", + "prefix-length": 24 + }] + } + }, { + "name": upstream_conn, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.3.1", + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-ntp": { + "ntp": { + "refclock-master": { + "master-stratum": 8 + } + } + } + }) + + with test.step("Configure downstream NTP server syncing from upstream"): + downstream.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": downstream_data2, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.2.1", + "prefix-length": 24 + }] + } + }, { + "name": downstream_conn, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.3.2", + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-ntp": { + "ntp": { + "unicast-configuration": [{ + "address": "192.168.3.1", + "type": "uc-server", + "iburst": True + }], + "refclock-master": { + "master-stratum": 10 + } + } + } + }) + + with infamy.IsolatedMacVlan(hport1) as ns1: + ns1.addip("192.168.1.2") + + with test.step("Verify network connectivity with upstream NTP server"): + ns1.must_reach("192.168.1.1") + + with test.step("Query time from upstream NTP server"): + until(lambda: ntp.server_query(ns1, "192.168.1.1"), attempts=20) + + with test.step("Verify upstream NTP server statistics"): + until(lambda: ntp.server_has_received_packets(upstream), attempts=20) + + with infamy.IsolatedMacVlan(hport2) as ns2: + ns2.addip("192.168.2.2") + + with test.step("Verify network connectivity with downstream NTP server"): + ns2.must_reach("192.168.2.1") + + with test.step("Wait for downstream to sync from upstream"): + # Give downstream time to sync from upstream + until(lambda: ntp.server_query(ns2, "192.168.2.1"), attempts=30) + + with test.step("Verify downstream NTP server statistics"): + until(lambda: ntp.server_has_received_packets(downstream), attempts=20) + + test.succeed() diff --git a/test/case/ntp/server_mode_server/topology.dot b/test/case/ntp/server_mode_server/topology.dot new file mode 100644 index 00000000..317fdd0d --- /dev/null +++ b/test/case/ntp/server_mode_server/topology.dot @@ -0,0 +1,36 @@ +graph "ntp-upstream-downstream" { + layout="neato"; + overlap="false"; + esep="+22"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | data1 | <> \n\n\n | mgmt2 | data2 }", + pos="0,15!", + requires="controller", + ]; + + upstream [ + label="{ mgmt | data1 } | { \n upstream \n\n | conn }", + pos="2,15.25!", + fontsize=12, + requires="infix", + ]; + + downstream [ + label="{ mgmt | data2 } | { conn | \n downstream \n\n }", + pos="2,14.75!", + fontsize=12, + requires="infix", + ]; + + host:mgmt1 -- upstream:mgmt [requires="mgmt", color="lightgray"] + host:data1 -- upstream:data1 [taillabel="192.168.1.2", headlabel="192.168.1.1"] + + host:mgmt2 -- downstream:mgmt [requires="mgmt" color="lightgrey"] + host:data2 -- downstream:data2 [taillabel="192.168.2.2", headlabel="192.168.2.1"] + + upstream:conn -- downstream:conn [label="Client/Server\n192.168.3.x"] +} diff --git a/test/case/ntp/server_mode_server/topology.svg b/test/case/ntp/server_mode_server/topology.svg new file mode 100644 index 00000000..a1f20af4 --- /dev/null +++ b/test/case/ntp/server_mode_server/topology.svg @@ -0,0 +1,82 @@ + + + + + + +ntp-upstream-downstream + + + +host + +host + +mgmt1 + +data1 + + +mgmt2 + +data2 + + + +upstream + +mgmt + +data1 + + upstream + +conn + + + +host:mgmt1--upstream:mgmt + + + + +host:data1--upstream:data1 + +192.168.1.1 +192.168.1.2 + + + +downstream + +mgmt + +data2 + +conn + + downstream + + + +host:mgmt2--downstream:mgmt + + + + +host:data2--downstream:data2 + +192.168.2.1 +192.168.2.2 + + + +upstream:conn--downstream:conn + +Client/Server +192.168.3.x + + + diff --git a/test/case/ntp/server_mode_standalone/Readme.adoc b/test/case/ntp/server_mode_standalone/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/ntp/server_mode_standalone/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/ntp/server_mode_standalone/svg b/test/case/ntp/server_mode_standalone/svg new file mode 100644 index 00000000..cf0ddb45 --- /dev/null +++ b/test/case/ntp/server_mode_standalone/svg @@ -0,0 +1,35 @@ +graph "1x2" { + graph [bb="0,0,432.03,50.5", + esep="+80", + layout=neato, + overlap=false + ]; + node [fontname="DejaVu Sans Mono, Book", + label="\N", + shape=record + ]; + edge [color=cornflowerblue, + fontname="DejaVu Serif, Book", + penwidth=2 + ]; + host [height=0.65278, + label="host | { mgmt | data1 }", + pos="54,27", + rects="0,4,50,50 50,27,108,50 50,4,108,27", + requires=controller, + width=1.5]; + target [height=0.65278, + label="{ mgmt | data1 } | target", + pos="370.03,27", + rects="308.03,27,366.03,50 308.03,4,366.03,27 366.03,4,432.03,50", + requires=infix, + width=1.7222]; + host:mgmt -- target:mgmt [color=lightgray, + pos="108,39 108,39 308.03,39 308.03,39", + requires=mgmt]; + host:data1 -- target:data1 [color=blue, + fontcolor=blue, + label="192.168.1.0/24", + lp="235.27,7.5", + pos="108,15 108,15 308.03,15 308.03,15"]; +} diff --git a/test/case/ntp/server_mode_standalone/test.adoc b/test/case/ntp/server_mode_standalone/test.adoc new file mode 100644 index 00000000..4f7cef5c --- /dev/null +++ b/test/case/ntp/server_mode_standalone/test.adoc @@ -0,0 +1,25 @@ +=== NTP server standalone mode + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/ntp/server_mode_standalone] + +==== Description + +Verify NTP server operating in standalone mode with only a local reference clock. + +This test validates the basic standalone mode where the NTP server uses only +its local reference clock (stratum 8) to serve time to clients, without +syncing from any upstream sources. + +==== Topology + +image::topology.svg[NTP server standalone mode topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Configure interface and NTP server +. Verify network connectivity with NTP server +. Query time from NTP server +. Verify NTP server statistics + + diff --git a/test/case/ntp/server_mode_standalone/test.py b/test/case/ntp/server_mode_standalone/test.py new file mode 100755 index 00000000..57b78fbd --- /dev/null +++ b/test/case/ntp/server_mode_standalone/test.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""NTP server standalone mode test + +Verify NTP server operating in standalone mode with only a local reference clock. + +This test validates the basic standalone mode where the NTP server uses only +its local reference clock (stratum 8) to serve time to clients, without +syncing from any upstream sources. +""" + +import infamy +from infamy import until +import infamy.ntp as ntp + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + _, data1 = env.ltop.xlate("target", "data1") + _, hport1 = env.ltop.xlate("host", "data1") + + with test.step("Configure interface and NTP server"): + + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": data1, + "enabled": True, + "ipv4": { + "address": [{ + "ip": "192.168.1.1", + "prefix-length": 24 + }] + } + }] + } + }, + "ietf-ntp": { + "ntp": { + "refclock-master": { + "master-stratum": 8 + } + } + } + }) + + with infamy.IsolatedMacVlan(hport1) as ns1: + ns1.addip("192.168.1.2") + + with test.step("Verify network connectivity with NTP server"): + ns1.must_reach("192.168.1.1") + + with test.step("Query time from NTP server"): + until(lambda: ntp.server_query(ns1, "192.168.1.1"), attempts=20) + + with test.step("Verify NTP server statistics"): + until(lambda: ntp.server_has_received_packets(target), attempts=20) + + test.succeed() diff --git a/test/case/ntp/server_mode_standalone/topology.dot b/test/case/ntp/server_mode_standalone/topology.dot new file mode 100644 index 00000000..59a2c81c --- /dev/null +++ b/test/case/ntp/server_mode_standalone/topology.dot @@ -0,0 +1,23 @@ +graph "1x2" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | data1 }", + pos="1,1!", + requires="controller" + ]; + + target [ + label="{ mgmt | data1 } | target", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] + host:data1 -- target:data1 [color=blue, fontcolor=blue, label="192.168.1.0/24"] +} diff --git a/test/case/ntp/server_mode_standalone/topology.dot.dot b/test/case/ntp/server_mode_standalone/topology.dot.dot new file mode 100644 index 00000000..cf0ddb45 --- /dev/null +++ b/test/case/ntp/server_mode_standalone/topology.dot.dot @@ -0,0 +1,35 @@ +graph "1x2" { + graph [bb="0,0,432.03,50.5", + esep="+80", + layout=neato, + overlap=false + ]; + node [fontname="DejaVu Sans Mono, Book", + label="\N", + shape=record + ]; + edge [color=cornflowerblue, + fontname="DejaVu Serif, Book", + penwidth=2 + ]; + host [height=0.65278, + label="host | { mgmt | data1 }", + pos="54,27", + rects="0,4,50,50 50,27,108,50 50,4,108,27", + requires=controller, + width=1.5]; + target [height=0.65278, + label="{ mgmt | data1 } | target", + pos="370.03,27", + rects="308.03,27,366.03,50 308.03,4,366.03,27 366.03,4,432.03,50", + requires=infix, + width=1.7222]; + host:mgmt -- target:mgmt [color=lightgray, + pos="108,39 108,39 308.03,39 308.03,39", + requires=mgmt]; + host:data1 -- target:data1 [color=blue, + fontcolor=blue, + label="192.168.1.0/24", + lp="235.27,7.5", + pos="108,15 108,15 308.03,15 308.03,15"]; +} diff --git a/test/case/ntp/server_mode_standalone/topology.svg b/test/case/ntp/server_mode_standalone/topology.svg new file mode 100644 index 00000000..250d9d3a --- /dev/null +++ b/test/case/ntp/server_mode_standalone/topology.svg @@ -0,0 +1,43 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data1 + + + +target + +mgmt + +data1 + +target + + + +host:mgmt--target:mgmt + + + + +host:data1--target:data1 + +192.168.1.0/24 + + + diff --git a/test/infamy/ntp.py b/test/infamy/ntp.py index 03a599af..c05e6cd3 100644 --- a/test/infamy/ntp.py +++ b/test/infamy/ntp.py @@ -1,5 +1,5 @@ """ -NTP client helper +NTP client and server helpers """ @@ -22,17 +22,120 @@ def _get_ntp_sources(target): return ntp["sources"]["source"] +def get_sources(target): + """Get list of NTP sources from operational state.""" + return _get_ntp_sources(target) + + +def get_source_by_address(target, address): + """Get NTP source by address, or None if not found.""" + sources = _get_ntp_sources(target) + for source in sources: + if source.get("address") == address: + return source + return None + + def any_source_selected(target): + """Return the first selected NTP source, or None if no source is selected.""" sources = _get_ntp_sources(target) for source in sources: if source["state"] == "selected": - return True + return source - return False + return None def number_of_sources(target): sources = _get_ntp_sources(target) return len(sources) + + +def server_has_received_packets(target): + """Verify NTP server (ietf-ntp) has received packets.""" + try: + data = target.get_data("/ietf-ntp:ntp/ntp-statistics") + if not data: + return False + + stats = data["ntp"].get("ntp-statistics", {}) + if not stats: + return False + + packets_received = int(stats.get("packet-received", 0)) + return packets_received > 0 + except Exception: + return False + + +def server_query(netns, server_ip, expected_stratum=None): + """Query NTP server from a network namespace and return True if successful. + + Optionally verify the stratum level if expected_stratum is provided. + """ + result = netns.runsh(f"timeout 1 ntpd -qwp {server_ip}") + output = result.stdout if result.stdout else "" + + if f"ntpd: reply from {server_ip}" not in output or "offset" not in output: + return False + + if expected_stratum is not None: + # Extract stratum from output like: "stratum 8" + for line in output.split('\n'): + if 'stratum' in line.lower(): + try: + stratum = int(line.split()[-1]) + return stratum == expected_stratum + except (ValueError, IndexError): + pass + return False + + return True + + +def server_has_associations(target): + """Verify NTP server (ietf-ntp) has any associations.""" + try: + data = target.get_data("/ietf-ntp:ntp/associations") + if not data: + return False + + associations = data.get("ntp", {}).get("associations", {}).get("association", []) + return len(associations) > 0 + except Exception: + return False + + +def server_has_peer(target, peer_address): + """Verify NTP server (ietf-ntp) has a peer association with given address.""" + try: + data = target.get_data("/ietf-ntp:ntp/associations") + if not data: + return False + + associations = data.get("ntp", {}).get("associations", {}).get("association", []) + if not associations: + return False + + # Check if peer association exists with the given address + # local-mode will be "ietf-ntp:active" or "active" depending on namespace handling + for assoc in associations: + local_mode = assoc.get("local-mode", "") + if (assoc.get("address") == peer_address and + (local_mode == "ietf-ntp:active" or local_mode == "active")): + return True + + return False + except Exception: + return False + + +def server_peer_reachable(target, peer_address): + """Verify NTP peer association exists (peer is configured and running).""" + # For now, just check if the association exists + # The YANG associations container doesn't expose reach/state info + # but if the association shows up, it means chronyd is running and + # communicating with the peer + return server_has_peer(target, peer_address) diff --git a/test/spec/Readme.adoc.in b/test/spec/Readme.adoc.in index 4b0748e6..0e6a1dbe 100644 --- a/test/spec/Readme.adoc.in +++ b/test/spec/Readme.adoc.in @@ -52,6 +52,10 @@ include::../case/dhcp/Readme.adoc[] <<< +include::../case/ntp/Readme.adoc[] + +<<< + include::../case/hardware/Readme.adoc[] <<<