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 )