go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/starlark/stdlib/internal/luci/common.star (about)

     1  # Copyright 2018 The LUCI Authors.
     2  #
     3  # Licensed under the Apache License, Version 2.0 (the "License");
     4  # you may not use this file except in compliance with the License.
     5  # You may obtain a copy of the License at
     6  #
     7  #      http://www.apache.org/licenses/LICENSE-2.0
     8  #
     9  # Unless required by applicable law or agreed to in writing, software
    10  # distributed under the License is distributed on an "AS IS" BASIS,
    11  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  # See the License for the specific language governing permissions and
    13  # limitations under the License.
    14  
    15  """Definitions imported by all other luci/**/*.star modules.
    16  
    17  Should not import other LUCI modules to avoid dependency cycles.
    18  """
    19  
    20  load("@stdlib//internal/error.star", "error")
    21  load("@stdlib//internal/graph.star", "graph")
    22  load("@stdlib//internal/sequence.star", "sequence")
    23  load("@stdlib//internal/validate.star", "validate")
    24  
    25  # Node edges (parent -> child):
    26  #   luci.project: root
    27  #   luci.project -> [luci.realm]
    28  #   luci.project -> [luci.custom_role]
    29  #   luci.realm -> [luci.realm]
    30  #   luci.custom_role -> [luci.custom_role]
    31  #   luci.bindings_root -> [luci.binding]
    32  #   luci.realm -> [luci.binding]
    33  #   luci.custom_role -> [luci.binding]
    34  #   luci.project -> luci.logdog
    35  #   luci.project -> luci.milo
    36  #   luci.project -> luci.cq
    37  #   luci.project -> [luci.bucket]
    38  #   luci.project -> [luci.milo_view]
    39  #   luci.project -> [luci.cq_group]
    40  #   luci.project -> [luci.notifiable]
    41  #   luci.project -> [luci.notifier_template]
    42  #   luci.project -> [luci.buildbucket_notification_topic]
    43  #   luci.bucket -> [luci.builder]
    44  #   luci.bucket -> [luci.gitiles_poller]
    45  #   luci.bucket -> [luci.bucket_constraints]
    46  #   luci.bucket_constraints_root -> [luci.bucket_constraints]
    47  #   luci.bucket -> [luci.dynamic_builder_template]
    48  #   luci.builder_ref -> luci.builder
    49  #   luci.builder -> [luci.triggerer]
    50  #   luci.builder -> luci.executable
    51  #   luci.builder -> luci.task_backend
    52  #   luci.dynamic_builder_template -> luci.executable
    53  #   luci.gitiles_poller -> [luci.triggerer]
    54  #   luci.triggerer -> [luci.builder_ref]
    55  #   luci.milo_entries_root -> [luci.list_view_entry]
    56  #   luci.milo_entries_root -> [luci.console_view_entry]
    57  #   luci.milo_view -> luci.list_view
    58  #   luci.list_view -> [luci.list_view_entry]
    59  #   luci.list_view_entry -> list.builder_ref
    60  #   luci.milo_view -> luci.console_view
    61  #   luci.milo_view -> luci.external_console_view
    62  #   luci.console_view -> [luci.console_view_entry]
    63  #   luci.console_view_entry -> list.builder_ref
    64  #   luci.cq_verifiers_root -> [luci.cq_tryjob_verifier]
    65  #   luci.cq_group -> [luci.cq_tryjob_verifier]
    66  #   luci.cq_tryjob_verifier -> luci.builder_ref
    67  #   luci.cq_tryjob_verifier -> luci.cq_equivalent_builder
    68  #   luci.cq_equivalent_builder -> luci.builder_ref
    69  #   luci.notifiable -> [luci.builder_ref]
    70  #   luci.notifiable -> luci.notifier_template
    71  
    72  def _namespaced_key(*pairs):
    73      """Returns a key namespaced to the current project.
    74  
    75      Args:
    76        *pairs: key path inside the current project namespace.
    77  
    78      Returns:
    79        graph.key.
    80      """
    81      return graph.key(kinds.LUCI_NS, "", *pairs)
    82  
    83  def _project_scoped_key(kind, attr, ref):
    84      """Returns a key either by grabbing it from the keyset or constructing.
    85  
    86      Args:
    87        kind: kind of the key to return.
    88        attr: field name that supplies 'ref', for error messages.
    89        ref: either a keyset or a string "<name>".
    90  
    91      Returns:
    92        graph.key of the requested kind.
    93      """
    94      if graph.is_keyset(ref):
    95          return ref.get(kind)
    96      return graph.key(kinds.LUCI_NS, "", kind, validate.string(attr, ref))
    97  
    98  def _bucket_scoped_key(kind, attr, ref, allow_external = False):
    99      """Returns either a bucket-scoped or a project-scoped key of the given kind.
   100  
   101      Args:
   102        kind: kind of the key to return.
   103        attr: field name that supplies 'ref', for error messages.
   104        ref: either a keyset, a string "<bucket>/<name>" or just "<name>". If it
   105          is a string, it may optionally be prefixed with "<project>:" prefix to
   106          indicate that this is a reference to a node in another project.
   107        allow_external: True if it is OK to return a key from another project.
   108  
   109      Returns:
   110        graph.key of the requested kind.
   111      """
   112      if graph.is_keyset(ref):
   113          key = ref.get(kind)
   114          if key.root.kind != kinds.LUCI_NS:
   115              fail("unexpected key %s" % key)
   116          if key.root.id and not allow_external:
   117              fail("reference to %s in external project %r is not allowed here" % (kind, key.root.id))
   118          return key
   119  
   120      chunks = validate.string(attr, ref).split(":", 1)
   121      if len(chunks) == 2:
   122          proj, ref = chunks[0], chunks[1]
   123          if proj:
   124              if not allow_external:
   125                  fail("reference to %s in external project %r is not allowed here" % (kind, proj))
   126              if proj == "*":
   127                  fail("buildbot builders no longer allowed here")
   128      else:
   129          proj, ref = "", chunks[0]
   130  
   131      chunks = ref.split("/", 1)
   132      if len(chunks) == 1:
   133          return graph.key(kinds.LUCI_NS, proj, kind, chunks[0])
   134      return graph.key(kinds.LUCI_NS, proj, kinds.BUCKET, chunks[0], kind, chunks[1])
   135  
   136  def _builder_ref_key(ref, attr = "triggers", allow_external = False):
   137      """Returns luci.builder_ref key, auto-instantiating external nodes."""
   138      if allow_external:
   139          _maybe_add_external_builder(ref)
   140      return _bucket_scoped_key(kinds.BUILDER_REF, attr, ref, allow_external = allow_external)
   141  
   142  # Kinds is a enum-like struct with node kinds of various LUCI config nodes.
   143  kinds = struct(
   144      # This kind is used to namespace nodes from different projects:
   145      #   * ("@luci.ns", "", ...) - keys of nodes in the current project.
   146      #   * ("@luci.ns", "<name>", ...) - keys of nodes in another project.
   147      LUCI_NS = "@luci.ns",
   148  
   149      # Publicly declarable nodes.
   150      PROJECT = "luci.project",
   151      REALM = "luci.realm",
   152      CUSTOM_ROLE = "luci.custom_role",
   153      BINDING = "luci.binding",
   154      LOGDOG = "luci.logdog",
   155      BUCKET = "luci.bucket",
   156      EXECUTABLE = "luci.executable",
   157      TASK_BACKEND = "luci.task_backend",
   158      BUILDER = "luci.builder",
   159      GITILES_POLLER = "luci.gitiles_poller",
   160      MILO = "luci.milo",
   161      LIST_VIEW = "luci.list_view",
   162      LIST_VIEW_ENTRY = "luci.list_view_entry",
   163      CONSOLE_VIEW = "luci.console_view",
   164      CONSOLE_VIEW_ENTRY = "luci.console_view_entry",
   165      EXTERNAL_CONSOLE_VIEW = "luci.external_console_view",
   166      CQ = "luci.cq",
   167      CQ_GROUP = "luci.cq_group",
   168      CQ_TRYJOB_VERIFIER = "luci.cq_tryjob_verifier",
   169      NOTIFY = "luci.notify",
   170      NOTIFIABLE = "luci.notifiable",  # either luci.notifier or luci.tree_closer
   171      NOTIFIER_TEMPLATE = "luci.notifier_template",
   172      SHADOWED_BUCKET = "luci.shadowed_bucket",
   173      SHADOW_OF = "luci.shadow_of",
   174      BUCKET_CONSTRAINTS = "luci.bucket_constraints",
   175      BUILDBUCKET_NOTIFICATION_TOPIC = "luci.buildbucket_notification_topic",
   176      DYNAMIC_BUILDER_TEMPLATE = "luci.dynamic_builder_template",
   177  
   178      # Internal nodes (declared internally as dependency of other nodes).
   179      BUILDER_REF = "luci.builder_ref",
   180      TRIGGERER = "luci.triggerer",
   181      BINDINGS_ROOT = "luci.bindings_root",
   182      MILO_ENTRIES_ROOT = "luci.milo_entries_root",
   183      MILO_VIEW = "luci.milo_view",
   184      CQ_VERIFIERS_ROOT = "luci.cq_verifiers_root",
   185      CQ_EQUIVALENT_BUILDER = "luci.cq_equivalent_builder",
   186      BUCKET_CONSTRAINTS_ROOT = "luci.bucket_constraints_root",
   187  )
   188  
   189  # Keys is a collection of key constructors for various LUCI config nodes.
   190  keys = struct(
   191      # Publicly declarable nodes.
   192      project = lambda: _namespaced_key(kinds.PROJECT, "..."),
   193      realm = lambda ref: _project_scoped_key(kinds.REALM, "realm", ref),
   194      custom_role = lambda ref: _project_scoped_key(kinds.CUSTOM_ROLE, "role", ref),
   195      logdog = lambda: _namespaced_key(kinds.LOGDOG, "..."),
   196      bucket = lambda ref: _project_scoped_key(kinds.BUCKET, "bucket", ref),
   197      executable = lambda ref: _project_scoped_key(kinds.EXECUTABLE, "executable", ref),
   198      buildbucket_notification_topic = lambda ref: _project_scoped_key(kinds.BUILDBUCKET_NOTIFICATION_TOPIC, "buildbucket_notification_topic", ref),
   199      task_backend = lambda ref: _project_scoped_key(kinds.TASK_BACKEND, "task_backend", ref),
   200  
   201      # TODO(vadimsh): Make them accept keysets if necessary. These currently
   202      # require strings, not keysets. They are currently not directly used by
   203      # anything, only through 'builder_ref' and 'triggerer' nodes. As such, they
   204      # are never consumed via keysets.
   205      builder = lambda bucket, name: _namespaced_key(kinds.BUCKET, bucket, kinds.BUILDER, name),
   206      gitiles_poller = lambda bucket, name: _namespaced_key(kinds.BUCKET, bucket, kinds.GITILES_POLLER, name),
   207      milo = lambda: _namespaced_key(kinds.MILO, "..."),
   208      list_view = lambda ref: _project_scoped_key(kinds.LIST_VIEW, "list_view", ref),
   209      console_view = lambda ref: _project_scoped_key(kinds.CONSOLE_VIEW, "console_view", ref),
   210      external_console_view = lambda ref: _project_scoped_key(kinds.EXTERNAL_CONSOLE_VIEW, "external_console_view", ref),
   211      cq = lambda: _namespaced_key(kinds.CQ, "..."),
   212      cq_group = lambda ref: _project_scoped_key(kinds.CQ_GROUP, "cq_group", ref),
   213      notify = lambda: _namespaced_key(kinds.NOTIFY, "..."),
   214      notifiable = lambda ref: _project_scoped_key(kinds.NOTIFIABLE, "notifies", ref),
   215      notifier_template = lambda ref: _project_scoped_key(kinds.NOTIFIER_TEMPLATE, "template", ref),
   216      shadowed_bucket = lambda bucket_key: _namespaced_key(kinds.SHADOWED_BUCKET, bucket_key.id),
   217      shadow_of = lambda bucket_key: _namespaced_key(kinds.SHADOW_OF, bucket_key.id),
   218  
   219      # Internal nodes (declared internally as dependency of other nodes).
   220      builder_ref = _builder_ref_key,
   221      triggerer = lambda ref, attr = "triggered_by": _bucket_scoped_key(kinds.TRIGGERER, attr, ref),
   222      bindings_root = lambda: _namespaced_key(kinds.BINDINGS_ROOT, "..."),
   223      milo_entries_root = lambda: _namespaced_key(kinds.MILO_ENTRIES_ROOT, "..."),
   224      milo_view = lambda name: _namespaced_key(kinds.MILO_VIEW, name),
   225      cq_verifiers_root = lambda: _namespaced_key(kinds.CQ_VERIFIERS_ROOT, "..."),
   226      bucket_constraints_root = lambda: _namespaced_key(kinds.BUCKET_CONSTRAINTS_ROOT, "..."),
   227  
   228      # Generates a key of the given kind and name within some auto-generated
   229      # unique container key.
   230      #
   231      # Used with LIST_VIEW_ENTRY, CONSOLE_VIEW_ENTRY, CQ_TRYJOB_VERIFIER,
   232      # BUCKET_CONSTRAINTS and DYNAMIC_BUILDER_TEMPLATE helper nodes.
   233      # They don't really represent any "external" entities, and their names don't
   234      # really matter, other than for error messages.
   235      #
   236      # Note that IDs of keys whose kind stars with '_' (like '_UNIQUE' here),
   237      # are skipped when printing the key in error messages. Thus the meaningless
   238      # confusing auto-generated part of the key isn't showing up in error
   239      # messages.
   240      unique = lambda kind, name: graph.key("_UNIQUE", str(sequence.next(kind)), kind, name),
   241  )
   242  
   243  ################################################################################
   244  ## builder_ref implementation.
   245  
   246  def _builder_ref_add(target):
   247      """Adds two builder_ref nodes that have 'target' as a child.
   248  
   249      Builder refs are named pointers to builders. Each builder has two such refs:
   250      a bucket-scoped one (for when the builder is referenced using its full name
   251      "<bucket>/<name>"), and a global one (when the builder is referenced just as
   252      "<name>").
   253  
   254      Global refs can have more than one child (which means there are multiple
   255      builders with the same name in different buckets). Such refs are reported as
   256      ambiguous by builder_ref.follow(...).
   257  
   258      Args:
   259        target: a graph.key (of BUILDER kind) to setup refs for.
   260  
   261      Returns:
   262        graph.key of added bucket-scoped BUILDER_REF node ("<bucket>/<name>" one).
   263      """
   264      if target.kind != kinds.BUILDER:
   265          fail("got %s, expecting a builder key" % (target,))
   266  
   267      bucket = target.container.id  # name of the bucket, as string
   268      builder = target.id  # name of the builder, as string
   269  
   270      short = keys.builder_ref(builder)
   271      graph.add_node(short, idempotent = True)  # there may be such node already
   272      graph.add_edge(short, target)
   273  
   274      full = keys.builder_ref("%s/%s" % (bucket, builder))
   275      graph.add_node(full)
   276      graph.add_edge(full, target)
   277  
   278      return full
   279  
   280  def _builder_ref_follow(ref_node, context_node):
   281      """Given a BUILDER_REF node, returns a BUILDER graph.node the ref points to.
   282  
   283      Emits an error and returns None if the reference is ambiguous (i.e.
   284      'ref_node' has more than one child). Such reference can't be used to refer
   285      to a single builder.
   286  
   287      Note that the emitted error doesn't stop the generation phase, but marks it
   288      as failed. This allows to collect more errors before giving up.
   289  
   290      Args:
   291        ref_node: graph.node with the ref.
   292        context_node: graph.node where this ref is used, for error messages.
   293  
   294      Returns:
   295        graph.node of BUILDER kind.
   296      """
   297      if ref_node.key.kind != kinds.BUILDER_REF:
   298          fail("%s is not builder_ref" % ref_node)
   299  
   300      # builder_ref nodes are always linked to something, see _builder_ref_add.
   301      variants = graph.children(ref_node.key)
   302      if not variants:
   303          fail("%s is unexpectedly unconnected" % ref_node)
   304  
   305      # No ambiguity.
   306      if len(variants) == 1:
   307          return variants[0]
   308  
   309      error(
   310          "ambiguous reference %r in %s, possible variants:\n  %s",
   311          ref_node.key.id,
   312          context_node,
   313          "\n  ".join([str(v) for v in variants]),
   314          trace = context_node.trace,
   315      )
   316      return None
   317  
   318  # Additional API for dealing with builder_refs.
   319  builder_ref = struct(
   320      add = _builder_ref_add,
   321      follow = _builder_ref_follow,
   322  )
   323  
   324  ################################################################################
   325  ## Rudimentary hacky support for externally-defined builders.
   326  
   327  def _maybe_add_external_builder(ref):
   328      """If 'ref' points to an external builder, defines corresponding nodes.
   329  
   330      Kicks in if 'ref' is a string that has form "<project>:<bucket>/<name>".
   331      Declares necessary luci.builder(...) and luci.builder_ref(...) nodes
   332      namespaced to the corresponding external project.
   333      """
   334      if type(ref) != "string" or ":" not in ref:
   335          return
   336      proj, rest = ref.split(":", 1)
   337      if "/" not in rest:
   338          return
   339      bucket, name = rest.split("/", 1)
   340  
   341      # TODO(vadimsh): This is very tightly coupled to the implementation of
   342      # luci.builder(...) rule. It basically sets up same structure of nodes,
   343      # except it doesn't fully populate luci.builder props (because we don't know
   344      # them), and doesn't link the builder node to the project root (because we
   345      # don't want to generate cr-buildbucket.cfg entry for it).
   346  
   347      builder = graph.key(kinds.LUCI_NS, proj, kinds.BUCKET, bucket, kinds.BUILDER, name)
   348      graph.add_node(builder, idempotent = True, props = {
   349          "name": name,
   350          "bucket": bucket,
   351          "project": proj,
   352      })
   353  
   354      # This is roughly what _builder_ref_add does, except namespaced to 'proj'.
   355      refs = [
   356          graph.key(kinds.LUCI_NS, proj, kinds.BUILDER_REF, name),
   357          graph.key(kinds.LUCI_NS, proj, kinds.BUCKET, bucket, kinds.BUILDER_REF, name),
   358      ]
   359      for ref in refs:
   360          graph.add_node(ref, idempotent = True)
   361          graph.add_edge(ref, builder)
   362  
   363  ################################################################################
   364  ## triggerer implementation.
   365  
   366  def _triggerer_add(owner, idempotent = False):
   367      """Adds two 'triggerer' nodes that have 'owner' as a parent.
   368  
   369      Triggerer nodes are essentially nothing more than a way to associate a node
   370      of arbitrary kind ('owner') to a list of builder_ref's of builders it
   371      triggers (children of added 'triggerer' node).
   372  
   373      We need this indirection to make 'triggered_by' relation work: when a
   374      builder 'B' is triggered by something named 'T', we don't know whether 'T'
   375      is another builder or a gitiles poller ('T' may not even be defined yet).
   376      So instead all things that can trigger builders have an associated
   377      'triggerer' node and 'T' names such a node.
   378  
   379      To allow omitting bucket name when it is not important, each triggering
   380      entity actually defines two 'triggerer' nodes: a bucket-scoped one and a
   381      global one. During the graph traversal phase, children of both nodes are
   382      merged.
   383  
   384      If a globally named 'triggerer' node has more than one parent, it means
   385      there are multiple things in different buckets that have the same name.
   386      Using such references in 'triggered_by' relation is ambiguous. This
   387      situation is detected during the graph traversal phase, see
   388      triggerer.targets(...).
   389  
   390      Args:
   391        owner: a graph.key to setup triggerers for.
   392        idempotent: if True, allow the triggerer node to be redeclared.
   393  
   394      Returns:
   395        graph.key of added bucket-scoped TRIGGERER node ("<bucket>/<name>" one).
   396      """
   397      if not owner.container or owner.container.kind != kinds.BUCKET:
   398          fail("got %s, expecting a bucket-scoped key" % (owner,))
   399  
   400      bucket = owner.container.id  # name of the bucket, as string
   401      name = owner.id  # name of the builder or poller, as string
   402  
   403      # Short (not scoped to a bucket) keys are not unique, even for nodes that
   404      # are marked as idempotent=False. Make sure it is OK to re-add them by
   405      # marking them as idempotent. Dups are checked in triggerer.targets(...).
   406      short = keys.triggerer(name)
   407      graph.add_node(short, idempotent = True)
   408      graph.add_edge(owner, short)
   409  
   410      full = keys.triggerer("%s/%s" % (bucket, name))
   411      graph.add_node(full, idempotent = idempotent)
   412      graph.add_edge(owner, full)
   413  
   414      return full
   415  
   416  def _triggerer_targets(root):
   417      """Collects all BUILDER nodes triggered by the given node.
   418  
   419      Enumerates all TRIGGERER children of 'root', and collects all BUILDER nodes
   420      they refer to, following BUILDER_REF references.
   421  
   422      Various ambiguities are reported as errors (which marks the generation
   423      phase as failed). Corresponding nodes are skipped, to collect as many
   424      errors as possible before giving up.
   425  
   426      Args:
   427        root: a graph.node that represents the triggering entity: something that
   428              has a triggerer as a child.
   429  
   430      Returns:
   431        List of graph.node of BUILDER kind, sorted by key.
   432      """
   433      out = set()
   434  
   435      for t in graph.children(root.key, kinds.TRIGGERER):
   436          parents = graph.parents(t.key)
   437  
   438          for ref in graph.children(t.key, kinds.BUILDER_REF):
   439              # Resolve builder_ref to a concrete builder. This may return None
   440              # if the ref is ambiguous.
   441              builder = builder_ref.follow(ref, root)
   442              if not builder:
   443                  continue
   444  
   445              # If 't' has multiple parents, it can't be used in 'triggered_by'
   446              # relations, since it is ambiguous. Report this situation.
   447              if len(parents) != 1:
   448                  error(
   449                      "ambiguous reference %r in %s, possible variants:\n  %s",
   450                      t.key.id,
   451                      builder,
   452                      "\n  ".join([str(v) for v in parents]),
   453                      trace = builder.trace,
   454                  )
   455              else:
   456                  out = out.union([builder])
   457  
   458      return graph.sorted_nodes(out)
   459  
   460  triggerer = struct(
   461      add = _triggerer_add,
   462      targets = _triggerer_targets,
   463  )
   464  
   465  ################################################################################
   466  ## Helpers for defining milo views (lists or consoles).
   467  
   468  def _view_add_view(key, entry_kind, entry_ctor, entries, props):
   469      """Adds a *_view node, ensuring it doesn't clash with some other view.
   470  
   471      Args:
   472        key: a key of *_view node to add.
   473        entry_kind: a kind of corresponding *_view_entry node.
   474        entry_ctor: a corresponding *_view_entry rule.
   475        entries: a list of *_view_entry, builder refs or dict.
   476        props: properties for the added node.
   477  
   478      Returns:
   479        A keyset with added keys.
   480      """
   481      graph.add_node(key, props)
   482  
   483      # If there's some other view of different kind with the same name this will
   484      # cause an error.
   485      milo_view_key = keys.milo_view(key.id)
   486      graph.add_node(milo_view_key)
   487  
   488      # project -> milo_view -> *_view. Indirection through milo_view allows to
   489      # treat different kinds of views uniformly.
   490      graph.add_edge(keys.project(), milo_view_key)
   491      graph.add_edge(milo_view_key, key)
   492  
   493      # 'entry' is either a *_view_entry keyset, a builder_ref (perhaps given
   494      # as a string) or a dict with parameters to *_view_entry rules. In the
   495      # latter two cases, we add a *_view_entry for it automatically. This allows
   496      # to somewhat reduce verbosity of list definitions.
   497      for entry in validate.list("entries", entries):
   498          if type(entry) == "dict":
   499              entry = entry_ctor(**entry)
   500          elif type(entry) == "string" or (graph.is_keyset(entry) and entry.has(kinds.BUILDER_REF)):
   501              entry = entry_ctor(builder = entry)
   502          graph.add_edge(key, entry.get(entry_kind))
   503  
   504      return graph.keyset(key, milo_view_key)
   505  
   506  def _view_add_entry(kind, view, builder, props = None):
   507      """Adds *_view_entry node.
   508  
   509      Common implementation for list_view_node and console_view_node. Allows
   510      referring to builders defined in other projects.
   511  
   512      Args:
   513        kind: a kind of the node to add (e.g. LIST_VIEW_ENTRY).
   514        view: a key of the parent *_view to add the entry to, if known.
   515        builder: a reference to builder.
   516        props: properties for the added node.
   517  
   518      Returns:
   519        A keyset with the added key.
   520      """
   521      if builder == None:
   522          fail("'builder' is required")
   523      builder = keys.builder_ref(builder, attr = "builder", allow_external = True)
   524  
   525      # Note: name of this node is important only for error messages. It isn't
   526      # showing up in any generated files and by construction it can't
   527      # accidentally collide with some other name.
   528      key = keys.unique(kind, builder.id)
   529      graph.add_node(key, props)
   530      if view != None:
   531          graph.add_edge(parent = view, child = key)
   532      if builder != None:
   533          graph.add_edge(parent = key, child = builder)
   534  
   535      # This is used to detect *_view_entry nodes that aren't connected to any
   536      # *_view. Such orphan nodes aren't allowed.
   537      graph.add_node(keys.milo_entries_root(), idempotent = True)
   538      graph.add_edge(parent = keys.milo_entries_root(), child = key)
   539  
   540      return graph.keyset(key)
   541  
   542  view = struct(
   543      add_view = _view_add_view,
   544      add_entry = _view_add_entry,
   545  )
   546  
   547  ################################################################################
   548  ## Helpers for defining luci.notifiable nodes.
   549  
   550  def _notifiable_add(name, props, template, notified_by):
   551      """Adds a luci.notifiable node.
   552  
   553      This is a shared portion of luci.notifier and luci.tree_closer
   554      implementation. Nodes defined here are traversed by gen_notify_cfg in
   555      generators.star.
   556  
   557      Args:
   558        name: name of the notifier or the tree closer.
   559        props: a dict with node props.
   560        template: an optional reference to a luci.notifier_template to link to.
   561        notified_by: builders to link to.
   562  
   563      Returns:
   564        A keyset with the luci.notifiable key.
   565      """
   566      key = keys.notifiable(validate.string("name", name))
   567      graph.add_node(key, idempotent = True, props = props)
   568      graph.add_edge(keys.project(), key)
   569  
   570      for b in validate.list("notified_by", notified_by):
   571          graph.add_edge(
   572              parent = key,
   573              child = keys.builder_ref(b, attr = "notified_by"),
   574              title = "notified_by",
   575          )
   576  
   577      if template != None:
   578          graph.add_edge(key, keys.notifier_template(template))
   579  
   580      return graph.keyset(key)
   581  
   582  notifiable = struct(
   583      add = _notifiable_add,
   584  )