NumericalOS

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))