Skip to content

Commit b8ca8e0

Browse files
Resolve references as they're read, within a scope
The marker style follows the TypeScript SDK, where a reference has to be a string that survives a model's validation and is resolved in a pass over desired state afterwards. Python doesn't need either: the function already has observed state when it builds desired state. reference.composing scopes the work to one composed resource. Within it, c.ref and c.external_name return the value itself, or None while it isn't available, and the scope records the dependencies when the block exits. References therefore work in fields of any type, and there's no marker to forget to resolve. c.update leaves out None fields, because in a scope None means "not available yet". resource.update would send it as an explicit null, asking the API server to clear the field. A resource that already exists keeps its current spec for any field a missing reference left out, with a warning, since the scope can't see which field a value was assigned to. Without CAPABILITY_DEPENDENCIES, a resource that doesn't exist yet and has an unresolved reference is left out, as with markers. Both styles share the stand-in, the source lookup and the edge recording, and a test checks that they produce the same desired state and dependencies. Only one of them should survive review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2d89153 commit b8ca8e0

2 files changed

Lines changed: 478 additions & 51 deletions

File tree

‎crossplane/function/reference.py‎

Lines changed: 278 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,31 @@
5151
what a model's own validation accepts in a string field. So references work
5252
only in string fields. Anywhere else the model rejects them, which is at
5353
least 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
5679
import json
5780
import types
5881
import 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+
365447
class _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

502537
def _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

Comments
 (0)