numos/validate.py
back to source
"""Export-time validation. A machine must never discover these faults as PID 1."""
ON_FAILURE = ("halt", "degrade", "continue")
KINDS = ("oneshot", "longrun", "target")
RESTARTS = ("never", "on-failure", "always")
class ValidationError(Exception):
"""Raised when a state would be unsafe to boot."""
# numos.state renders one record per line with space-separated fields, and
# only the last field on a line (a unit's exec, a health check's probe) may
# contain spaces. A space anywhere else shifts every later field by one --
# and the result is still self-consistently hashed, so it passes render(),
# verify(), and both the Python and shell verifiers untouched. numinit then
# misreads it silently: for `U op-bad op-v1 longrun on-failure ...` every
# lookup returns rc=0 with kind=op-v1, restart=longrun, backoff_ms=on-failure
# and no halt anywhere. numos.state.parse() meanwhile dies with a raw
# ValueError. Neither is a fault a machine should discover as PID 1.
_DELIMITERS = ((" ", "a space"), ("\t", "a tab"),
("\n", "a newline"), ("\r", "a carriage return"))
def _token(record, field, value):
"""Check a field that is not last on its line."""
if value is None:
return
text = str(value)
if text == "":
raise ValidationError("%s: field %s is empty" % (record, field))
for char, label in _DELIMITERS:
if char in text:
raise ValidationError("%s: field %s contains %s: %r"
% (record, field, label, text))
def _token_list(record, field, values):
"""Check a comma-joined list field: elements carry the comma too."""
for value in values:
_token(record, field, value)
if "," in str(value):
raise ValidationError("%s: field %s element contains a comma: %r"
% (record, field, value))
if str(value) == "-":
raise ValidationError("%s: field %s element is %r, which is the "
"empty-list marker" % (record, field, value))
def _tail(record, field, value):
"""Check a field that IS last on its line: spaces are legitimate there,
but a newline would forge a second record."""
text = str(value)
if text == "":
raise ValidationError("%s: field %s is empty" % (record, field))
for char, label in (("\n", "a newline"), ("\r", "a carriage return")):
if char in text:
raise ValidationError("%s: field %s contains %s: %r"
% (record, field, label, text))
def check_field_separators(state):
"""Raise ValidationError if any field carries the format's delimiters."""
for arch in state.get("arch_targets", []):
record = "arch target %s" % arch["canonical"]
_token(record, "canonical", arch["canonical"])
_token_list(record, "aliases", arch["aliases"])
_token(record, "zig_triple", arch["zig_triple"])
_token(record, "busybox_variant", arch["busybox_variant"])
_token(record, "artifact_sha256", arch["artifact_sha256"])
for phase in state.get("boot_phases", []):
record = "phase %s" % phase["name"]
_token(record, "name", phase["name"])
_token(record, "on_failure", phase["on_failure"])
_token_list(record, "required_units", phase["required_units"])
for unit in state.get("units", []):
record = "unit %s" % unit["name"]
_token(record, "name", unit["name"])
_token(record, "kind", unit["kind"])
_token(record, "restart", unit["restart"])
_token_list(record, "arch_mask", unit["arch_mask"])
_token(record, "health_probe", unit["health_probe"])
_token_list(record, "requires", unit["requires"])
_token_list(record, "after", unit["after"])
_tail(record, "exec", unit["exec"])
for check in state.get("health", []):
record = "health predicate %s" % check["name"]
_token(record, "name", check["name"])
_token(record, "local_action", check["local_action"])
_token(record, "on_fire", check["on_fire"])
_tail(record, "probe", check["probe"])
def find_cycle(units):
"""Return a cycle as a list of unit names, or None if the DAG is acyclic."""
edges = {u["name"]: list(u["requires"]) + list(u["after"]) for u in units}
WHITE, GREY, BLACK = 0, 1, 2
color = dict((n, WHITE) for n in edges)
stack = []
def visit(node):
color[node] = GREY
stack.append(node)
for nxt in edges.get(node, []):
if nxt not in color:
continue
if color[nxt] == GREY:
return stack[stack.index(nxt):] + [nxt]
if color[nxt] == WHITE:
found = visit(nxt)
if found:
return found
color[node] = BLACK
stack.pop()
return None
for node in sorted(edges):
if color[node] == WHITE:
found = visit(node)
if found:
return found
return None
def validate(state):
"""Raise ValidationError unless the state is safe to boot."""
# First: a field-shifted record would make every check below inspect
# the wrong value, so the wire format has to hold before anything else.
check_field_separators(state)
names = [u["name"] for u in state["units"]]
dupes = sorted(set(n for n in names if names.count(n) > 1))
if dupes:
raise ValidationError("duplicate unit names: %s" % ", ".join(dupes))
known = set(names)
for unit in state["units"]:
if unit["kind"] not in KINDS:
raise ValidationError("unit %s: bad kind %r" % (unit["name"], unit["kind"]))
if unit["restart"] not in RESTARTS:
raise ValidationError("unit %s: bad restart %r" % (unit["name"], unit["restart"]))
for ref in list(unit["requires"]) + list(unit["after"]):
if ref not in known:
raise ValidationError("unit %s references unknown unit %s" % (unit["name"], ref))
cycle = find_cycle(state["units"])
if cycle:
raise ValidationError("dependency cycle: %s" % " -> ".join(cycle))
ordinals = [p["ordinal"] for p in state["boot_phases"]]
tied = sorted(set(o for o in ordinals if ordinals.count(o) > 1))
if tied:
raise ValidationError("duplicate phase ordinal: %s" % ", ".join(str(o) for o in tied))
for phase in state["boot_phases"]:
if phase["on_failure"] not in ON_FAILURE:
raise ValidationError("phase %s: bad on_failure %r"
% (phase["name"], phase["on_failure"]))
for ref in phase["required_units"]:
if ref not in known:
raise ValidationError("phase %s requires unknown unit %s"
% (phase["name"], ref))