chromium/infra/config/lib/nodes.star

# Copyright 2022 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Utility library for working with lucicfg graph nodes."""

load("@stdlib//internal/graph.star", "graph")
load("@stdlib//internal/sequence.star", "sequence")
load("@stdlib//internal/luci/common.star", "builder_ref", "keys", "kinds")

_CHROMIUM_NS_KIND = "@chromium"

def _create_singleton_node_type(kind):
    """Create a singleton node type.

    Singleton nodes types only allow for a single node of the type to exist.
    This can be used for creating configuration nodes for generators since
    generators are unable to access lucicfg vars.

    Args:
        kind: (str) An identifier for the kind of the node. Must be unique
            within the chromium namespace.

    Returns:
        A node type that can be used for creating and getting a node of
        the given kind.

        The type has the following properties:
        * kind: The kind of node of the type.

        The node type has the following methods:
        * key(): Creates a key for the node.
        * add(**kwargs): Adds a node with a key created via `key()`.
            `graph.add_node` will be called with the key and `**kwargs`.
            Returns the key.
        * get(): Gets the node with the key given by
            `key(bucket_name, key_value)`.
    """

    def key():
        return graph.key(_CHROMIUM_NS_KIND, "", kind, "")

    def add(**kwargs):
        k = key()
        graph.add_node(k, **kwargs)
        return k

    def get():
        return graph.node(key())

    return struct(
        kind = kind,
        key = key,
        add = add,
        get = get,
    )

_ANONYMOUS_PREFIX = "<anonymous>"

def _create_unscoped_node_type(kind, allow_empty_id = False):
    """Create an unscoped node type.

    Unscoped node types only allow for one node to exist with a given key_value.
    Key values are arbitrary, it is up to the calling code to assign meaning to
    the key values and enforce validity.

    Args:
        kind: (str) An identifier for the kind of the node. Must be unique
            within the chromium namespace.
        allow_empty_id: (bool) Whether or not to allow the creation of nodes
            without providing an ID value. This can allow for creating resources
            that are defined within the definition of other resources without
            requiring assigning an ID upfront. Instead, a unique ID will be
            generated.

    Returns:
        A node type that can be used for creating and getting nodes of
        the given kind.

        The type has the following properties:
        * kind: The kind of nodes of the type.

        The node types has the following methods:
        * key(key_id_or_keyset): Gets a key of kind. key_id_or_keyset can either
            be the ID value for the key or it can be a keyset, in which case the
            key of kind will be extracted from the keyset.
        * add(key_id, **kwargs): Adds a node with a key created via
            `key(key_id)`. `graph.add_node` will be called with the key and
            `**kwargs`. Returns the key. If allow_empty_id is True, key_id will
            have the defult value of None and a None value for key_id will
            create a node with a key that is unique within the lucicfg run.
        * get(key_id): Gets the node with key given by `key(key_id)`.
    """

    def key(key_id_or_keyset):
        if graph.is_keyset(key_id_or_keyset):
            return key_id_or_keyset.get(kind)
        return graph.key(_CHROMIUM_NS_KIND, "", kind, key_id_or_keyset)

    if allow_empty_id:
        def add(key_id = None, **kwargs):
            if key_id == None:
                key_id = "{}:{}".format(_ANONYMOUS_PREFIX, sequence.next(kind))
            elif key_id.startswith(_ANONYMOUS_PREFIX):
                fail("cannot specify a key ID with prefix \"{}\"".format(_ANONYMOUS_PREFIX))
            k = key(key_id)
            graph.add_node(k, **kwargs)
            return k
    else:
        def add(key_id, **kwargs):
            k = key(key_id)
            graph.add_node(k, **kwargs)
            return k

    def get(key_id):
        return graph.node(key(key_id))

    return struct(
        kind = kind,
        key = key,
        add = add,
        get = get,
    )

def _create_scoped_node_type(kind, scope_kind):
    """Create a node type scoped to another kind.

    Scoped node types allow for a node to exist with a given key value per key
    value of the scope kind. Key values are arbitrary, it is up to the calling
    code to assign meaning to the key values and enforce validity.

    Args:
        kind: (str) An identifier for the kind of the node. Must be unique within
            the chromium namespace.
        scope_kind: (str) An identifier for the scope kind.

    Returns:
        A node type that can be used for creating and getting nodes of
        the given kind.

        The type has the following properties:
        * kind: The kind of nodes of the type.

        The node types has the following methods:
        * key(scope_key_value, key_value): Creates a key with the given scope
            value as the container and key_value as the ID.
        * add(scope_key_value, key_value, **kwargs): Adds a node with a key
            created via `key(scope_key_value, key_value)`. `graph.add_node` will
            be called with the key and `**kwargs`. Returns the key.
        * get(scope_key_value, key_value): Gets the node with the key given by
            `key(scope_key_value, key_value)`.
    """

    def key(scope_key_value, key_value):
        return graph.key(_CHROMIUM_NS_KIND, "", scope_kind, scope_key_value, kind, key_value)

    def add(scope_key_value, key_value, **kwargs):
        k = key(scope_key_value, key_value)
        graph.add_node(k, **kwargs)
        return k

    def get(scope_key_value, key_value):
        return graph.node(key(scope_key_value, key_value))

    return struct(
        kind = kind,
        key = key,
        add = add,
        get = get,
    )

def _create_node_type_with_builder_ref(kind):
    """Create a node type that allows reference via builder name.

    Node types created by this function result in the creation of 2 different
    kinds: the target kind (`kind`) and a ref kind. One node of the target kind
    can exist for each builder defined in the project. Additionally, 2 nodes of
    the ref kind will exist that have edges to the node of the target kind. The
    ref nodes provide the means of linking to the node via a ref. A ref is
    either a bucket-scoped builder name (e.g. "ci/linux-builder") or simple
    builder name (e.g. "linux-builder") if the simple builder name is
    unambiguous.

    Args:
        kind: (str) An identifier for the kind of the node. Must be unique
            within the chromium namespace.

    Returns:
        A node type that can be used for creating and getting nodes of
        the given kind.

        The type has the following properties:
        * kind: The kind of nodes of the type.
        * ref_kind: The kind of ref nodes of the type.

        The node types has the following methods:
        * key(bucket_name, builder_name): Creates a key for the target kind with
            the given bucket and builder.
        * ref_key(ref): Creates a key with the ref kind for the given ref.
        * add(bucket_name, builder_name, **kwargs): Adds a node with a key
            created via `key(bucket_name, builder_name)`. `graph.add_node` will
            be called with the key and `**kwargs`. Additionally, two ref nodes
            will be created as parents of the target node, one with the
            bucket-scoped builder name and one with the simple builder name.
            Returns the target key.
        * get(builder_name): Gets the node with the key given by
            `key(bucket_name, builder_name)`.
        * add_ref(key, ref): Add an edge from an arbitrary node identified by
            `key` to one of this node type's ref nodes identified by `ref`.
        * follow_ref(ref_node, context_node): Get the target node that is the
            child of `ref_node`, which is a node of this node type's ref kind.
            In the event that the ref node has multiple children (i.e. a simple
            builder name that is ambiguous) `context_node` will be used in the
            failure message to identify the source of the reference.
    """
    ref_kind = kind + " ref"

    def key(bucket_name, builder_name):
        return graph.key(_CHROMIUM_NS_KIND, "", kinds.BUCKET, bucket_name, kind, builder_name)

    def ref_key(ref):
        chunks = ref.split(":", 1)
        if len(chunks) != 1:
            fail("reference to builder in external project '{}' is not allowed here"
                .format(chunks[0]))
        chunks = ref.split("/", 1)
        if len(chunks) == 1:
            return graph.key("@chromium", "", ref_kind, ref)
        return graph.key("@chromium", "", kinds.BUCKET, chunks[0], ref_kind, chunks[1])

    def add(bucket_name, builder_name, **kwargs):
        k = key(bucket_name, builder_name)
        graph.add_node(k, **kwargs)
        for ref in (builder_name, "{}/{}".format(bucket_name, builder_name)):
            rk = ref_key(ref)
            graph.add_node(rk, idempotent = True)
            graph.add_edge(rk, k)
        return k

    def get(bucket_name, builder_name):
        return graph.node(key(bucket_name, builder_name))

    def add_ref(key, ref):
        rk = ref_key(ref)
        graph.add_edge(key, rk)

    def follow_ref(ref_node, context_node):
        if ref_node.key.kind != ref_kind:
            fail("{} is not {}".format(ref_node, ref_kind))

        variants = graph.children(ref_node.key, kind)
        if not variants:
            fail("{} is unexpectedly unconnected".format(ref_node))

        if len(variants) == 1:
            return variants[0]

        fail(
            "ambiguous reference '{}' in {}, possible variants:\n  {}".format(
                ref_node.key.id,
                context_node,
                "\n  ".join([str(v) for v in variants]),
            ),
            trace = context_node.trace,
        )

    return struct(
        kind = kind,
        ref_kind = ref_kind,
        add = add,
        get = get,
        add_ref = add_ref,
        follow_ref = follow_ref,
    )

# A node-type for access to lucicfg standard builder nodes. It doesn't provide
# full access because it doesn't allow for the creation of new nodes, but it can
# be used as a node type when creating link node types.
_BUILDER = struct(
    kind = kinds.BUILDER,
    ref_kind = kinds.BUILDER_REF,
    add_ref = lambda key, ref: graph.add_edge(key, keys.builder_ref(ref)),
    follow_ref = builder_ref.follow,
)

def _create_link_node_type(kind, parent_node_type, child_node_type):
    """Create a link node type.

    A link node type allows for creating nodes that express a relationship
    between two nodes. The nodes themselves only matter for providing a
    connection between the related nodes and as such, no means are provided for
    interacting directly with the nodes of this kind. The specific meaning of
    the relationship is determined by the caller. Nodes of this type would be
    necessary when there are potentially multiple relationships that could exist
    between given nodes.

    Args:
        kind: (str) An identifier for the kind of the node. Must be unique.
        parent_node_type: (node type) The node type of the nodes that will be
            the parent in the link relationship.
        child_node_type: (node type) The node type of the nodes that will be the
            child in the link relationship.

    Returns:
        A node type that can be used for linking related nodes and retrieving
        the parents or children of the relationship.

        The node types has the following methods:
        * link(parent_key, child_key): Create a link between the nodes
            identified by `parent_key` and `child_key`. The name is an arbitrary
            name that will appear in error messages if there are issues with the
            node.
        * children(parent_key): Get the nodes that are linked by nodes of this
            type as children of the node identified by `parent_key`.
        * parents(child_key): Get the nodes that are linked by nodes of this
            type as parents of the node identified by `child_key`.

        If the child_node_type was created by
        `create_node_type_with_builder_ref`, the returned node type changes in
        the following way:
        * link instead has the signature link(name, parent_key, child_ref). A
            link will be created between the node identified by `parent_key` and
            the ref node for the reference `child_ref`.
        * children will get the target node type by calling `follow_ref`, so
            links to ambiguous refs will cause failures.
    """

    # The keys are not actually used after creation of the link, but may appear
    # in error messages, so we create a unique key with the given name
    def key(name):
        return keys.unique(kind, name)

    parent_kind = parent_node_type.kind
    child_kind = child_node_type.kind

    if hasattr(child_node_type, "ref_kind"):
        def link(parent_key, child_ref):
            k = key(child_ref)
            graph.add_node(k)
            graph.add_edge(parent_key, k)
            child_node_type.add_ref(k, child_ref)

        def children(parent_key):
            if parent_key.kind != parent_kind:
                fail("kind of {} is not {}".format(parent_key, parent_kind))
            parent_node = graph.node(parent_key)
            children = []

            # The unique keys use the string representation of an incrementing
            # number, which makes the ordering of them seem somewhat chaotic, so
            # get the link nodes in definition order
            for link_node in graph.children(parent_key, kind, graph.DEFINITION_ORDER):
                for ref_node in graph.children(link_node.key, child_node_type.ref_kind):
                    children.append(child_node_type.follow_ref(ref_node, parent_node))
            return children

        def parents(child_key):
            if child_key.kind != child_kind:
                fail("kind of {} is not {}".format(child_key, child_kind))
            parents = []
            for ref_node in graph.parents(child_key, child_node_type.ref_kind):
                # The unique keys use the string representation of an
                # incrementing number, which makes the ordering of them seem
                # somewhat chaotic, so get the link nodes in definition order
                for link_node in graph.parents(ref_node.key, kind, graph.DEFINITION_ORDER):
                    parents.extend(graph.parents(link_node.key, parent_kind))
            return parents

    else:
        def link(parent_key, child_key):
            k = key(child_key.id)
            graph.add_node(k)
            graph.add_edge(parent_key, k)
            graph.add_edge(k, child_key)

        def children(parent_key):
            if parent_key.kind != parent_kind:
                fail("kind of {} is not {}".format(parent_key, parent_kind))
            children = []

            # The unique keys use the string representation of an incrementing
            # number, which makes the ordering of them seem somewhat chaotic, so
            # get the link nodes in definition order
            for link in graph.children(parent_key, kind, graph.DEFINITION_ORDER):
                children.extend(graph.children(link.key, child_kind))
            return children

        def parents(child_key):
            if child_key.kind != child_kind:
                fail("kind of {} is not {}".format(child_key, child_kind))
            parents = []

            # The unique keys use the string representation of an incrementing
            # number, which makes the ordering of them seem somewhat chaotic, so
            # get the link nodes in definition order
            for link in graph.parents(child_key, kind, graph.DEFINITION_ORDER):
                parents.extend(graph.parents(link.key, parent_kind))
            return parents

    return struct(
        link = link,
        children = children,
        parents = parents,
    )

nodes = struct(
    BUILDER = _BUILDER,
    create_singleton_node_type = _create_singleton_node_type,
    create_unscoped_node_type = _create_unscoped_node_type,
    create_scoped_node_type = _create_scoped_node_type,
    create_bucket_scoped_node_type = lambda kind: _create_scoped_node_type(kind, kinds.BUCKET),
    create_node_type_with_builder_ref = _create_node_type_with_builder_ref,
    create_link_node_type = _create_link_node_type,
)