5151what a model's own validation accepts in a string field. So references work
5252only in string fields. Anywhere else the model rejects them, which is at
5353least a loud failure rather than a silent one.
54+
55+ There is a second way to use the same named resources. composing scopes the
56+ work to one composed resource, so a reference can resolve as soon as it's
57+ read rather than being marked for later:
58+
59+ vpc = reference.named("vpc", VPC)
60+
61+ with reference.composing(req, rsp, "subnet") as c:
62+ c.update(
63+ Subnet(
64+ spec={
65+ "forProvider": {
66+ "region": "us-east-1",
67+ "vpcId": c.external_name(vpc),
68+ }
69+ }
70+ )
71+ )
72+
73+ c.external_name and c.ref return the value itself, or None while it isn't
74+ available, so they work in fields of any type and there is nothing to
75+ resolve afterwards. The dependencies are recorded when the block exits.
5476"""
5577
78+ import contextlib
5679import json
5780import types
5881import typing
@@ -362,6 +385,65 @@ def _value_at(obj: typing.Any, path: list) -> typing.Any:
362385 return obj
363386
364387
388+ def _lookup (req : fnv1 .RunFunctionRequest , s : _Source ) -> typing .Any :
389+ """Return the resource a reference points at, or _MISSING."""
390+ if s .kind == _COMPOSED :
391+ if s .name not in req .observed .resources :
392+ return _MISSING
393+ return resource .struct_to_dict (req .observed .resources [s .name ].resource )
394+
395+ items = request .get_required_resources (req , s .name )
396+ if s .resource_name is None :
397+ # With several matches and nothing to pick by there is no right
398+ # answer, so decline rather than guess.
399+ return items [0 ] if len (items ) == 1 else _MISSING
400+ for item in items :
401+ meta = item .get ("metadata" , {})
402+ if meta .get ("name" ) == s .resource_name and (
403+ s .namespace is None or meta .get ("namespace" ) == s .namespace
404+ ):
405+ return item
406+ return _MISSING
407+
408+
409+ def _read (req : fnv1 .RunFunctionRequest , s : _Source , path : list ) -> typing .Any :
410+ """Return the value a reference points at, or _MISSING if there isn't one."""
411+ src = _lookup (req , s )
412+ if src is _MISSING :
413+ return _MISSING
414+ if path == [_EXTERNAL_NAME ]:
415+ v = _value_at (src , ["metadata" , "annotations" , _EXTERNAL_NAME_ANNOTATION ])
416+ if v is _MISSING :
417+ v = _value_at (src , ["metadata" , "name" ])
418+ else :
419+ v = _value_at (src , path )
420+ return _MISSING if v is None else v
421+
422+
423+ def _record (
424+ rsp : fnv1 .RunFunctionResponse , name : str , sources : typing .Iterable [_Source ]
425+ ) -> None :
426+ """Add a dependency of name on each source, skipping any already declared."""
427+ declared = {
428+ (d .resource , d .WhichOneof ("depends_on" ), _target (d ))
429+ for d in rsp .dependencies .items
430+ }
431+ for s in sorted (sources , key = lambda s : tuple (p or "" for p in s )):
432+ if s .kind == _COMPOSED :
433+ key = (name , "composed_resource" , s .name )
434+ if s .name == name or key in declared :
435+ continue
436+ response .add_dependency (rsp , name , s .name )
437+ else :
438+ key = (name , "required_resource" , (s .name , s .resource_name , s .namespace ))
439+ if key in declared :
440+ continue
441+ response .add_required_resource_dependency (
442+ rsp , name , s .name , name = s .resource_name , namespace = s .namespace
443+ )
444+ declared .add (key )
445+
446+
365447class _Resolver :
366448 """Resolves the references in one desired composed resource."""
367449
@@ -380,41 +462,13 @@ def __init__(self, req: fnv1.RunFunctionRequest, name: str):
380462 req .observed .resources [name ].resource
381463 )
382464
383- def source (self , s : _Source ) -> typing .Any :
384- if s .kind == _COMPOSED :
385- if s .name not in self .req .observed .resources :
386- return _MISSING
387- return resource .struct_to_dict (self .req .observed .resources [s .name ].resource )
388-
389- items = request .get_required_resources (self .req , s .name )
390- if s .resource_name is None :
391- # With several matches and nothing to pick by there is no right
392- # answer, so decline rather than guess.
393- return items [0 ] if len (items ) == 1 else _MISSING
394- for item in items :
395- meta = item .get ("metadata" , {})
396- if meta .get ("name" ) == s .resource_name and (
397- s .namespace is None or meta .get ("namespace" ) == s .namespace
398- ):
399- return item
400- return _MISSING
401-
402465 def value (self , marker : tuple [_Source , list ], at : list ) -> typing .Any :
403466 s , path = marker
404467 self .sources .add (s )
405468
406- src = self .source (s )
407- if src is not _MISSING :
408- if path == [_EXTERNAL_NAME ]:
409- v = _value_at (
410- src , ["metadata" , "annotations" , _EXTERNAL_NAME_ANNOTATION ]
411- )
412- if v is _MISSING :
413- v = _value_at (src , ["metadata" , "name" ])
414- else :
415- v = _value_at (src , path )
416- if v is not _MISSING and v is not None :
417- return v
469+ v = _read (self .req , s , path )
470+ if v is not _MISSING :
471+ return v
418472
419473 kept = _value_at (self .observed , at )
420474 if kept is not _MISSING :
@@ -463,10 +517,6 @@ def resolve(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) -> None
463517 of desired state until it can.
464518 """
465519 ordered = request .has_capability (req , fnv1 .CAPABILITY_DEPENDENCIES )
466- declared = {
467- (d .resource , d .WhichOneof ("depends_on" ), _target (d ))
468- for d in rsp .dependencies .items
469- }
470520
471521 for name in list (rsp .desired .resources ):
472522 r = rsp .desired .resources [name ]
@@ -481,22 +531,7 @@ def resolve(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) -> None
481531 continue
482532
483533 r .resource .CopyFrom (resource .dict_to_struct (body ))
484-
485- for s in sorted (resolver .sources , key = lambda s : tuple (p or "" for p in s )):
486- if s .kind == _COMPOSED :
487- if s .name == name or (name , "composed_resource" , s .name ) in declared :
488- continue
489- response .add_dependency (rsp , name , s .name )
490- declared .add ((name , "composed_resource" , s .name ))
491- continue
492-
493- key = (name , "required_resource" , (s .name , s .resource_name , s .namespace ))
494- if key in declared :
495- continue
496- response .add_required_resource_dependency (
497- rsp , name , s .name , name = s .resource_name , namespace = s .namespace
498- )
499- declared .add (key )
534+ _record (rsp , name , resolver .sources )
500535
501536
502537def _target (d : fnv1 .Dependency ) -> typing .Any :
@@ -539,3 +574,195 @@ def walk(v: structpb.Value) -> bool:
539574 return False
540575
541576 return any (walk (v ) for v in s .fields .values ())
577+
578+
579+ class Scope :
580+ """Resolves references for one composed resource as they're read.
581+
582+ Get one from composing. Where ref and external_name at module level
583+ return marker strings for resolve to replace later, a Scope's return the
584+ value itself, because the scope already knows which resource is asking.
585+ That means references work in fields of any type, not only strings, and
586+ there's nothing left to resolve once the scope closes.
587+ """
588+
589+ def __init__ (
590+ self , req : fnv1 .RunFunctionRequest , rsp : fnv1 .RunFunctionResponse , name : str
591+ ):
592+ """Create a Scope. Use composing rather than calling this directly."""
593+ self .req = req
594+ self .rsp = rsp
595+ self .name = name
596+ self ._sources : set [_Source ] = set ()
597+ self ._unresolved : list [str ] = []
598+
599+ def ref (self , value : V ) -> V :
600+ """Read a field of a named resource, and depend on that resource.
601+
602+ Args:
603+ value: A field read through a stand-in from named or
604+ named_required, for example vpc.status.atProvider.id.
605+
606+ Returns:
607+ The field's observed value, or None if it isn't available yet.
608+ None leaves the field out when the model is written to desired
609+ state, and the dependency means Crossplane doesn't create this
610+ resource until the value exists.
611+
612+ Raises:
613+ TypeError: value isn't a field of a named resource.
614+ ValueError: value is the whole resource rather than a field of it.
615+ """
616+ marker = _decode (ref (value ))
617+ assert marker is not None # noqa: S101 # ref always returns a marker.
618+ return typing .cast (V , self ._get (* marker , value ._describe ()))
619+
620+ def external_name (self , named_resource : typing .Any ) -> str | None :
621+ """Read a named resource's external name, and depend on that resource.
622+
623+ Args:
624+ named_resource: A stand-in from named or named_required.
625+
626+ Returns:
627+ The external name, or None if the resource doesn't exist yet.
628+
629+ Raises:
630+ TypeError: named_resource isn't a stand-in for a resource.
631+ """
632+ marker = _decode (external_name (named_resource ))
633+ assert marker is not None # noqa: S101 # external_name always returns one.
634+ return self ._get (* marker , f"{ named_resource ._describe ()} external name" )
635+
636+ def update (self , source : dict | structpb .Struct | pydantic .BaseModel ) -> None :
637+ """Write this scope's composed resource to desired state.
638+
639+ Args:
640+ source: The resource, as resource.update accepts it.
641+
642+ Unlike resource.update, this leaves out fields whose value is None. In
643+ a scope, None is what ref returns for a value that isn't available
644+ yet, and sending it would ask the API server to clear the field. Use
645+ resource.update directly to send an explicit null.
646+ """
647+ r = self .rsp .desired .resources [self .name ]
648+ match source :
649+ case pydantic .BaseModel ():
650+ data = source .model_dump (
651+ exclude_unset = True , exclude_none = True , by_alias = True , warnings = False
652+ )
653+ # As resource.update: identify the resource even though
654+ # apiVersion and kind are rarely set explicitly.
655+ data ["apiVersion" ] = source .apiVersion
656+ data ["kind" ] = source .kind
657+ resource .update (r , data )
658+ case structpb .Struct ():
659+ resource .update (r , _without_none (resource .struct_to_dict (source )))
660+ case dict ():
661+ resource .update (r , _without_none (source ))
662+ case _:
663+ resource .update (r , source )
664+
665+ def _get (self , s : _Source , path : list , what : str ) -> typing .Any :
666+ self ._sources .add (s )
667+ v = _read (self .req , s , path )
668+ if v is _MISSING :
669+ self ._unresolved .append (what )
670+ return None
671+ return v
672+
673+ def _close (self ) -> None :
674+ _record (self .rsp , self .name , self ._sources )
675+
676+ if not self ._unresolved :
677+ return
678+
679+ exists = self .name in self .req .observed .resources
680+ ordered = request .has_capability (self .req , fnv1 .CAPABILITY_DEPENDENCIES )
681+
682+ if not exists :
683+ # Crossplane won't create the resource until the dependency is
684+ # ready. One that doesn't enforce dependencies would create it
685+ # without the field, so hold it back by leaving it out instead.
686+ if not ordered :
687+ self .rsp .desired .resources .pop (self .name , None )
688+ return
689+
690+ # The resource exists, but something it refers to doesn't. The fields
691+ # that refer to it came back None and were left out, and applying
692+ # that would unset them. Keep what the resource already has for any
693+ # field this function no longer sets, and say so.
694+ if self .name in self .rsp .desired .resources :
695+ r = self .rsp .desired .resources [self .name ]
696+ observed = resource .struct_to_dict (
697+ self .req .observed .resources [self .name ].resource
698+ )
699+ body = resource .struct_to_dict (r .resource )
700+ if "spec" in observed :
701+ body ["spec" ] = _overlay (observed ["spec" ], body .get ("spec" , {}))
702+ r .resource .CopyFrom (resource .dict_to_struct (body ))
703+
704+ response .warning (
705+ self .rsp ,
706+ f"{ self .name } : kept its current spec, because "
707+ f"{ ', ' .join (self ._unresolved )} isn't available" ,
708+ )
709+
710+
711+ def _without_none (v : typing .Any ) -> typing .Any :
712+ """Return v with every None value left out, recursing into dicts and lists."""
713+ if isinstance (v , dict ):
714+ return {k : _without_none (x ) for k , x in v .items () if x is not None }
715+ if isinstance (v , list ):
716+ return [_without_none (x ) for x in v if x is not None ]
717+ return v
718+
719+
720+ def _overlay (base : dict , top : dict ) -> dict :
721+ """Return base with top written over it, recursing into nested dicts."""
722+ out = dict (base )
723+ for k , v in top .items ():
724+ out [k ] = (
725+ _overlay (out [k ], v )
726+ if isinstance (v , dict ) and isinstance (out .get (k ), dict )
727+ else v
728+ )
729+ return out
730+
731+
732+ @contextlib .contextmanager
733+ def composing (
734+ req : fnv1 .RunFunctionRequest , rsp : fnv1 .RunFunctionResponse , name : str
735+ ) -> typing .Iterator [Scope ]:
736+ """Compose one resource, depending on whatever it reads from others.
737+
738+ Args:
739+ req: The RunFunctionRequest, whose observed and required resources
740+ references are read from.
741+ rsp: The RunFunctionResponse to update.
742+ name: The composed resource being built, a key into desired state.
743+
744+ Yields:
745+ A Scope whose ref and external_name return real values, and record
746+ that this resource depends on the resources they came from:
747+
748+ vpc = reference.named("vpc", VPC)
749+
750+ with reference.composing(req, rsp, "subnet") as c:
751+ c.update(Subnet(spec={"forProvider": {
752+ "region": "us-east-1",
753+ "vpcId": c.external_name(vpc),
754+ "mapPublicIpOnLaunch": c.ref(vpc.spec.forProvider.enableDnsSupport),
755+ }}))
756+
757+ The dependencies are recorded when the block exits, so an exception
758+ inside it records nothing.
759+
760+ A value that isn't available yet comes back as None, and the field is
761+ left out. If the resource doesn't exist yet that's what ordering is for:
762+ Crossplane waits for the dependency before creating it. If it does exist,
763+ it keeps its current spec for the fields that were left out, and the
764+ response carries a warning saying why.
765+ """
766+ scope = Scope (req , rsp , name )
767+ yield scope
768+ scope ._close ()
0 commit comments