2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-06 17:46:55 +00:00

test(provtest): add provtest, a wire-level provision chain client

provtest asks the management node the questions a booting node asks and
asserts on the answers. DNS, TFTP and HTTP are driven by dig, curl and
tftp, so the result is what a real client sees rather than what this
tool's own protocol parser believed. The xcatd stages use sockets: they
must choose their source address, because xcatd names a client by the
reverse lookup of the address a connection arrived from.

Scenarios are declarative .conf files sharing dhcptest's assertion
grammar, exit codes and TAP output. Nothing here reads the xCAT database
or runs an xCAT command; every expected value arrives via --set.

validate and list run offline, in a checkout, with no root.
This commit is contained in:
Daniel Hilst
2026-09-11 07:55:59 -03:00
parent 6f6acc1b08
commit 7ea4bbdfb4
17 changed files with 2824 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""provtest -- drive the xCAT provision chain the way a booting node does.
Runs straight out of a checkout: this script puts its own directory on
sys.path, so no installation, no virtualenv and no pip are involved. Nothing
beyond the standard library is imported; the DNS, HTTP and TFTP stages are
driven by `dig`, `curl` and `tftp`, and the xCAT stages by a socket.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
from provtest_lib.cli import main # noqa: E402 (path set up above)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,14 @@
"""provtest -- drive the xCAT provision chain the way a booting node does.
A node that is being provisioned speaks five protocols in turn: it resolves
names, fetches a loader and a configuration over TFTP, fetches a kernel and a
kickstart over HTTP, asks xcatd where it is going, and reports back while it
installs. This tool speaks all five, from a client's side, and asserts on what
comes back.
It never reads the xCAT database and never runs an xCAT command. Every value it
expects is supplied to it, so a test cannot be satisfied by asking xCAT to
confirm its own output.
"""
__version__ = "1.0"
@@ -0,0 +1,229 @@
"""The assertion mini-language: `target op value`.
One assertion per line, under a single multi-line `assert` key. A single key
holding many lines sidesteps configparser's one-value-per-key rule and keeps
the grammar uniform, so `status == NXDOMAIN` and `text contains destiny=install`
parse through exactly the same path.
The operator table is dhcptest's, so an operator reads the same in both suites.
"""
import re
from . import netutil, subst
from .errors import ConfigError
from .model import Assertion
#: Targets that describe the step rather than the reply.
META_TARGETS = frozenset(["attempt"])
#: Operators that take no value.
NULLARY_OPS = frozenset(["present", "absent"])
#: Operators asserting that the reply is *not* something, which a reply that
#: says nothing on the subject satisfies.
NEGATIVE_OPS = frozenset(["!=", "not-in"])
OPS = frozenset([
"==", "!=", "in", "not-in", "present", "absent",
"matches", "contains", "starts-with", "ends-with",
"<", "<=", ">", ">=",
])
class Result(object):
"""The outcome of one assertion, carrying enough to explain a failure."""
__slots__ = ("assertion", "ok", "expected", "actual", "detail")
def __init__(self, assertion, ok, expected=None, actual=None, detail=""):
self.assertion = assertion
self.ok = ok
self.expected = expected
self.actual = actual
self.detail = detail
def __repr__(self):
return "Result(%r, ok=%r, expected=%r, actual=%r)" % (
self.assertion.render(), self.ok, self.expected, self.actual)
def parse(line, source=""):
"""Parse one assertion line into an `Assertion`."""
text = line.strip()
if not text:
raise ConfigError("empty assertion", source)
parts = text.split(None, 2)
if len(parts) < 2:
raise ConfigError("assertion needs at least a target and an operator: %r"
% (text,), source)
target, op = parts[0], parts[1]
value = parts[2] if len(parts) > 2 else None
if op not in OPS:
raise ConfigError(
"unknown operator %r in %r (expected one of: %s)"
% (op, text, ", ".join(sorted(OPS))), source)
if op in NULLARY_OPS:
if value:
raise ConfigError("operator %r takes no value: %r" % (op, text), source)
value = None
elif value is None:
# `error != ` with nothing after it is a legitimate "must be non-empty".
value = ""
if op == "matches":
try:
re.compile(value)
except re.error as exc:
raise ConfigError("bad regex %r: %s" % (value, exc), source)
return Assertion(target=target, op=op, value=value, source=source, text=text)
def parse_block(block, source=""):
"""Parse a multi-line `assert = ...` value into a tuple of assertions."""
result = []
for number, raw in enumerate(str(block or "").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
where = "%s line %d of assert block" % (source, number) if source else ""
result.append(parse(line, where))
return tuple(result)
# ---------------------------------------------------------------------------
# evaluation
def evaluate(assertion, reply, context, extras=None):
"""Check one assertion against one reply."""
extras = extras or {}
expected = None
if assertion.value is not None:
expected = subst.resolve(assertion.value, context)
present, actual = _actual(assertion.target, reply, context, extras)
if assertion.op == "present":
return Result(assertion, present, expected="present",
actual="present" if present else "absent")
if assertion.op == "absent":
return Result(assertion, not present, expected="absent",
actual="present" if present else "absent")
if not present:
# A negative comparison is satisfied by a target that is not there at
# all: a config that names no kernel has certainly not named the wrong
# one. `absent` remains the way to assert the absence itself.
if assertion.op in NEGATIVE_OPS:
return Result(assertion, True, expected=expected, actual=None,
detail="%s is not present in the reply"
% (assertion.target,))
return Result(assertion, False, expected=expected, actual=None,
detail="%s is not present in the reply" % (assertion.target,))
ok, detail = _compare(assertion.op, actual, expected)
return Result(assertion, ok, expected=expected,
actual=subst.format_value(actual), detail=detail)
def _actual(target, reply, context, extras):
"""Return `(present, value)` for one assertion target."""
if target in META_TARGETS:
if target not in extras:
return False, None
return True, extras[target]
if target.startswith("$"):
try:
return True, subst.resolve(target, context)
except ConfigError:
return False, None
if reply is None:
return False, None
if not reply.has(target):
return False, None
return True, reply.field(target)
def _compare(op, actual, expected):
"""Type-aware comparison. Returns `(ok, detail)`."""
if op in ("==", "!="):
same = _equal(actual, expected)
return (same if op == "==" else not same), ""
if op in ("in", "not-in"):
inside = _member(actual, expected)
return (inside if op == "in" else not inside), ""
text = subst.format_value(actual)
if op == "matches":
return bool(re.search(expected, text, re.MULTILINE)), ""
if op == "contains":
return expected in text, ""
if op == "starts-with":
return text.startswith(expected), ""
if op == "ends-with":
return text.endswith(expected), ""
left, right = _as_numbers(actual, expected)
if left is None:
return False, "%r and %r are not both numbers" % (actual, expected)
if op == "<":
return left < right, ""
if op == "<=":
return left <= right, ""
if op == ">":
return left > right, ""
if op == ">=":
return left >= right, ""
raise ConfigError("unhandled operator %r" % (op,))
def _equal(actual, expected):
"""Compare as addresses, then as numbers, then as text.
A list on the left means "any of these", so a name with two A records
satisfies `data == <one of them>`. Writing the whole set is `data.0` and
`data.1`, or `count == 2`.
"""
if isinstance(actual, (list, tuple)):
return any(_equal(item, expected) for item in actual)
left_ip = netutil.parse_ip(subst.format_value(actual))
right_ip = netutil.parse_ip(expected)
if left_ip is not None and right_ip is not None:
return left_ip == right_ip
left, right = _as_numbers(actual, expected)
if left is not None:
return left == right
return subst.format_value(actual) == str(expected)
def _member(actual, expected):
"""`in` means membership: of a CIDR, of a `first-last` range, or of a list."""
text = subst.format_value(actual)
if "/" in expected and netutil.parse_ip(text) is not None:
return netutil.ip_in(text, expected)
if netutil.parse_range(expected) is not None:
return netutil.ip_in_range(text, expected)
candidates = [item.strip() for item in expected.split(",") if item.strip()]
return any(_equal(actual, candidate) for candidate in candidates)
def _as_numbers(actual, expected):
"""Both sides as ints, or `(None, None)` when either is not numeric."""
try:
left = actual if isinstance(actual, int) else int(str(actual).strip(), 0)
right = expected if isinstance(expected, int) else int(str(expected).strip(), 0)
except (TypeError, ValueError):
return None, None
return left, right
+160
View File
@@ -0,0 +1,160 @@
"""Command line interface.
provtest run [options] CONF [CONF...] # execute scenarios
provtest validate CONF [CONF...] # check offline
provtest list CONF [CONF...] # show what a file does
`validate` and `list` need no root, no network and no client programs, so they
run in a checkout-only CI job -- which is where a typo in a field name or a
reference to a step that has not run yet should be caught, long before a
management node is involved.
"""
import argparse
import sys
from . import config, machine, report
from .errors import EXIT_CONFIG, EXIT_OK, ProvTestError
def build_parser():
parser = argparse.ArgumentParser(
prog="provtest",
description="Drive the xCAT provision chain the way a booting node "
"does, one declarative .conf file at a time.")
subparsers = parser.add_subparsers(dest="command")
run = subparsers.add_parser(
"run", help="execute scenarios against a real management node")
_add_conf_arguments(run)
run.add_argument("-b", "--bind", metavar="ADDR",
help="source address for every request; xcatd names a "
"client by the reverse lookup of this address")
run.add_argument("--timeout", type=float, metavar="SECS",
help="per-attempt timeout (default: 5)")
run.add_argument("--retries", type=int, metavar="N",
help="attempts per step (default: 2)")
run.add_argument("--format", default="tap", choices=sorted(report.REPORTERS),
help="output format (default: tap)")
run.add_argument("-s", "--scenario", action="append", default=[],
metavar="NAME", help="run only this scenario (repeatable)")
run.add_argument("-v", "--verbose", action="count", default=0,
help="print each reply as it arrives")
validate = subparsers.add_parser(
"validate", help="parse and check .conf files without any network I/O")
_add_conf_arguments(validate)
listing = subparsers.add_parser(
"list", help="list the scenarios and steps in .conf files")
_add_conf_arguments(listing)
return parser
def _add_conf_arguments(parser):
parser.add_argument("conf", nargs="+", metavar="CONF",
help="a .conf file in INI syntax")
parser.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
dest="settings",
help="define %%(KEY)s for substitution (repeatable)")
def parse_settings(pairs):
settings = {}
for pair in pairs:
if "=" not in pair:
raise ProvTestError("--set needs KEY=VALUE, got %r" % (pair,))
key, _, value = pair.partition("=")
settings[key.strip()] = value
return settings
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
if not args.command:
parser.print_help()
return EXIT_CONFIG
try:
if args.command == "validate":
return command_validate(args)
if args.command == "list":
return command_list(args)
if args.command == "run":
return command_run(args)
except ProvTestError as exc:
sys.stderr.write("provtest: %s\n" % (exc,))
return exc.exit_code
parser.print_help()
return EXIT_CONFIG
def command_validate(args):
# Raw: a file is checked in a checkout, where no value has been supplied
# for %(name)s yet, so interpolating it here would fail every time.
settings = parse_settings(args.settings)
scenarios, _ = config.load(args.conf, settings, raw=True)
problems = []
for scenario in scenarios:
problems.extend(machine.validate_scenario(scenario))
for problem in problems:
sys.stderr.write("provtest: %s\n" % (problem,))
if problems:
return EXIT_CONFIG
needed = sorted(machine.required_variables(scenarios) - set(settings))
sys.stdout.write("%d scenario(s) OK in %d file(s)\n"
% (len(scenarios), len(args.conf)))
if needed:
sys.stdout.write("required variables: %s\n" % (", ".join(needed),))
return EXIT_OK
def command_list(args):
scenarios, _ = config.load(args.conf, parse_settings(args.settings), raw=True)
for scenario in scenarios:
sys.stdout.write("%s\n" % (scenario.name,))
if scenario.description:
sys.stdout.write(" %s\n" % (scenario.description,))
sys.stdout.write(" source: %s\n" % (scenario.source,))
for step in scenario.steps:
sys.stdout.write(" step %-16s type=%-12s expect=%-4s asserts=%d\n"
% (step.name, step.type, step.expect or "ok",
len(step.assertions)))
needed = sorted(machine.required_variables([scenario]))
if needed:
sys.stdout.write(" requires: %s\n" % (", ".join(needed),))
return EXIT_OK
def command_run(args):
from . import runner # spawns clients and opens sockets; not for list
scenarios, _ = config.load(args.conf, parse_settings(args.settings))
if args.scenario:
wanted = set(args.scenario)
missing = wanted - set(s.name for s in scenarios)
if missing:
raise ProvTestError(
"no such scenario: %s" % (", ".join(sorted(missing)),))
scenarios = [s for s in scenarios if s.name in wanted]
problems = []
for scenario in scenarios:
problems.extend(machine.validate_scenario(scenario))
if problems:
for problem in problems:
sys.stderr.write("provtest: %s\n" % (problem,))
return EXIT_CONFIG
reporter = report.make(args.format)
options = runner.RunOptions(
bind=args.bind,
timeout=args.timeout,
retries=args.retries,
verbose=args.verbose,
)
return runner.run(scenarios, reporter, options)
@@ -0,0 +1,242 @@
"""Reading declarative .conf files with configparser.
Layout of a file::
[vars] optional; %(name)s values, overridable with --set
[defaults] optional; step keys applied to every step
[scenario <name>] starts a scenario
[step <name>] belongs to the scenario above it
Variables use configparser's own `BasicInterpolation`: `%(name)s` is resolved
by the library, out of `[vars]` and of anything passed with `--set`. `$` has no
meaning to it, so `$grubcfg.text` reaches the runtime untouched -- and it has
to, because its value is a reply that has not arrived when the file is read.
Unknown keys are a hard error rather than a silent skip, matching `xcattest`,
where an unrecognised `check:` fails the case instead of passing it.
"""
import configparser
import os
from . import assertions as assertions_mod
from . import machine
from .errors import ConfigError
from .model import Scenario, Step
#: Keys accepted in a `[scenario ...]` section.
SCENARIO_KEYS = frozenset(["description"])
#: Keys `[defaults]` may carry. `type` and `assert` are per-step by nature: a
#: default type would make a mistyped `type=` silently become another protocol,
#: and a default assertion would be made about replies it was never written for.
DEFAULTS_KEYS = frozenset(
key
for step_type in machine.STEP_KEYS
for key in machine.allowed_keys(step_type)
) - frozenset(["type", "assert", "description"])
def load(paths, overrides=None, raw=False):
"""Read every path and return `(scenarios, variables)`.
With `raw`, `%(name)s` is left in place instead of being interpolated, so
`validate` can check a file in a checkout where no value has been supplied
for it yet.
"""
scenarios = []
variables = {}
for path in paths:
file_scenarios, file_vars = load_file(path, overrides, raw)
scenarios.extend(file_scenarios)
variables.update(file_vars)
variables.update(overrides or {})
_reject_duplicate_scenarios(scenarios)
return scenarios, variables
def load_file(path, overrides=None, raw=False):
"""Read one .conf file into `(scenarios, variables)`."""
if not os.path.isfile(path):
raise ConfigError("no such file", path)
parser = configparser.ConfigParser(
interpolation=None,
inline_comment_prefixes=(";",),
delimiters=("=",),
)
parser.optionxform = str.lower
try:
with open(path) as handle:
parser.read_file(handle, source=path)
except configparser.Error as exc:
raise ConfigError(str(exc), path)
variables = dict(parser["vars"]) if parser.has_section("vars") else {}
variables.update(overrides or {})
scenarios = []
defaults = {}
current = None
steps = []
for section in parser.sections():
items = _items(parser, section, path, raw, variables)
kind, _, name = section.partition(" ")
kind = kind.strip().lower()
name = name.strip()
where = "%s [%s]" % (path, section)
if section.lower() == "vars":
continue # read above, into `variables`
if section.lower() == "defaults":
_reject_unknown(items, DEFAULTS_KEYS, where)
defaults = items
continue
if kind == "scenario":
if not name:
raise ConfigError("a [scenario] section needs a name", where)
if current is not None:
scenarios.append(_build_scenario(current, steps, path))
_reject_unknown(items, SCENARIO_KEYS, where)
current = (name, items)
steps = []
continue
if kind == "step":
if current is None:
raise ConfigError(
"a [step] section must follow a [scenario] section", where)
if not name:
raise ConfigError("a [step] section needs a name", where)
steps.append(_build_step(name, items, defaults, where))
continue
raise ConfigError(
"unknown section; expected [vars], [defaults], [scenario <name>] "
"or [step <name>]", where)
if current is not None:
scenarios.append(_build_scenario(current, steps, path))
elif steps:
raise ConfigError("steps without a scenario", path)
if not scenarios:
raise ConfigError("no [scenario ...] section found", path)
return scenarios, variables
def _build_scenario(current, steps, path):
name, items = current
if not steps:
raise ConfigError("scenario %r has no steps" % (name,), path)
return Scenario(
name=name,
description=items.get("description", ""),
steps=steps,
source=path,
)
def _build_step(name, items, defaults, where):
step_type = (items.get("type") or "").strip().lower()
if not step_type:
raise ConfigError("step %r has no type=" % (name,), where)
machine.check_type(step_type, where)
allowed = machine.allowed_keys(step_type)
_reject_unknown(items, allowed, where)
# A default only applies where the type in hand can use it, so one
# `[defaults] server =` serves dns, tftp, http and xcatreq steps alike
# without making `path =` illegal for the dns step in the same file.
merged = dict((key, value) for key, value in defaults.items()
if key in allowed)
merged.update(items)
expect = (merged.get("expect") or "").strip().lower() or None
timeout = _float(merged.get("timeout"), 5.0, "timeout", where)
retries = _int(merged.get("retries"), 2, "retries", where)
parsed = assertions_mod.parse_block(merged.pop("assert", ""), where)
for key in ("type", "expect", "timeout", "retries"):
merged.pop(key, None)
return Step(
name=name,
type=step_type,
expect=expect,
params=merged,
assertions=parsed,
timeout=timeout,
retries=retries,
source=where,
)
#: configparser's own variable syntax, `%(name)s`. The parser is built with
#: `interpolation=None` and this is applied by hand, per value, because both of
#: the built-in ways of supplying the values -- DEFAULTSECT and `get(vars=...)`
#: -- also make each variable an *option*, so `--set path=/install` would
#: overwrite the step key of the same name. Interpolating explicitly keeps the
#: two namespaces apart: variables are only ever read through `%(...)s`.
INTERPOLATION = configparser.BasicInterpolation()
def _items(parser, section, path, raw, variables=None):
"""One section's keys, with `%(name)s` resolved out of `variables`."""
items = {}
for key in parser.options(section):
value = parser.get(section, key, raw=True)
if raw:
items[key] = value
continue
try:
items[key] = INTERPOLATION.before_get(
parser, section, key, value, variables or {})
except configparser.InterpolationMissingOptionError as exc:
raise ConfigError(
"%%(%s)s is not defined; pass --set %s=<value>"
% (exc.reference, exc.reference), "%s [%s]" % (path, section))
except configparser.Error as exc:
raise ConfigError(str(exc), "%s [%s]" % (path, section))
return items
def _reject_unknown(items, allowed, where):
unknown = sorted(set(items) - set(allowed))
if unknown:
raise ConfigError(
"unknown key(s): %s (allowed: %s)"
% (", ".join(unknown), ", ".join(sorted(allowed))), where)
def _reject_duplicate_scenarios(scenarios):
seen = {}
for scenario in scenarios:
if scenario.name in seen:
raise ConfigError(
"scenario %r is defined in both %s and %s"
% (scenario.name, seen[scenario.name], scenario.source))
seen[scenario.name] = scenario.source
def _float(value, default, key, where):
if value is None or value == "":
return default
try:
return float(value)
except ValueError:
raise ConfigError("%s= must be a number, got %r" % (key, value), where)
def _int(value, default, key, where):
if value is None or value == "":
return default
try:
return int(value, 0) if isinstance(value, str) else int(value)
except ValueError:
raise ConfigError("%s= must be an integer, got %r" % (key, value), where)
+120
View File
@@ -0,0 +1,120 @@
"""DNS, asked the way an operator asks: `dig`.
A node resolves three things before it can be provisioned -- its own name, the
master's name, and whatever the kickstart refers to -- and xcatd then resolves
the node's address backwards to decide which node it is talking to. All four
are one resolver query, so one step type covers the whole stage.
The query is always sent to a named server. A booting node uses the resolver
DHCP gave it, and the point of the test is that *that* server answers, not that
the host running the test has some resolver that does.
"""
import re
from . import proc
from .model import Reply
HEADER_RE = re.compile(r"->>HEADER<<-\s+opcode:\s*(\w+),\s*status:\s*(\w+),\s*id:\s*(\d+)")
FLAGS_RE = re.compile(r"^;;\s*flags:\s*([^;]*);\s*(.*)$")
COUNT_RE = re.compile(r"(\w+):\s*(\d+)")
def build(server, name, rrtype="A", port=53, bind=None, recursion=True,
timeout=5.0, tries=1):
"""The `dig` command line for one query."""
dig = proc.require("dig", "DNS")
argv = [dig, "@%s" % (server,), name, rrtype.upper()]
if port and int(port) != 53:
argv += ["-p", str(int(port))]
if bind:
argv += ["-b", str(bind)]
argv += [
"+noall", "+comments", "+answer", "+authority",
"+tries=%d" % (max(1, int(tries)),),
"+time=%d" % (max(1, int(round(float(timeout)))),),
"+norecurse" if not recursion else "+recurse",
]
return argv
def query(server, name, rrtype="A", port=53, bind=None, recursion=True,
timeout=5.0):
"""Run one query and decode what `dig` printed into a `Reply`."""
argv = build(server, name, rrtype, port, bind, recursion, timeout)
done = proc.run(argv, timeout=float(timeout) + 5.0)
reply = decode(done.text, name=name, rrtype=rrtype, server=server)
reply.sent = done.command()
reply.raw = done.out
if done.timed_out:
reply.ok = False
reply.error = "dig did not return within the timeout"
elif done.rc != 0 and not reply.fields.get("status"):
reply.ok = False
reply.error = _why(done)
return reply
def decode(text, name="", rrtype="", server=""):
"""Turn `dig +noall +comments +answer +authority` output into a `Reply`."""
status = ""
flags = ""
counts = {}
names, ttls, types, data, records = [], [], [], [], []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith(";"):
match = HEADER_RE.search(stripped)
if match:
status = match.group(2)
continue
match = FLAGS_RE.match(stripped)
if match:
flags = match.group(1).strip()
counts = dict((key.upper(), int(value))
for key, value in COUNT_RE.findall(match.group(2)))
continue
parts = stripped.split(None, 4)
if len(parts) < 5 or parts[2].upper() != "IN":
continue
# An authority section shares the record syntax, and SOA records there
# are how a resolver says "this zone is mine and the name is not in
# it", so they are kept apart rather than counted as answers.
if parts[3].upper() == "SOA":
continue
names.append(parts[0].rstrip("."))
ttls.append(int(parts[1]) if parts[1].isdigit() else 0)
types.append(parts[3].upper())
data.append(parts[4].strip().rstrip(".") if parts[3].upper() != "TXT"
else parts[4].strip())
records.append(stripped)
fields = {
"status": status,
"rcode": status,
"flags": flags,
"count": counts.get("ANSWER", len(data)),
"authority": counts.get("AUTHORITY", 0),
"name": names,
"ttl": ttls,
"type": types,
"data": data,
"answers": records,
"question": name,
"server": server,
}
ok = status == "NOERROR"
return Reply(kind="dns", fields=fields, ok=ok,
error="" if status else "no answer was received")
def _why(done):
"""The first line `dig` wrote to stderr, or a fallback."""
for line in (done.errtext + done.text).splitlines():
if "error" in line.lower() or "could be reached" in line.lower():
return line.strip().lstrip(";").strip()
return "dig exited %d" % (done.rc,)
@@ -0,0 +1,46 @@
"""Exceptions and exit codes.
The codes are dhcptest's, so a caller driving both suites reads one table:
0 every assertion held
1 at least one assertion failed
2 the configuration or the command line is wrong
3 this host cannot run the test at all
3 is deliberately not 0. A host with no `dig` reports that it tested nothing
rather than reporting a green run, because a pass that proves nothing is worse
than a failure that says why.
"""
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_CONFIG = 2
EXIT_UNSUPPORTED = 3
class ProvTestError(Exception):
"""Base class: every failure this tool raises on purpose."""
exit_code = EXIT_CONFIG
class ConfigError(ProvTestError):
"""A .conf file says something that cannot be carried out."""
exit_code = EXIT_CONFIG
def __init__(self, message, source=""):
self.message = message
self.source = source
Exception.__init__(self, self.__str__())
def __str__(self):
if self.source:
return "%s: %s" % (self.source, self.message)
return self.message
class UnsupportedError(ProvTestError):
"""The host is missing something the run cannot go on without."""
exit_code = EXIT_UNSUPPORTED
@@ -0,0 +1,165 @@
"""HTTP, fetched the way an installer fetches: `curl`.
Two different things are served over HTTP during a provision and both are
tested here. grub2-http and xnba fetch the kernel and initrd from
`/tftpboot/...`; the installer fetches the repository and its kickstart or
preseed from `/install/...`. They are two Apache aliases over the same port,
and a cluster that has moved off port 80 has to have moved the URL in the
kernel command line with it -- which is why the port comes out of the config
the node was handed, not out of a constant.
The body is written to a file rather than captured through a pipe, so a digest
of a kernel can be compared against the same kernel fetched over TFTP without
either ever being decoded as text.
"""
import hashlib
import os
import tempfile
from . import proc
from .model import Reply
#: Above this, a body is measured and digested but not carried as text.
TEXT_LIMIT = 1 << 20
#: What `-w` prints, in this order, on one line.
WRITE_OUT = "%{http_code}\\t%{size_download}\\t%{content_type}\\t%{url_effective}\\n"
def url_for(server, path, port=80, scheme="http"):
"""Join a server, a port and a path into a URL."""
text = str(path)
if text.startswith("http://") or text.startswith("https://"):
return text
if not text.startswith("/"):
text = "/" + text
port = int(port or 80)
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
return "%s://%s%s" % (scheme, server, text)
return "%s://%s:%d%s" % (scheme, server, port, text)
def build(url, method="GET", bind=None, headers=(), timeout=10.0,
body_file=None, header_file=None, insecure=False):
"""The `curl` command line for one request."""
curl = proc.require("curl", "HTTP")
argv = [curl, "-sS", "--globoff", "--max-time", str(int(float(timeout))),
"-o", body_file, "-D", header_file, "-w", WRITE_OUT]
if bind:
argv += ["--interface", str(bind)]
if insecure:
argv += ["-k"]
method = (method or "GET").upper()
if method == "HEAD":
argv += ["-I"]
elif method != "GET":
argv += ["-X", method]
for header in headers:
argv += ["-H", header]
argv += [url]
return argv
def fetch(url, method="GET", bind=None, headers=(), timeout=10.0,
insecure=False):
"""Make one request and return a `Reply` describing the response."""
body_handle, body_file = tempfile.mkstemp(prefix="provtest-http-")
head_handle, header_file = tempfile.mkstemp(prefix="provtest-hdr-")
os.close(body_handle)
os.close(head_handle)
try:
argv = build(url, method, bind, headers, timeout, body_file,
header_file, insecure)
done = proc.run(argv, timeout=float(timeout) + 5.0)
return _decode(done, url, body_file, header_file)
finally:
for path in (body_file, header_file):
try:
os.unlink(path)
except OSError:
pass
def _decode(done, url, body_file, header_file):
status, size, content_type, effective = _write_out(done.text, url)
disk_size = os.path.getsize(body_file) if os.path.exists(body_file) else 0
body = b""
if 0 < disk_size <= TEXT_LIMIT:
with open(body_file, "rb") as handle:
body = handle.read()
digest = _digest(body_file) if disk_size else ""
error = ""
if done.timed_out:
error = "the request did not finish within the timeout"
elif done.rc != 0:
error = (done.errtext.strip().replace("curl: ", "", 1)
or "curl exited %d" % (done.rc,))
fields = {
"status": status,
"size": size or disk_size,
"sha256": digest,
"text": body.decode("utf-8", "replace"),
"url": effective,
"header": _headers(header_file),
"content_type": content_type,
"error": error,
"ok": bool(status and 200 <= status < 400),
}
return Reply(kind="http", fields=fields, ok=fields["ok"], error=error,
sent=done.command(), raw=body)
def _write_out(text, url):
"""The last `-w` line: status, size, content type, effective URL.
The last line, not the first: a redirect that `curl` was asked to follow
would print one per hop, and the response under test is the final one.
"""
for line in reversed(text.strip().splitlines()):
parts = line.split("\t")
if len(parts) != 4:
continue
try:
status = int(parts[0])
except ValueError:
status = 0
try:
size = int(parts[1])
except ValueError:
size = 0
return status, size, parts[2].strip(), parts[3].strip() or url
return 0, 0, "", url
def _headers(path):
"""The response headers, lowercased, as a mapping.
Only the last response block is kept, for the same reason as above.
"""
headers = {}
if not os.path.exists(path):
return headers
with open(path, "rb") as handle:
text = handle.read().decode("utf-8", "replace")
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.upper().startswith("HTTP/"):
headers = {}
continue
key, _, value = stripped.partition(":")
if value:
headers[key.strip().lower()] = value.strip()
return headers
def _digest(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -0,0 +1,229 @@
"""What a step of each type may say, and what its reply will hold.
This is the offline half of the tool. `validate` runs it in a checkout with no
network, no root and no server, so a typo in a field name or a step that names
no server is a configuration error reported before anything is sent.
Every table here is declarative on purpose: adding a protocol means adding a
row, not a branch in the runner.
"""
from . import subst
from .errors import ConfigError
#: Keys every step may carry, whatever its type. `bind` is the client's own
#: address: xcatd names a client by the reverse lookup of the address it
#: connected from, so which end of the veth pair a request leaves by is itself
#: under test, and cannot be left to the kernel's source selection.
COMMON_KEYS = frozenset(["type", "expect", "timeout", "retries", "assert",
"bind"])
#: Per type: the keys it accepts, beyond COMMON_KEYS.
STEP_KEYS = {
"dns": frozenset(["server", "port", "name", "rrtype", "recursion"]),
"tftp": frozenset(["server", "port", "path", "mode"]),
"http": frozenset(["server", "port", "path", "url", "method", "header",
"insecure"]),
"xcatreq": frozenset(["server", "port", "command", "element", "raw",
"callback_port", "callback_listen", "callback_reply",
"callback_wait", "cert", "key", "source_port"]),
"monitor": frozenset(["server", "port", "send", "source_port"]),
"flowrequest": frozenset(["server", "port", "message", "source_port",
"replies"]),
"findme": frozenset(["server", "port", "payload", "encoding", "source_port",
"callback_listen", "callback_wait"]),
"extract": frozenset(["from", "pattern", "group"]),
"sleep": frozenset(["duration"]),
"noop": frozenset([]),
}
#: Keys naming a value that must be present for the step to mean anything.
REQUIRED_KEYS = {
"dns": ("server", "name"),
"tftp": ("server", "path"),
"http": (), # url, or server+path; checked below
"xcatreq": ("server", "command"),
"monitor": ("server", "send"),
"flowrequest": ("server",),
"findme": ("server",),
"extract": ("from", "pattern"),
"sleep": ("duration",),
"noop": (),
}
#: Per type: the reply fields a `.conf` may name, as an assertion target or
#: through `$step.field`. A name not in this set is a typo, and is reported as
#: one rather than silently never matching.
REPLY_FIELDS = {
"dns": frozenset(["status", "flags", "count", "data", "type", "ttl",
"name", "question", "answers", "authority", "server",
"rcode", "raw"]),
"tftp": frozenset(["ok", "size", "sha256", "text", "error", "path",
"server", "raw"]),
"http": frozenset(["status", "size", "sha256", "text", "url", "header",
"content_type", "error", "ok", "raw"]),
"xcatreq": frozenset(["destiny", "kernel", "initrd", "kcmdline",
"imgserver", "name", "error", "serverdone",
"elements", "data", "text", "handshake", "ok",
"callback_seen", "callback_data", "raw"]),
"monitor": frozenset(["greeting", "lines", "raw", "text", "ok", "closed",
"error"]),
"flowrequest": frozenset(["replies", "count", "ok", "error", "raw"]),
"findme": frozenset(["callbacks", "count", "ok", "error", "raw"]),
"extract": frozenset(["value", "groups", "matched", "count"]),
"sleep": frozenset([]),
"noop": frozenset([]),
}
#: Reply fields that are a mapping rather than a scalar or a list, and so are
#: addressed as `header.content-type` or `elements.destiny`.
MAPPING_FIELDS = frozenset(["header", "elements"])
#: `expect=` values. `fail` is not an error: a scenario asserting that a path
#: outside the tftp root is refused wants the refusal to be the passing result.
EXPECTATIONS = frozenset(["ok", "fail", "any"])
def known_step_types():
return frozenset(STEP_KEYS)
def allowed_keys(step_type):
return COMMON_KEYS | STEP_KEYS.get(step_type, frozenset())
def all_reply_fields():
"""Every field name any step type can produce."""
names = set()
for fields in REPLY_FIELDS.values():
names.update(fields)
return names
def check_field(field):
"""True when `field` is a name some step type's reply could carry.
Cross-type: a `$step.field` reference is resolved against whichever step
it names, and which step that is cannot be known from the text alone when
the reference is written in `[defaults]`. Validation is therefore that the
name exists *somewhere*; naming a field of the wrong type fails at run
time, where the reply is in hand and the message can say so.
"""
head, _, tail = field.rpartition(".")
if head:
if head in MAPPING_FIELDS:
return bool(tail)
if head not in all_reply_fields():
return False
try:
int(tail)
except ValueError:
return False
return True
return field in all_reply_fields()
def validate_scenario(scenario):
"""Every problem in one scenario, as a list of strings.
Returns rather than raises, so one pass over a file reports everything
wrong with it instead of the first thing.
"""
problems = []
seen = set()
for step in scenario.steps:
where = "%s scenario %r step %r" % (scenario.source, scenario.name, step.name)
if step.name in seen:
problems.append("%s: step %r is defined twice" % (where, step.name))
seen.add(step.name)
problems.extend(_validate_required(step, where))
problems.extend(_validate_expect(step, where))
problems.extend(_validate_targets(step, where))
problems.extend(_validate_references(step, scenario, seen, where))
return problems
def _validate_required(step, where):
problems = []
for key in REQUIRED_KEYS.get(step.type, ()):
if not step.param(key):
problems.append("%s: a %s step needs %s=" % (where, step.type, key))
if step.type == "http" and not step.param("url"):
if not (step.param("server") and step.param("path")):
problems.append(
"%s: an http step needs url=, or server= and path=" % (where,))
if step.type == "extract":
group = step.param("group")
if group not in (None, ""):
try:
int(group)
except ValueError:
problems.append("%s: group= must be a number, got %r"
% (where, group))
return problems
def _validate_expect(step, where):
if step.expect and step.expect not in EXPECTATIONS:
return ["%s: unknown expect=%r (known: %s)"
% (where, step.expect, ", ".join(sorted(EXPECTATIONS)))]
return []
def _validate_targets(step, where):
problems = []
fields = REPLY_FIELDS.get(step.type, frozenset())
for assertion in step.assertions:
target = assertion.target
if target.startswith("$"):
continue
head, _, tail = target.rpartition(".")
if head in MAPPING_FIELDS:
if head not in fields:
problems.append("%s: a %s reply carries no %s"
% (where, step.type, head))
continue
base = head or target
if base not in fields:
problems.append(
"%s: a %s reply has no field %r (it has: %s)"
% (where, step.type, target, ", ".join(sorted(fields))))
return problems
def _validate_references(step, scenario, seen, where):
"""Reject a `$step.field` naming a step that does not run before this one."""
problems = []
texts = [assertion.value for assertion in step.assertions]
texts.extend(str(value) for value in step.params.values())
texts.extend(assertion.target for assertion in step.assertions)
for text in texts:
for name, field in subst.references(text):
if name not in seen:
problems.append(
"%s: $%s.%s refers to a step that has not run yet"
% (where, name, field))
elif not check_field(field):
problems.append("%s: no reply field is called .%s" % (where, field))
return problems
def required_variables(scenarios):
"""Every `%(name)s` a run of these scenarios would have to be given."""
names = set()
for scenario in scenarios:
for step in scenario.steps:
for value in step.params.values():
names.update(subst.variables(value))
for assertion in step.assertions:
names.update(subst.variables(assertion.value))
names.update(subst.variables(assertion.target))
return names
def check_type(step_type, where=""):
if step_type not in STEP_KEYS:
raise ConfigError(
"unknown step type %r (known: %s)"
% (step_type, ", ".join(sorted(STEP_KEYS))), where)
@@ -0,0 +1,184 @@
"""Plain data structures shared across the tool.
No network and no subprocess: everything here can be built by hand in a unit
test, which is how the assertion language is tested without a server.
Written for Python 3.6, so no dataclasses.
"""
from .errors import ConfigError
class Assertion(object):
"""One line of the assertion mini-language: `target op value`.
For example::
status == NOERROR
data == 10.99.1.11
text contains destiny=install
sha256 == $tftpfetch.sha256
"""
__slots__ = ("target", "op", "value", "source", "text")
def __init__(self, target, op, value, source="", text=""):
self.target = target
self.op = op
self.value = value
self.source = source
self.text = text or self.render()
def render(self):
if self.value is None:
return "%s %s" % (self.target, self.op)
return "%s %s %s" % (self.target, self.op, self.value)
def __repr__(self):
return "Assertion(%r)" % (self.render(),)
class Step(object):
"""One client action plus the assertions made about what came back."""
__slots__ = ("name", "type", "expect", "params", "assertions", "timeout",
"retries", "source")
def __init__(self, name, type, expect, params, assertions,
timeout=5.0, retries=2, source=""):
self.name = name
self.type = type
self.expect = expect
self.params = dict(params)
self.assertions = tuple(assertions)
self.timeout = timeout
self.retries = retries
self.source = source
def param(self, key, default=None):
value = self.params.get(key, default)
if isinstance(value, str):
value = value.strip()
return value
def __repr__(self):
return "Step(%r, type=%r, expect=%r)" % (self.name, self.type, self.expect)
class Scenario(object):
"""A named sequence of steps, run in order against one server."""
__slots__ = ("name", "description", "steps", "source")
def __init__(self, name, description="", steps=(), source=""):
self.name = name
self.description = description
self.steps = tuple(steps)
self.source = source
def step(self, name):
for step in self.steps:
if step.name == name:
return step
raise ConfigError("no such step %r in scenario %r" % (name, self.name))
def __repr__(self):
return "Scenario(%r, steps=%d)" % (self.name, len(self.steps))
class Reply(object):
"""What one step got back, as a flat namespace of fields.
Unlike DHCP, the five protocols here share no header, so a reply is a bag
of named values rather than a fixed record. What each step type puts in it
is declared in `machine.REPLY_FIELDS`, so a `.conf` naming a field no step
of that type ever produces is rejected before any socket is opened.
A field may hold a list -- DNS answers, monitor lines, findme callbacks.
Indexing is written `data.0`, and the bare name of a list resolves to its
first element, so `data == 10.99.1.11` reads the way an operator expects
for the ordinary one-answer case.
"""
__slots__ = ("kind", "fields", "ok", "error", "sent", "attempt", "raw")
def __init__(self, kind="", fields=None, ok=True, error="", sent="",
attempt=0, raw=b""):
self.kind = kind
self.fields = dict(fields or {})
self.ok = ok
self.error = error
self.sent = sent
self.attempt = attempt
self.raw = raw
def field(self, name):
"""Resolve one `$step.<name>` reference, or raise KeyError.
`a.b` is looked up whole first, so a header written into the reply as
`header.content-type` wins over any attempt to index a list called
`header`. Only then is the name split into container and index.
"""
if name in self.fields:
return self._first(self.fields[name])
head, _, tail = name.rpartition(".")
if head and head in self.fields:
container = self.fields[head]
if isinstance(container, (list, tuple)):
try:
index = int(tail)
except ValueError:
raise KeyError("%s is not an index into .%s" % (tail, head))
if index >= len(container) or index < -len(container):
raise KeyError(
"%s reply has %d item(s) in .%s, so .%s does not exist"
% (self.kind, len(container), head, name))
return container[index]
if isinstance(container, dict):
if tail not in container:
raise KeyError("%s reply has no .%s" % (self.kind, name))
return container[tail]
raise KeyError("%s reply has no field .%s" % (self.kind or "this", name))
def has(self, name):
try:
value = self.field(name)
except KeyError:
return False
# An empty list is "nothing came back", which is absence, not a value.
if isinstance(value, (list, tuple)):
return bool(value)
return value not in (None, "")
@staticmethod
def _first(value):
if isinstance(value, (list, tuple)):
return value[0] if value else []
return value
def summary(self):
parts = []
for key in sorted(self.fields):
value = self.fields[key]
if key in ("text", "raw", "body"):
continue
parts.append("%s=%s" % (key, _short(value)))
head = "%s %s" % (self.kind or "reply", "ok" if self.ok else "failed")
if self.error:
head += " (%s)" % (self.error,)
return "%s %s" % (head, " ".join(parts))
def __repr__(self):
return "Reply(%s)" % (self.summary(),)
def _short(value, limit=60):
if isinstance(value, (list, tuple)):
text = "[" + ", ".join(str(item) for item in value) + "]"
elif isinstance(value, dict):
text = "{" + ", ".join("%s=%s" % (k, v) for k, v in sorted(value.items())) + "}"
else:
text = str(value)
if len(text) > limit:
text = text[:limit - 3] + "..."
return text
@@ -0,0 +1,91 @@
"""Address arithmetic, and nothing that touches a network.
Kept separate from the clients so that the assertion language can compare
addresses in a unit test with no interface, no root and no server.
"""
import ipaddress
def parse_ip(text):
"""An `IPv4Address`, or None when `text` is not one."""
try:
return ipaddress.IPv4Address(str(text).strip())
except (ipaddress.AddressValueError, ValueError):
return None
def ip_in(address, network):
"""True when `address` falls inside the CIDR block `network`."""
ip = parse_ip(address)
if ip is None:
return False
try:
net = ipaddress.IPv4Network(str(network).strip(), strict=False)
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError):
return False
return ip in net
def parse_range(text):
"""`first-last` as a pair of addresses, or None."""
if text is None or "-" not in str(text):
return None
first, _, last = str(text).strip().partition("-")
low, high = parse_ip(first), parse_ip(last)
if low is None or high is None:
return None
return low, high
def ip_in_range(address, text):
"""True when `address` is inside an inclusive `first-last` range."""
bounds = parse_range(text)
ip = parse_ip(address)
if bounds is None or ip is None:
return False
low, high = bounds
return low <= ip <= high
def hex_ip(address, upper=True):
"""The eight-digit hex a PXE or grub2 loader asks for its address by.
This is the encoding the loader itself performs, not xCAT's: 10.99.1.11
becomes 0A63010B. It is here so a scenario can say what the name should be
without the fixture having to compute it, and so the tool never has to ask
xCAT what name it wrote.
"""
ip = parse_ip(address)
if ip is None:
raise ValueError("not an IPv4 address: %r" % (address,))
text = "%08X" % (int(ip),)
return text if upper else text.lower()
def reverse_name(address):
"""The IN-ADDR.ARPA name a resolver is asked for a PTR by."""
ip = parse_ip(address)
if ip is None:
raise ValueError("not an IPv4 address: %r" % (address,))
return ip.reverse_pointer
def normalise_mac(text):
"""A MAC in the lowercase colon form, or ValueError."""
raw = str(text).strip().lower().replace("-", ":").replace(".", ":")
parts = [part for part in raw.split(":") if part != ""]
if len(parts) != 6:
raise ValueError("not a MAC address: %r" % (text,))
octets = []
for part in parts:
if len(part) > 2:
raise ValueError("not a MAC address: %r" % (text,))
value = int(part, 16)
octets.append("%02x" % (value,))
return ":".join(octets)
def dashed_mac(text):
"""The `01-aa-bb-...` form a grub2 or pxelinux per-MAC file is named by."""
return normalise_mac(text).replace(":", "-")
@@ -0,0 +1,92 @@
"""The one place a child process is started.
DNS, HTTP and TFTP are driven by the clients an operator would reach for --
`dig`, `curl`, `tftp` -- rather than by a hand-rolled implementation of each
protocol. What is under test is whether a real client gets a usable answer,
so using the real client removes a whole class of "the test's own parser was
wrong" from the result.
Confining the spawning to this module is what makes that safe. Output is read
from a pipe with a timeout and a hard kill, never through a shell, and every
argument list is built as a list, so nothing a `.conf` supplies can be
interpreted as a command.
"""
import os
import shutil
import subprocess
from .errors import UnsupportedError
class Completed(object):
"""What a client program did: exit status and the two streams, as bytes."""
__slots__ = ("argv", "rc", "out", "err", "timed_out")
def __init__(self, argv, rc, out=b"", err=b"", timed_out=False):
self.argv = list(argv)
self.rc = rc
self.out = out
self.err = err
self.timed_out = timed_out
@property
def text(self):
return self.out.decode("utf-8", "replace")
@property
def errtext(self):
return self.err.decode("utf-8", "replace")
def command(self):
return " ".join(self.argv)
def __repr__(self):
return "Completed(%r, rc=%r)" % (self.command(), self.rc)
def which(program):
"""The path to a client program, or None."""
return shutil.which(program)
def require(program, why):
"""The path to a client program, or a skip-the-host error saying why."""
path = which(program)
if path is None:
raise UnsupportedError(
"%s is not installed, so %s cannot be tested" % (program, why))
return path
def run(argv, timeout=10.0, stdin=None):
"""Run a client program and collect what it said.
A program that outlives its timeout is killed and reported as timed out
rather than raising, because "the server did not answer" is a result the
scenario may be asserting on.
"""
env = dict(os.environ)
# A localised client would report its errors in a language the parsers
# here do not read.
env["LC_ALL"] = "C"
env["LANG"] = "C"
try:
child = subprocess.Popen(
list(argv),
stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
except OSError as exc:
return Completed(argv, 127, b"", str(exc).encode())
try:
out, err = child.communicate(input=stdin, timeout=timeout)
return Completed(argv, child.returncode, out, err)
except subprocess.TimeoutExpired:
child.kill()
out, err = child.communicate()
return Completed(argv, 124, out, err, timed_out=True)
@@ -0,0 +1,216 @@
"""Result reporting: TAP 13 by default, plus human and JSON forms.
TAP is the default because `prove` consumes it directly, and because it is what
dhcptest emits, so a driver that already collects one suite's output collects
this one unchanged.
The failure diagnostic carries expected, received, the reply's fields and the
command or request that produced it, so a red test can be understood from the
log without re-running it -- which matters most in CI, where the network it ran
on no longer exists by the time anyone reads the result.
"""
import json
import sys
from .errors import EXIT_FAILED, EXIT_OK
class Record(object):
"""One assertion outcome, with everything needed to explain it."""
__slots__ = ("scenario", "step", "description", "ok", "skip_reason",
"expected", "actual", "detail", "reply", "sent", "attempt",
"attempts")
def __init__(self, scenario, step, description, ok, skip_reason=None,
expected=None, actual=None, detail="", reply=None, sent="",
attempt=0, attempts=0):
self.scenario = scenario
self.step = step
self.description = description
self.ok = ok
self.skip_reason = skip_reason
self.expected = expected
self.actual = actual
self.detail = detail
self.reply = reply
self.sent = sent
self.attempt = attempt
self.attempts = attempts
@property
def name(self):
return "%s/%s: %s" % (self.scenario, self.step, self.description)
class Reporter(object):
"""Base reporter: counts, and an exit code."""
def __init__(self, stream=None):
self.stream = stream or sys.stdout
self.records = []
self.passed = 0
self.failed = 0
self.skipped = 0
def add(self, record):
self.records.append(record)
if record.skip_reason is not None:
self.skipped += 1
elif record.ok:
self.passed += 1
else:
self.failed += 1
self.emit(record)
def emit(self, record):
raise NotImplementedError
def start(self):
pass
def finish(self):
pass
def exit_code(self):
return EXIT_FAILED if self.failed else EXIT_OK
def write(self, text):
self.stream.write(text)
self.stream.flush()
class TapReporter(Reporter):
"""TAP version 13, with a trailing plan and YAML failure diagnostics."""
def start(self):
self.write("TAP version 13\n")
def emit(self, record):
number = len(self.records)
status = "ok" if (record.ok or record.skip_reason is not None) else "not ok"
line = "%s %d - %s" % (status, number, record.name)
if record.skip_reason is not None:
line += " # SKIP %s" % (record.skip_reason,)
self.write(line + "\n")
if not record.ok and record.skip_reason is None:
self.write(_yaml_block(record))
def finish(self):
self.write("1..%d\n" % (len(self.records),))
self.write("# passed %d, failed %d, skipped %d\n"
% (self.passed, self.failed, self.skipped))
class PrettyReporter(Reporter):
"""Human-first output carrying the same information as the TAP form."""
def __init__(self, stream=None):
Reporter.__init__(self, stream)
self._scenario = None
def emit(self, record):
if record.scenario != self._scenario:
self._scenario = record.scenario
self.write("\n%s\n" % (record.scenario,))
if record.skip_reason is not None:
self.write(" SKIP %s/%s -- %s\n"
% (record.step, record.description, record.skip_reason))
return
mark = "PASS" if record.ok else "FAIL"
self.write(" %s %s: %s\n" % (mark, record.step, record.description))
if not record.ok:
for line in _diagnostic_lines(record):
self.write(" %s\n" % (line,))
def finish(self):
self.write("\nPROVTEST SUMMARY: total=%d passed=%d failed=%d skipped=%d\n"
% (len(self.records), self.passed, self.failed, self.skipped))
class JsonReporter(Reporter):
"""One JSON object per run, for callers that would rather not parse text."""
def emit(self, record):
pass
def finish(self):
payload = {
"total": len(self.records),
"passed": self.passed,
"failed": self.failed,
"skipped": self.skipped,
"records": [_as_dict(record) for record in self.records],
}
self.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
REPORTERS = {
"tap": TapReporter,
"pretty": PrettyReporter,
"json": JsonReporter,
}
def make(name, stream=None):
try:
return REPORTERS[name](stream)
except KeyError:
raise ValueError("unknown report format %r" % (name,))
# ---------------------------------------------------------------------------
# diagnostics
def _diagnostic_lines(record):
lines = []
if record.expected is not None:
lines.append("expected: %r" % (record.expected,))
lines.append("received: %r" % (record.actual,))
if record.detail:
lines.append("detail: %s" % (_oneline(record.detail),))
if record.reply is not None:
lines.append("reply: %s" % (_oneline(record.reply.summary()),))
else:
lines.append("reply: none received")
if record.sent:
lines.append("sent: %s" % (_oneline(record.sent),))
if record.attempts:
lines.append("attempt: %d of %d" % (record.attempt, record.attempts))
return lines
def _yaml_block(record):
out = [" ---\n"]
for line in _diagnostic_lines(record):
key, _, value = line.partition(":")
out.append(" %s: %s\n" % (key.strip(), value.strip()))
out.append(" ...\n")
return "".join(out)
def _oneline(text, limit=400):
"""Collapse a value to one line, because TAP's diagnostics are per line."""
flat = " ".join(str(text).split())
if len(flat) > limit:
flat = flat[:limit - 3] + "..."
return flat
def _as_dict(record):
return {
"scenario": record.scenario,
"step": record.step,
"assertion": record.description,
"ok": bool(record.ok),
"skip": record.skip_reason,
"expected": record.expected,
"actual": record.actual,
"detail": record.detail,
"reply": record.reply.summary() if record.reply is not None else None,
"sent": record.sent,
"attempt": record.attempt,
"attempts": record.attempts,
}
@@ -0,0 +1,354 @@
"""Executing scenarios: one step at a time, in the order they were written.
A step is dispatched to the client for its protocol, its reply is bound under
the step's name so later steps can refer to it, and its assertions are then
evaluated against that reply. Binding before asserting is what makes the
cross-stage cases work: a TFTP step fetches a grub config, an extract step
pulls the kernel path out of it, and an HTTP step fetches exactly that path --
none of which is possible if a step can only see values written in the file.
"""
import re
import sys
import time
from . import assertions as assertions_mod
from . import dnsc, httpc, machine, report, subst, tftpc, xcatc
from .errors import ConfigError, ProvTestError
from .model import Reply
#: Step types whose "ok" is decided by the step itself rather than a server.
LOCAL_TYPES = frozenset(["extract", "sleep", "noop"])
class RunOptions(object):
"""Command-line overrides, applied on top of what the .conf says."""
__slots__ = ("bind", "timeout", "retries", "verbose")
def __init__(self, bind=None, timeout=None, retries=None, verbose=0):
self.bind = bind
self.timeout = timeout
self.retries = retries
self.verbose = verbose
def run(scenarios, reporter, options):
"""Run every scenario and return the process exit code."""
reporter.start()
for scenario in scenarios:
_run_scenario(scenario, reporter, options)
reporter.finish()
return reporter.exit_code()
def _run_scenario(scenario, reporter, options):
context = subst.Context()
for step in scenario.steps:
try:
reply, attempt, attempts = _execute(step, context, options)
except ProvTestError as exc:
reporter.add(report.Record(
scenario.name, step.name, "step could not be run", ok=False,
detail=str(exc)))
return
context.bind(step.name, reply)
if options.verbose:
sys.stderr.write("# %s/%s: %s\n"
% (scenario.name, step.name, reply.summary()))
extras = {"attempt": attempt}
_check_expectation(scenario, step, reply, reporter, attempt, attempts)
for assertion in step.assertions:
try:
result = assertions_mod.evaluate(assertion, reply, context, extras)
except ConfigError as exc:
reporter.add(report.Record(
scenario.name, step.name, assertion.render(), ok=False,
detail=str(exc), reply=reply, sent=reply.sent))
continue
reporter.add(report.Record(
scenario.name, step.name, assertion.render(), ok=result.ok,
expected=result.expected, actual=result.actual,
detail=result.detail, reply=reply, sent=reply.sent,
attempt=attempt, attempts=attempts))
def _check_expectation(scenario, step, reply, reporter, attempt, attempts):
"""Report the transport outcome itself, unless the step waived it.
The default is `ok`, so a step whose server never answered fails even when
the author wrote no assertion about it. Silence is not a pass.
"""
expect = step.expect or ("any" if step.type in LOCAL_TYPES else "ok")
if expect == "any":
return
wanted = (expect == "ok")
description = "expect %s" % (expect,)
reporter.add(report.Record(
scenario.name, step.name, description, ok=(reply.ok == wanted),
expected=expect, actual="ok" if reply.ok else "failed",
detail=reply.error, reply=reply, sent=reply.sent,
attempt=attempt, attempts=attempts))
def _execute(step, context, options):
"""Run one step, retrying only a transport that was expected to work."""
retries = options.retries if options.retries is not None else step.retries
retries = max(1, int(retries))
expect = step.expect or ("any" if step.type in LOCAL_TYPES else "ok")
reply = None
for attempt in range(1, retries + 1):
reply = _dispatch(step, context, options)
reply.attempt = attempt
if expect != "ok" or reply.ok or attempt == retries:
return reply, attempt, retries
time.sleep(0.2)
return reply, retries, retries
def _dispatch(step, context, options):
handler = HANDLERS.get(step.type)
if handler is None:
raise ConfigError("no handler for step type %r" % (step.type,), step.source)
return handler(step, context, options)
# ---------------------------------------------------------------------------
# per-type handlers
def _dns(step, context, options):
param = _resolver(step, context)
return dnsc.query(
server=param("server"),
name=param("name"),
rrtype=param("rrtype") or "A",
port=_int(param("port"), 53),
bind=param("bind") or options.bind,
recursion=_bool(param("recursion"), True),
timeout=_timeout(step, options),
)
def _tftp(step, context, options):
param = _resolver(step, context)
return tftpc.fetch(
server=param("server"),
path=param("path"),
port=_int(param("port"), 69),
mode=param("mode") or "octet",
timeout=_timeout(step, options),
)
def _http(step, context, options):
param = _resolver(step, context)
url = param("url")
if not url:
url = httpc.url_for(param("server"), param("path"),
_int(param("port"), 80))
headers = [line.strip() for line in (param("header") or "").splitlines()
if line.strip()]
return httpc.fetch(
url=url,
method=param("method") or "GET",
bind=param("bind") or options.bind,
headers=headers,
timeout=_timeout(step, options),
insecure=_bool(param("insecure"), False),
)
def _xcatreq(step, context, options):
param = _resolver(step, context)
return xcatc.request(
server=param("server"),
command=param("command"),
port=_int(param("port"), 3001),
elements=_pairs(param("element")),
bind=param("bind") or options.bind,
timeout=_timeout(step, options),
cert=param("cert") or None,
key=param("key") or None,
callback_port=param("callback_port") or None,
callback_listen=param("callback_listen") or None,
callback_reply=param("callback_reply") or None,
callback_wait=_float(param("callback_wait"), 2.0),
raw=param("raw") or None,
source_port=_int(param("source_port"), 0),
)
def _monitor(step, context, options):
param = _resolver(step, context)
send = [line.strip() for line in (param("send") or "").splitlines()
if line.strip()]
return xcatc.monitor(
server=param("server"),
send=send,
port=_int(param("port"), 3002),
bind=param("bind") or options.bind,
timeout=_timeout(step, options),
source_port=_int(param("source_port"), 0),
)
def _flowrequest(step, context, options):
param = _resolver(step, context)
return xcatc.flowrequest(
server=param("server"),
port=_int(param("port"), 3001),
message=param("message") or "resourcerequest: xcatd",
bind=param("bind") or options.bind,
timeout=_timeout(step, options),
source_port=_int(param("source_port"), 0),
expected=_int(param("replies"), 1),
)
def _findme(step, context, options):
param = _resolver(step, context)
payload = param("payload")
if not payload:
payload = xcatc.discovery_packet()
return xcatc.findme(
server=param("server"),
payload=payload,
port=_int(param("port"), 3001),
encoding=(param("encoding") or "gzip").lower(),
bind=param("bind") or options.bind,
source_port=_int(param("source_port"), 301),
timeout=_timeout(step, options),
callback_listen=_int(param("callback_listen"), 3001),
callback_wait=_float(param("callback_wait"), 5.0),
)
def _extract(step, context, options):
"""Pull a value out of an earlier reply, so a later step can fetch it.
This is the join between stages. A grub2 config names a kernel; nothing in
the config file says what that name will be, and hard-coding it would make
the test assert xCAT's output against a copy of xCAT's output. Extracting
it and then fetching it asserts the only thing that matters: that what the
node was told to fetch can be fetched.
"""
param = _resolver(step, context)
source = param("from") or ""
pattern = step.param("pattern") or ""
try:
regex = re.compile(pattern, re.MULTILINE)
except re.error as exc:
raise ConfigError("bad regex %r: %s" % (pattern, exc), step.source)
match = regex.search(source)
groups = list(match.groups()) if match else []
index = _int(param("group"), 1 if groups else 0)
value = ""
if match:
if index > len(groups):
raise ConfigError(
"group=%d, but %r has %d capturing group(s)"
% (index, pattern, len(groups)), step.source)
value = match.group(index) or ""
fields = {
"value": value,
"groups": [text or "" for text in groups],
"matched": bool(match),
"count": len(groups),
}
return Reply(kind="extract", fields=fields, ok=bool(match),
error="" if match else "%r matched nothing" % (pattern,),
sent="match %r" % (pattern,))
def _sleep(step, context, options):
param = _resolver(step, context)
duration = _float(param("duration"), 1.0)
time.sleep(duration)
return Reply(kind="sleep", fields={}, ok=True,
sent="slept %.1fs" % (duration,))
def _noop(step, context, options):
return Reply(kind="noop", fields={}, ok=True, sent="")
HANDLERS = {
"dns": _dns,
"tftp": _tftp,
"http": _http,
"xcatreq": _xcatreq,
"monitor": _monitor,
"flowrequest": _flowrequest,
"findme": _findme,
"extract": _extract,
"sleep": _sleep,
"noop": _noop,
}
assert set(HANDLERS) == machine.known_step_types()
# ---------------------------------------------------------------------------
# parameter handling
def _resolver(step, context):
"""A `param(key)` that resolves `$step.field` against what has run."""
def param(key, default=""):
value = step.param(key, default)
if value in (None, ""):
return value
return subst.resolve(value, context)
return param
def _timeout(step, options):
return options.timeout if options.timeout is not None else step.timeout
def _int(value, default):
if value in (None, ""):
return default
try:
return int(str(value).strip(), 0)
except ValueError:
raise ConfigError("expected a number, got %r" % (value,))
def _float(value, default):
if value in (None, ""):
return default
try:
return float(str(value).strip())
except ValueError:
raise ConfigError("expected a number, got %r" % (value,))
def _bool(value, default):
if value in (None, ""):
return default
text = str(value).strip().lower()
if text in ("1", "yes", "true", "on"):
return True
if text in ("0", "no", "false", "off"):
return False
raise ConfigError("expected a yes/no value, got %r" % (value,))
def _pairs(block):
"""`name = value` lines under an `element` key, as an ordered list."""
pairs = []
for line in (block or "").splitlines():
text = line.strip()
if not text or text.startswith("#"):
continue
name, _, value = text.partition("=")
pairs.append((name.strip(), value.strip()))
return pairs
@@ -0,0 +1,89 @@
"""Runtime references to earlier replies: `$step.field`.
Variables are configparser's job. `%(name)s` is interpolated by
`BasicInterpolation` out of `[vars]` and `--set` before a value ever reaches
this module, and `BasicInterpolation` gives `$` no meaning of its own, so the
two schemes share a file with no escaping.
What configparser cannot do is `$grubcfg.text`, because that value is a reply
that has not arrived when the file is read. Cross-stage assertions are the
whole point of this suite -- TFTP fetches a config, HTTP fetches what that
config named -- so this form is resolved at step-execution time instead, when
the earlier replies exist.
"""
import re
from .errors import ConfigError
REF_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_-]*)\.([A-Za-z_][A-Za-z0-9_.-]*)")
#: configparser's variable syntax, matched only to report what a file needs.
VAR_RE = re.compile(r"%\(([^)]+)\)s")
class Context(object):
"""Every reply a `$reference` can resolve against, at one point in a run."""
def __init__(self):
self.bindings = {}
self.order = []
def bind(self, name, reply):
if name not in self.bindings:
self.order.append(name)
self.bindings[name] = reply
def get_binding(self, name):
if name not in self.bindings:
raise ConfigError(
"$%s is not available here; no step of that name has run yet"
% (name,))
return self.bindings[name]
def __repr__(self):
return "Context(bindings=%r)" % (self.order,)
def references(text):
"""Every `$step.field` in a value, as `[(step, field), ...]`."""
if text is None:
return []
return [(name, field.rstrip(".")) for name, field in REF_RE.findall(str(text))]
def variables(text):
"""Every `%(name)s` in a value."""
if text is None:
return set()
return set(VAR_RE.findall(str(text)))
def resolve(text, context):
"""Substitute every `$step.field` in `text`."""
if text is None:
return None
def replace(match):
name, field = match.group(1), match.group(2).rstrip(".")
reply = context.get_binding(name)
try:
return format_value(reply.field(field))
except KeyError as exc:
raise ConfigError(str(exc))
return REF_RE.sub(replace, str(text))
def format_value(value):
"""Render a resolved value the way a .conf author would have typed it."""
if isinstance(value, bool):
return "yes" if value else "no"
if isinstance(value, bytes):
return value.decode("utf-8", "replace")
if isinstance(value, (list, tuple)):
return ", ".join(format_value(item) for item in value)
if isinstance(value, dict):
return ", ".join("%s=%s" % (key, format_value(value[key]))
for key in sorted(value))
return str(value)
@@ -0,0 +1,105 @@
"""TFTP, fetched the way a loader fetches: the `tftp` client.
The names are the thing under test at this stage. A loader asks for
`boot/grub2/grub.cfg-0A63010B` and nothing else; if `nodeset` wrote the hex a
digit out, the transfer fails and the node simply times out at the loader with
no error anywhere on the management node. So the fetch is by exact name, and
the reply carries the size and a digest, which is what lets one scenario assert
that the same bytes come back over TFTP and over HTTP.
The `tftp` client has no way to choose a source address. It does not need one:
TFTP has no notion of who is asking, so nothing at this stage depends on which
end of the veth pair the request left by.
"""
import hashlib
import os
import re
import tempfile
from . import proc
from .model import Reply
ERROR_RE = re.compile(r"Error code (\d+):\s*(.*)")
#: The bytes above which a fetched file is summarised rather than carried.
#: A kernel is tens of megabytes and no assertion reads it as text; a config
#: file is a few hundred bytes and every assertion does.
TEXT_LIMIT = 1 << 20
def build(server, path, port=69, mode="octet", local=None):
"""The `tftp` command line for one fetch."""
tftp = proc.require("tftp", "TFTP")
return [tftp, str(server), str(int(port)), "-m", mode,
"-c", "get", str(path), str(local)]
def fetch(server, path, port=69, mode="octet", timeout=10.0, keep=None):
"""Fetch one file and return a `Reply` describing what arrived."""
handle, local = tempfile.mkstemp(prefix="provtest-tftp-")
os.close(handle)
try:
argv = build(server, path, port, mode, local)
done = proc.run(argv, timeout=float(timeout) + 5.0)
reply = _decode(done, local, server, path)
if keep and reply.fields.get("ok"):
_copy(local, keep)
return reply
finally:
try:
os.unlink(local)
except OSError:
pass
def _decode(done, local, server, path):
size = os.path.getsize(local) if os.path.exists(local) else 0
body = b""
if size and size <= TEXT_LIMIT:
with open(local, "rb") as handle:
body = handle.read()
digest = _digest(local) if size else ""
error = ""
match = ERROR_RE.search(done.text + done.errtext)
if match:
error = "%s (code %s)" % (match.group(2).strip(), match.group(1))
elif done.timed_out:
error = "the transfer did not finish within the timeout"
elif done.rc != 0:
error = (done.errtext.strip() or done.text.strip()
or "tftp exited %d" % (done.rc,))
# tftp-hpa creates the local file before it knows whether the server will
# answer, so a zero-length result with no error is still a failure: no
# file this suite fetches is legitimately empty.
ok = not error and size > 0
fields = {
"ok": ok,
"size": size,
"sha256": digest,
"text": body.decode("utf-8", "replace"),
"error": error,
"path": path,
"server": server,
}
reply = Reply(kind="tftp", fields=fields, ok=ok, error=error,
sent=done.command(), raw=body)
return reply
def _digest(local):
digest = hashlib.sha256()
with open(local, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def _copy(local, target):
with open(local, "rb") as source:
with open(target, "wb") as sink:
for chunk in iter(lambda: source.read(65536), b""):
sink.write(chunk)
@@ -0,0 +1,470 @@
"""A small xCAT client: the four ways a booting node talks to xcatd.
A node being provisioned makes exactly four kinds of request, and none of them
needs an xCAT command or an xCAT library to make:
* **TLS on 3001** -- `getdestiny`, `nextdestiny`, `getpostscript`,
`getcredentials`. The genesis scripts send a few lines of XML down
`openssl s_client` and read a few lines back; this does the same over a
socket, so a request can be sent from a chosen source address.
* **Plain TCP on 3002** -- the install monitor. A line protocol with no TLS
at all, framed by `ready` and `done`.
* **UDP on 3001** -- flow control (`resourcerequest: xcatd`) and `findme`.
* **A connection back to the node** -- xcatd answers a findme by connecting
out to the client's TCP 3001, and signs a certificate only after the
client's TCP 300 agrees. Both are listeners this module can stand up.
Two things about xcatd make all of this reachable from a script, and both are
why this suite can exist at all. The TLS listener accepts a client with no
certificate, and identity comes from the reverse lookup of the address the
connection arrived from. So the address a request leaves by *is* the identity,
which is why every call here takes a `bind`.
"""
import gzip
import re
import socket
import ssl
import threading
import time
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")
ELEMENT_RE = re.compile(r"<([A-Za-z_][\w.-]*)>([^<]*)</\1>")
#: What the install monitor prints before it will read anything.
GREETING = "ready"
#: The default callback port the genesis scripts declare.
CALLBACK_PORT = 300
# ---------------------------------------------------------------------------
# listeners on the client's side
class Listener(object):
"""A socket on the client end, for the connections xcatd makes outward.
Started before the request that provokes the callback and stopped after
it, so a scenario can assert both that a callback arrived and -- the more
interesting case -- that it did not.
"""
def __init__(self, address, port, reply=None, backlog=4):
self.address = address
self.port = int(port)
self.reply = reply
self.received = []
self.peers = []
self.error = ""
self._socket = None
self._thread = None
self._stop = threading.Event()
self._backlog = backlog
def start(self):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.address or "", self.port))
sock.listen(self._backlog)
sock.settimeout(0.2)
except OSError as exc:
self.error = "cannot listen on %s:%d: %s" % (
self.address or "*", self.port, exc)
return self
self._socket = sock
self._thread = threading.Thread(target=self._serve)
self._thread.daemon = True
self._thread.start()
return self
def _serve(self):
while not self._stop.is_set():
try:
conn, peer = self._socket.accept()
except socket.timeout:
continue
except OSError:
return
try:
conn.settimeout(2.0)
data = b""
while True:
try:
chunk = conn.recv(4096)
except (socket.timeout, OSError):
break
if not chunk:
break
data += chunk
if self.reply is not None:
break
if self.reply is not None:
try:
conn.sendall(self.reply.encode())
except OSError:
pass
self.received.append(data.decode("utf-8", "replace").strip())
self.peers.append(peer[0])
finally:
try:
conn.close()
except OSError:
pass
def stop(self):
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
if self._socket is not None:
try:
self._socket.close()
except OSError:
pass
return self.received
def wait(self, seconds):
"""Block until something arrives, or the time runs out."""
deadline = time.time() + float(seconds)
while time.time() < deadline and not self.received:
time.sleep(0.05)
return self.received
# ---------------------------------------------------------------------------
# TLS on 3001
def request(server, command, port=3001, elements=(), bind=None, timeout=10.0,
cert=None, key=None, callback_port=None, callback_listen=None,
callback_reply=None, callback_wait=2.0, raw=None,
source_port=None):
"""Send one `<xcatrequest>` over TLS and decode what came back."""
body = raw if raw is not None else _xml(command, elements, callback_port)
listener = None
if callback_listen:
listener = Listener(bind, int(callback_listen), callback_reply).start()
# Always present, listener or not: "no callback arrived" is an assertion a
# scenario makes, and a field that is missing rather than false cannot be
# asserted against.
fields = {"callback_seen": False, "callback_data": []}
error = ""
handshake = False
text = ""
try:
sock = _tcp(server, int(port), bind, timeout, source_port)
context = _tls_context(cert, key)
try:
stream = context.wrap_socket(sock, server_hostname=None)
except ssl.SSLError as exc:
sock.close()
raise OSError("TLS handshake failed: %s" % (exc,))
handshake = True
try:
stream.sendall(body.encode())
text = _drain(stream, timeout)
finally:
try:
stream.close()
except OSError:
pass
except OSError as exc:
error = str(exc)
if listener is not None:
listener.wait(callback_wait)
fields["callback_data"] = listener.stop()
fields["callback_seen"] = bool(fields["callback_data"])
if listener.error and not error:
error = listener.error
fields.update(decode_elements(text))
fields["text"] = text
fields["handshake"] = handshake
fields["ok"] = handshake and bool(text) and not fields.get("error")
return Reply(kind="xcatreq", fields=fields, ok=fields["ok"], error=error,
sent=body.strip().replace("\n", " "),
raw=text.encode("utf-8", "replace"))
def decode_elements(text):
"""Every `<tag>value</tag>` in a response, flattened.
Flattened deliberately: the genesis scripts read the response with `sed`,
one element at a time and with no regard for nesting, so a test that
parsed it as a tree could pass where a node fails.
"""
elements = {}
for name, value in ELEMENT_RE.findall(text):
elements.setdefault(name, value.strip())
fields = {"elements": elements, "serverdone": "<serverdone" in text}
for name in NAMED_ELEMENTS:
if name in elements:
fields[name] = elements[name]
return fields
def _xml(command, elements=(), callback_port=None):
lines = ["<xcatrequest>", "<command>%s</command>" % (command,)]
if callback_port:
lines.append("<callback_port>%s</callback_port>" % (callback_port,))
for name, value in elements:
lines.append("<%s>%s</%s>" % (name, value, name))
lines.append("</xcatrequest>")
return "\n".join(lines) + "\n"
def _tls_context(cert=None, key=None):
"""A client context that verifies nothing.
That is not a shortcut: it is what the genesis scripts do. `openssl
s_client` with no `-CAfile` accepts whatever certificate it is shown, and
a node has no CA to check against until xcatd has signed one for it.
"""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if cert:
context.load_cert_chain(cert, key or None)
return context
def _drain(stream, timeout):
"""Read until the server is done, or the timeout, whichever is first."""
stream.settimeout(timeout)
deadline = time.time() + float(timeout)
text = ""
while time.time() < deadline:
try:
chunk = stream.recv(8192)
except (socket.timeout, ssl.SSLError):
break
except OSError:
break
if not chunk:
break
text += chunk.decode("utf-8", "replace")
if "<serverdone" in text:
break
return text
# ---------------------------------------------------------------------------
# plain TCP on 3002
def monitor(server, send=(), port=3002, bind=None, timeout=10.0,
source_port=None):
"""Speak the install monitor's line protocol.
xcatd closes the connection without a greeting when the peer's address
does not reverse-resolve to a node, so an empty greeting is a result and
not an error.
"""
lines = []
greeting = ""
error = ""
closed = False
try:
sock = _tcp(server, int(port), bind, timeout, source_port)
sock.settimeout(timeout)
try:
greeting, rest = _read_line(sock, b"")
for line in send:
sock.sendall((line.rstrip("\n") + "\n").encode())
answer, rest = _read_line(sock, rest)
if answer == "":
closed = True
break
lines.append(answer)
if not closed:
# Everything the server says after the framing token. Several
# verbs answer `done` and then keep writing -- getpostscript
# sends the whole script and `#END OF SCRIPT` afterwards -- so
# a reader that stopped at the first line would report a
# complete exchange and an empty script.
trailing, closed = _drain_lines(sock, rest)
lines.extend(trailing)
finally:
sock.close()
except OSError as exc:
error = str(exc)
fields = {
"greeting": greeting,
"lines": lines,
"text": "\n".join([greeting] + lines).strip(),
"closed": closed,
"ok": greeting == GREETING,
"error": error,
}
return Reply(kind="monitor", fields=fields, ok=fields["ok"], error=error,
sent="; ".join(send))
def _read_line(sock, buffered):
"""One newline-terminated line, and whatever was read past it."""
data = buffered
while b"\n" not in data:
try:
chunk = sock.recv(4096)
except (socket.timeout, OSError):
break
if not chunk:
break
data += chunk
line, sep, rest = data.partition(b"\n")
if not sep and not line:
return "", b""
return line.decode("utf-8", "replace").strip(), rest
def _drain_lines(sock, rest):
"""Whatever the server writes before it closes, as `(lines, closed)`."""
data = rest
closed = False
try:
sock.settimeout(2.0)
while True:
chunk = sock.recv(4096)
if not chunk:
closed = True
break
data += chunk
except (socket.timeout, OSError):
pass
text = data.decode("utf-8", "replace")
return [line.strip() for line in text.splitlines() if line.strip()], closed
# ---------------------------------------------------------------------------
# UDP on 3001
def flowrequest(server, port=3001, message="resourcerequest: xcatd",
bind=None, timeout=10.0, source_port=None, expected=1):
"""Send a flow-control request and collect the datagrams that answer it.
Two answers are expected in turn and they are not the same: an immediate
acknowledgement, and a grant once a slot frees. A scenario that only wants
the first sets `replies = 1`.
"""
replies = []
error = ""
try:
sock = _udp(bind, source_port)
sock.settimeout(timeout)
sock.sendto(message.encode(), (server, int(port)))
deadline = time.time() + float(timeout)
while len(replies) < int(expected) and time.time() < deadline:
try:
data, _ = sock.recvfrom(4096)
except socket.timeout:
break
except OSError as exc:
error = str(exc)
break
replies.append(data.decode("utf-8", "replace").strip())
sock.close()
except OSError as exc:
error = str(exc)
fields = {
"replies": replies,
"count": len(replies),
"ok": bool(replies),
"error": error,
}
return Reply(kind="flowrequest", fields=fields, ok=fields["ok"],
error=error, sent=message)
def findme(server, payload, port=3001, encoding="gzip", bind=None,
source_port=301, timeout=10.0, callback_listen=3001,
callback_wait=5.0):
"""Send a discovery packet and record the callback it provokes.
The interesting assertions here are negative -- an unprivileged source
port, or an address on no managed network, must draw no callback at all --
so the listener is stood up before the datagram leaves and the absence of
a connection is the recorded result.
"""
listener = None
error = ""
if callback_listen:
listener = Listener(bind, int(callback_listen)).start()
if listener.error:
error = listener.error
body = payload.encode() if isinstance(payload, str) else payload
if encoding == "gzip":
body = gzip.compress(body)
try:
sock = _udp(bind, source_port)
sock.settimeout(timeout)
sock.sendto(body, (server, int(port)))
sock.close()
except OSError as exc:
error = error or str(exc)
callbacks = []
if listener is not None:
listener.wait(callback_wait)
callbacks = listener.stop()
fields = {
"callbacks": callbacks,
"count": len(callbacks),
"ok": bool(callbacks),
"error": error,
}
return Reply(kind="findme", fields=fields, ok=fields["ok"], error=error,
sent="findme %d bytes from port %s (%s)"
% (len(body), source_port, encoding), raw=body)
def discovery_packet(elements=()):
"""The XML a genesis image sends, without the signature it cannot forge.
`xcatd` gates a findme on three things -- the gzip or `<xcat` prefix, a
source port below 1000, and an address on a managed network -- and none of
them is the signature, so an unsigned packet reaches the same code path and
provokes the same callback. What a discovery *plugin* then does with an
unsigned packet is a different question, and no scenario here asserts it.
"""
lines = ["<xcatrequest>", "<command>findme</command>"]
for name, value in elements:
lines.append("<%s>%s</%s>" % (name, value, name))
lines.append("</xcatrequest>")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# sockets
def _tcp(server, port, bind, timeout, source_port=None):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
if bind or source_port:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind or "", int(source_port or 0)))
sock.connect((server, int(port)))
return sock
def _udp(bind, source_port=None):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if bind or source_port:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind or "", int(source_port or 0)))
return sock