2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-07 01:56:56 +00:00

test(provtest): assert what xCAT does rather than what the spec assumed

Running the scenarios against a live management node found nine places
where they asserted something xCAT does not do. Corrected here, each with
the reason in the file:

- install configs carry no xcatd= or destiny=; those are written for the
  genesis states only.
- #END OF SCRIPT is plain-transport framing; over TLS a reply ends with
  <serverdone>.
- getcredentials is named in <arg> and answered as <data><content/>
  <desc/></data>, so both fields are addressable now.
- currstate for an install is "install <osimage>", so the operator is
  starts-with.
- bootparams is empty for an ordinary install, so kernel and initrd
  cannot be asserted from getdestiny.
- a server never reached sends no <error>; assert the handshake instead.
- an NXDOMAIN answer is the expected result of a removal scenario.
- P-75 has to precede P-74: a withdrawn name cannot be nodeset again.

Also adds netutil.hex_net: a per-network config is named by as many hex
digits as the netmask covers, not by all eight.
This commit is contained in:
Daniel Hilst
2026-09-11 09:00:47 -03:00
parent 721bfe710e
commit 2efcbcde08
17 changed files with 306 additions and 65 deletions
+6
View File
@@ -0,0 +1,6 @@
# Python leaves these next to the source it imports, and provtest is run in
# place -- from a checkout during development and from
# /opt/xcat/share/xcat/tools/autotest after packaging -- so they appear in the
# tree rather than in a build directory.
__pycache__/
*.pyc
@@ -7,10 +7,21 @@
# never fetches them, and the day someone racks a new machine, nothing works and
# nothing says why.
#
# The name is the network address in hex truncated to the netmask, because that
# is what a loader asks for once it has failed to find a file for its own
# address: 10.99.1.0/24 is 0A6301, six digits and not eight.
#
# Three loader families, three scenarios, and a cluster runs whichever of them
# its architecture reaches: pxelinux and xnba for x86_64, grub2 for the UEFI
# architectures mknb writes a grub2 configuration for. They are selected with
# `-s` rather than skipped from inside, so a scenario that does not run says so
# by its absence and never by a pass.
#
# provtest run \
# --set server=10.99.1.1 --set client=10.99.1.11 \
# --set hexnet=0A630100 --set net=10.99.1.0 \
# --set hexnet=0A6301 --set netfile=10.99.1.0_24 \
# --set master=10.99.1.1 --set xcatport=3001 \
# -s pxelinux-discovery-config -s genesis-images-pxelinux \
# conf/discovery-artefacts.conf
[defaults]
@@ -29,9 +40,7 @@ type = tftp
path = boot/grub2/grub.cfg-%(hexnet)s
assert =
size > 0
# P-27: the destiny a machine with no definition must be given.
text contains destiny=discover
# P-28: and where to ask for the next one.
# P-28: where to ask for the next destiny.
text contains xcatd=%(master)s:%(xcatport)s
[step httpentry]
@@ -62,14 +71,83 @@ description = The pxelinux discovery config answers under the hex network addres
[step pxeconfig]
type = tftp
path = pxelinux.cfg/%(hexnet)s
# No `destiny=discover` here, deliberately: genesis asks xcatd what to do when
# the kernel command line does not tell it, and a machine nobody has defined is
# answered `discover`. The address it asks is therefore the whole content of
# this file, and a wrong one strands the machine in genesis with no error
# anywhere.
assert =
size > 0
text contains xcatd=%(master)s:%(xcatport)s
text contains genesis.kernel
# --- P-29, the other x86_64 loader ------------------------------------------
[scenario xnba-discovery-config]
description = The xnba discovery scripts for the network exist and name xcatd
[step gpxe]
type = tftp
path = xcat/xnba/nets/%(netfile)s
assert =
size > 0
text contains xcatd=%(master)s:%(xcatport)s
[step uefi]
type = tftp
path = xcat/xnba/nets/%(netfile)s.uefi
# The UEFI script does say `destiny=discover` where the BIOS one leaves it to
# xcatd, so this is the one place the word itself is assertable.
assert =
size > 0
text contains destiny=discover
text contains xcatd=%(master)s:%(xcatport)s
# --- P-30, P-31 -------------------------------------------------------------
[scenario genesis-kernel-and-initrd]
description = The genesis kernel and initrd named by the discovery config are fetchable
[scenario genesis-images-pxelinux]
description = The genesis kernel and initrd named by the pxelinux config are fetchable
[step pconfig]
type = tftp
path = pxelinux.cfg/%(hexnet)s
assert =
size > 0
[step pkernelpath]
type = extract
from = $pconfig.text
pattern = ^\s*KERNEL\s+/?(\S+)
assert =
matched == yes
[step pkernel]
type = tftp
path = $pkernelpath.value
assert =
size > 0
[step pinitrdpath]
type = extract
from = $pconfig.text
pattern = \binitrd=(\S+)
assert =
matched == yes
[step pinitrd]
type = tftp
path = $pinitrdpath.value
# Tens of megabytes over TFTP, which is slow enough to need its own timeout.
# It is fetched rather than merely looked for because a half-written initrd
# from an interrupted mknb is exactly the failure this catches, and a file that
# exists is not the same as a file that transfers.
timeout = 120
retries = 1
assert =
size > 0
[scenario genesis-images-grub2]
description = The genesis kernel and initrd named by the grub2 config are fetchable
[step gconfig]
type = tftp
@@ -100,10 +178,6 @@ assert =
[step initrd]
type = tftp
path = $initrdpath.value
# Tens of megabytes over TFTP, which is slow enough to need its own timeout.
# It is fetched rather than merely looked for because a half-written initrd
# from an interrupted mknb is exactly the failure this catches, and a file that
# exists is not the same as a file that transfers.
timeout = 120
retries = 1
assert =
+5
View File
@@ -23,6 +23,10 @@ description = A withdrawn node resolves in neither direction
type = dns
name = %(node)s.%(domain)s
rrtype = A
# NXDOMAIN is the passing result here, and the client calls only NOERROR ok,
# so the step has to say it expects no particular outcome and assert the
# status itself.
expect = any
assert =
status == NXDOMAIN
count == 0
@@ -31,6 +35,7 @@ assert =
type = dns
name = %(revname)s
rrtype = PTR
expect = any
# A PTR left behind by a removal is the worse half of the two: the name is
# gone, so nothing resolves the node forward, but the address still claims to
# be it, and xcatd will act on that claim.
+4 -2
View File
@@ -100,8 +100,10 @@ description = An undefined name in the cluster domain is refused, not forwarded
type = dns
name = %(missing)s.%(domain)s
rrtype = A
# NXDOMAIN is the answer under test, and dig reports it with rc=0, so the
# transport still succeeded. `expect` stays at its default.
expect = any
# `expect = any`: NXDOMAIN *is* the answer under test. The query reached the
# server and was answered authoritatively, which is a working resolver and not
# a failed one, so what is asserted is the status and not the fetch.
assert =
status == NXDOMAIN
count == 0
+31
View File
@@ -165,3 +165,34 @@ expect = fail
# The control: if every port answered, the step above would prove nothing.
assert =
status == 0
# --- P-39, the other half of it ---------------------------------------------
[scenario default-port-the-node-was-told]
description = A cluster on the default port names no port, and the default one answers
[step defaultconfig]
type = tftp
path = %(knownfile)s
port = 69
assert =
size > 0
[step configroot]
type = extract
from = $defaultconfig.text
pattern = set root=http,(\S+)
# On site.httpport 80 the plugins write the address alone, and the loader uses
# the protocol default. A port appearing here would be a node told to fetch
# from a port nothing was configured for, which is the same failure as the
# scenario above with the sign reversed -- so the assertion is that there is
# no colon, not that the port is right.
assert =
matched == yes
value matches ^[^:]+$
[step defaultserved]
type = http
url = http://$configroot.value/tftpboot/%(knownfile)s
assert =
status == 200
+21 -4
View File
@@ -6,8 +6,11 @@
# from the outside -- the node times out -- and telling them apart means
# watching a console during a reboot.
#
# P-74 needs the node's PTR removed first, and P-75 needs a second nodeset, so
# the fixture drives them in that order and the scenarios are selected with -s
# P-75 needs a second nodeset and P-74 needs the node's name withdrawn from
# resolution, and that order cannot be reversed: nodeset names the config it
# writes after the node's address, which it gets by resolving the node, so once
# the name is gone there is no second nodeset to be had. The fixture therefore
# drives P-75 first and P-74 last, and the scenarios are selected with -s
# rather than all run at once.
#
# provtest run \
@@ -48,8 +51,14 @@ expect = fail
# from the console that looks identical to a protocol fault at a server that
# is up. Here the two are told apart without booting anything.
assert =
handshake == no
error present
# No handshake and no answer, against a step that got both from the real
# address a moment earlier. `error` is deliberately not asserted: that
# field carries an <error> element the server sent, and a server that was
# never reached sends nothing at all -- which is the distinction being
# drawn, not an omission in the assertion.
handshake == no
ok == no
serverdone == no
# --- P-74 -------------------------------------------------------------------
# Run this only after the node's PTR has been removed.
@@ -80,7 +89,15 @@ type = tftp
path = boot/grub2/grub.cfg-%(hexip)s
assert =
size > 0
text starts-with #%(second)s
# P-17 for a genesis state, where it is written on the kernel command line
# as well as on the first line: genesis is what reads it.
text contains destiny=%(second)s
# P-16, and this is the only place it can be asserted: xcatd= is written
# for the genesis states and not for an OS install, because it is genesis
# that asks xcatd what to do next. A node put into one of these states
# with the wrong address here waits for an answer that cannot arrive.
text contains xcatd=%(master)s:%(xcatport)s
[step stale]
type = extract
+15 -6
View File
@@ -1,4 +1,4 @@
# Stage 3: TFTP, grub2. Spec P-09, P-11 to P-17, P-24, P-70, P-71.
# Stage 3: TFTP, grub2. Spec P-09, P-11 to P-15, P-17, P-24, P-70, P-71.
#
# grub2 asks for three things in order: its own binary, a per-node config named
# after the client, and whatever that config names. Each fetch is by exact
@@ -51,13 +51,22 @@ type = tftp
path = boot/grub2/grub.cfg-%(hexip)s
assert =
size > 0
# P-17: the state nodeset was told to put the node in. xCAT writes it on
# the first line of the file it generates, which is also where nodestat
# reads it back from -- so a config whose first line disagrees with the
# database is a node whose reported state is not the one it will boot.
text starts-with #%(destiny)s
# P-15: the installer picks its interface from BOOTIF. A node with two
# ports that is handed the wrong one installs onto the wrong network.
text contains BOOTIF=01-%(macdashes)s
# P-16: where to talk xCAT to, and on which port.
text contains xcatd=%(master)s:%(xcatport)s
# P-17: what nodeset was told to make it do.
text contains destiny=%(destiny)s
# grub2 writes $net_default_mac, the variable the loader expands to the
# interface it booted from, rather than the MAC xCAT has on file; either
# is a correct answer and an absent BOOTIF is not.
text matches BOOTIF=(\$net_default_mac|01-%(macdashes)s)
# P-16 is not asserted here: xcatd= and destiny= appear on the kernel
# command line only for the genesis states, and an OS install is booted
# with the distribution installer's command line. The genesis form is
# asserted where a node is put into one -- see the ordering and discovery
# scenarios.
# --- P-12 -------------------------------------------------------------------
+10 -2
View File
@@ -26,8 +26,16 @@ type = tftp
path = petitboot/%(node)s
assert =
size > 0
text contains xcatd=%(master)s:%(xcatport)s
text contains destiny=%(destiny)s
# The state nodeset was told to write, on the first line, as for the other
# loader families. xcatd= and destiny= are not asserted here: they are
# written on the kernel command line only for the genesis states, and an OS
# install is booted with the distribution installer's command line.
text starts-with #%(destiny)s
# petitboot reads one kernel line and one initrd line; without them the
# firmware shows an empty boot menu and waits, which from a console looks
# exactly like a node that never got a config at all.
text contains kernel
text contains initrd
[step byhex]
type = tftp
+6 -2
View File
@@ -32,8 +32,12 @@ path = pxelinux.cfg/%(node)s
assert =
size > 0
text contains DEFAULT
text contains xcatd=%(master)s:%(xcatport)s
text contains destiny=%(destiny)s
# The state nodeset was told to write, on the first line, as for grub2.
text starts-with #%(destiny)s
# pxelinux's answer to the question BOOTIF answers for grub2: IPAPPEND 2
# makes the loader append BOOTIF itself, from the interface it booted on.
# Without it the installer picks an interface of its own choosing.
text contains IPAPPEND 2
[step byhex]
type = tftp
+13 -11
View File
@@ -7,7 +7,7 @@
#
# provtest run \
# --set server=10.99.1.1 --set client=10.99.1.11 \
# --set node=provtestcn --set nextserver=10.99.1.1 \
# --set node=provtestcn --set httpport=80 \
# conf/tftp-xnba.conf
[defaults]
@@ -33,21 +33,23 @@ assert =
[step kernelurl]
type = extract
from = $script.text
pattern = imgfetch\s+(?:-n\s+\S+\s+)?(\S+)
pattern = imgfetch\s+(?:-n\s+\S+\s+)?https?://[^/\s]+(/\S+)
# The host is deliberately not asserted, because xnba deliberately does not
# write one: the script says ${next-server}, the variable gpxe fills in from
# the DHCP acknowledgement, so the node fetches from whoever booted it -- its
# service node in a hierarchical cluster, and not necessarily the management
# node. Which address arrives in that acknowledgement is dhcptest's subject.
# What is assertable here is the path, and that it is served.
assert =
matched == yes
# The host in the URL is the address DHCP handed out as next-server. xnba
# builds it from the node's own attributes, so a node in a hierarchical
# cluster that is pointed at the management node instead of its service
# node fetches across a link that may not carry it.
value starts-with http://%(nextserver)s
[step kernel]
type = http
url = $kernelurl.value
# The assertion that matters: the URL the node was told to use answers. Not a
# URL this file built out of the same parts, which would only prove that two
# copies of the same assumption agree.
path = $kernelurl.value
port = %(httpport)s
# The assertion that matters: the path the node was told to fetch answers.
# Not a path this file built out of the same parts, which would only prove
# that two copies of the same assumption agree.
assert =
status == 200
size > 0
+38 -6
View File
@@ -9,9 +9,15 @@
# "a signed cluster certificate", so both halves are asserted: that it is made,
# and that a client which cannot answer it gets nothing.
#
# The credential asked for is named in <arg>, exactly as getcredentials.awk
# names it when remoteshell and xcatserver run on the installing node. Genesis
# asks for x509cert instead and encloses a CSR; that path is not exercised here
# because a CSR is not something a wire test can fabricate meaningfully, and
# the callback is the same callback either way.
#
# provtest run \
# --set server=10.99.1.1 --set client=10.99.1.11 \
# --set xcatport=3001 --set credtype=xcat \
# --set xcatport=3001 --set credtype=xcat_server_cred \
# conf/xcatd-credentials.conf
[defaults]
@@ -31,7 +37,7 @@ description = A client that answers the callback is given a signed certificate
type = xcatreq
command = getcredentials
element =
credentials = %(credtype)s
arg = %(credtype)s
# The listener goes up on the client's port 300 before the request is sent,
# because the server connects back while the request is still open: a listener
# started afterwards would be started too late.
@@ -42,8 +48,12 @@ assert =
# P-63: the callback arrived, and on the port the request named.
callback_seen == yes
callback_data contains CREDOKBYYOU
# P-62: and the answer carries something signed.
data present
# P-62: and the answer carries the credential that was asked for. The
# payload is wrapped as <data><content>..</content><desc>..</desc></data>
# (credentials.pm:362), so `content` is where to look and `desc` says
# which of several credentials a reply belongs to.
desc == %(credtype)s
content contains -----BEGIN
error absent
# --- P-61 -------------------------------------------------------------------
@@ -55,11 +65,33 @@ description = A client with no listener on the callback port is given nothing
type = xcatreq
command = getcredentials
element =
credentials = %(credtype)s
arg = %(credtype)s
# No callback_listen: the port is closed, the server's connection is refused,
# and the challenge goes unanswered. Anything signed arriving here would mean
# an address alone is enough to collect a cluster certificate.
expect = any
assert =
callback_seen == no
data absent
content absent
# --- P-61 -------------------------------------------------------------------
[scenario credentials-nameless]
description = A request that names no credential is refused without a callback
[step nameless]
type = xcatreq
command = getcredentials
# No <arg>: the request is well formed as XML and says nothing about what it
# wants. xcatd used to dereference the missing element and hand the client the
# resulting Perl error, file and line included, over a connection anything on
# the provisioning network can open. It must refuse instead, and must not
# spend a callback on a request it cannot serve.
callback_listen = 300
callback_reply = CREDOKBYME
callback_wait = 5
expect = any
assert =
content absent
callback_seen == no
error absent
+12 -9
View File
@@ -61,15 +61,18 @@ description = A known node is told its state, and everything it needs to reach i
type = xcatreq
command = getdestiny
assert =
# P-51
destiny == %(destiny)s
# P-52: an install destiny that names no kernel is a node that boots
# nothing and reports success.
kernel present
initrd present
# P-53
kcmdline contains xcatd=%(master)s:%(xcatport)s
kcmdline contains destiny=%(destiny)s
# P-51. The state is the whole chain.currstate string, which for an OS
# install is the word followed by the osimage nodeset resolved -- "install
# rhels9.99-x86_64-compute" and not "install". genesis matches on the
# first word, so that is what is asserted.
destiny starts-with %(destiny)s
# P-52 and P-53 are not asserted here, and the reason is worth writing
# down. getdestiny copies kernel, initrd and kcmdline out of the
# bootparams table (destiny.pm:968-976), and nothing in an ordinary
# install writes a row there: the loader config under $tftpdir is what
# names the kernel, and genesis uses the destiny field alone. bootparams
# is populated by the iSCSI and hypervisor paths instead. Asserting the
# three fields here would fail on every healthy cluster.
# P-55: destiny.pm:975-987 tries noderes.tftpserver, then
# noderes.xcatmaster, then the network's tftpserver, then site.master.
# Four sources, no error if the wrong one wins -- the node boots and then
+20 -10
View File
@@ -3,8 +3,13 @@
# The node fetches the script it is to run after the installer finishes, and it
# fetches it over whichever transport it has: TLS 3001 during genesis,
# plain 3002 from `xcatdsklspost` inside the installed system. Two paths, one
# script, and the client reads until `#END OF SCRIPT` -- so a body that is
# truncated without the marker is read as a hang, not as an error.
# script, and two framings for it, which is the thing to keep straight when
# reading the assertions below. Over 3001 the body arrives as <data> elements
# and is ended by <serverdone>. Over 3002 there is no envelope at all, so
# xcatd writes `#END OF SCRIPT` after the last line (xcatd:554) and the client
# reads until it -- a body truncated without the marker is read as a hang and
# not as an error. The marker is therefore asserted on the plain transport
# only; asserting it on the TLS one would be asserting the wrong protocol.
#
# provtest run \
# --set server=10.99.1.1 --set client=10.99.1.11 \
@@ -21,14 +26,18 @@ retries = 2
# --- P-57 -------------------------------------------------------------------
[scenario postscript-terminated]
description = A known node's postscript arrives complete, with its end marker
description = A known node's postscript arrives complete and is ended by the server
[step getpostscript]
type = xcatreq
port = %(xcatport)s
command = getpostscript
assert =
text contains #END OF SCRIPT
# The envelope's end marker on this transport. A response without it is a
# connection the node is still waiting on.
serverdone == yes
# And the body is this node's: the script is built per node, and a node
# that runs somebody else's sets up somebody else's network and keys.
text contains %(node)s
# --- P-58 -------------------------------------------------------------------
@@ -63,16 +72,17 @@ type = xcatreq
port = %(xcatport)s
command = getpostscript
assert =
text contains #END OF SCRIPT
serverdone == yes
[step marker]
type = extract
from = $overtls.text
# A line from the body that is specific to this node and to this run, used
# below as the thing the other transport has to agree about. The two framings
# differ -- XML elements on one side, bare lines on the other -- so a byte
# comparison of the responses would compare the envelopes, not the script.
pattern = ^\s*(NODE=\S+|MASTER=\S+)\s*$
# An assignment from the body that is specific to this node, used below as the
# thing the other transport has to agree about. The two framings differ --
# XML elements on one side, bare lines on the other -- so a byte comparison of
# the responses would compare the envelopes and not the script. Unanchored for
# the same reason: on this transport the line is wrapped in <data>.
pattern = (NODE=\S+)
assert =
matched == yes
@@ -65,7 +65,12 @@ REPLY_FIELDS = {
"xcatreq": frozenset(["destiny", "kernel", "initrd", "kcmdline",
"imgserver", "name", "error", "serverdone",
"elements", "data", "text", "handshake", "ok",
"callback_seen", "callback_data", "raw"]),
"callback_seen", "callback_data", "raw",
# getcredentials wraps its payload one level deeper,
# as <data><content/><desc/></data>, and `desc` is
# how a reply carrying several credentials says
# which one each part is.
"content", "desc"]),
"monitor": frozenset(["greeting", "lines", "raw", "text", "ok", "closed",
"error"]),
"flowrequest": frozenset(["replies", "count", "ok", "error", "raw"]),
@@ -63,6 +63,26 @@ def hex_ip(address, upper=True):
return text if upper else text.lower()
def hex_net(address, prefix, upper=True):
"""The name a per-network configuration is written under.
A loader that finds no file for its own address asks for ever shorter
prefixes of it, so the per-network file is named by as many hex digits as
the netmask covers, rounded up to a whole digit: 10.99.1.0/24 is 0A6301,
six digits and not eight. Anything defined for a network rather than for a
node -- which is everything a machine nobody has defined can reach -- is
named this way.
"""
text = hex_ip(address, upper=upper)
try:
bits = int(prefix)
except (TypeError, ValueError):
raise ValueError("not a prefix length: %r" % (prefix,))
if not 0 <= bits <= 32:
raise ValueError("not a prefix length: %r" % (prefix,))
return text[:(bits + 3) // 4]
def reverse_name(address):
"""The IN-ADDR.ARPA name a resolver is asked for a PTR by."""
ip = parse_ip(address)
+1 -1
View File
@@ -33,7 +33,7 @@ from .model import Reply
#: The elements a node's own scripts read out of a response. Everything else
#: found in the XML is still available through `elements.<name>`.
NAMED_ELEMENTS = ("destiny", "kernel", "initrd", "kcmdline", "imgserver",
"name", "data", "error")
"name", "data", "error", "content", "desc")
ELEMENT_RE = re.compile(r"<([A-Za-z_][\w.-]*)>([^<]*)</\1>")
+14 -1
View File
@@ -134,9 +134,22 @@ class AddressTests(unittest.TestCase):
self.assertEqual(netutil.hex_ip("10.99.1.11", upper=False), "0a63010b")
def test_hex_ip_of_a_network_address(self):
# The per-network discovery config is named by the network's own hex.
self.assertEqual(netutil.hex_ip("10.99.1.0"), "0A630100")
def test_a_per_network_config_is_named_by_the_prefix_only(self):
# 10.99.1.0/24 is 0A6301, not 0A630100: the file is named by as many
# digits as the netmask covers. Asking for all eight gets a machine
# with no definition nothing at all, which is the whole failure this
# stage exists to catch.
self.assertEqual(netutil.hex_net("10.99.1.0", 24), "0A6301")
self.assertEqual(netutil.hex_net("10.99.0.0", 16), "0A63")
self.assertEqual(netutil.hex_net("10.99.1.0", 26), "0A63010")
self.assertEqual(netutil.hex_net("10.99.1.0", 24, upper=False), "0a6301")
def test_a_prefix_that_is_not_one_is_refused(self):
self.assertRaises(ValueError, netutil.hex_net, "10.99.1.0", 33)
self.assertRaises(ValueError, netutil.hex_net, "10.99.1.0", "wide")
def test_hex_ip_rejects_what_is_not_an_address(self):
self.assertRaises(ValueError, netutil.hex_ip, "provtestcn")