Support composed resource dependencies - #241
Draft
stevendborrelli wants to merge 8 commits into
Draft
stevendborrelli wants to merge 8 commits into
stevendborrelli wants to merge 8 commits into
Conversation
Sync the vendored protos with Crossplane's and regenerate, picking up Dependencies, Dependency, RequiredResourceDependency, the DependencyLifecycle enum, the dependencies field on the request and the response, and CAPABILITY_DEPENDENCIES. Add helpers for the field. response.to carries the request's dependencies forward, like it does desired state and context, so a function that adds an edge keeps the ones it was handed. It copies them only when the request has them: an unset Dependencies means "no opinion, carry mine forward" and an empty one means "drop every constraint", so copying an unset field as an empty one would turn the former into the latter. add_dependency takes composed-to-composed edges, with create_before_destroy rather than the lifecycle enum; add_required_resource_dependency is separate because create_before_destroy is invalid for a required resource, and a separate function makes that unrepresentable. clear_dependencies returns an empty set for a function that wants no constraints at all, and request.get_dependencies reads what the pipeline accumulated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Providers resolve cross-resource references themselves, through fields like vpcIdSelector, so Crossplane never sees the relationship and can't order anything. Writing the reference as a field value instead makes it visible: the function says where the value comes from, and the SDK records the dependency. reference.named names a composed resource, and named_required a required one. Reading a field through it records a path, and ref turns that into a marker string; external_name references the external name, which is what a selector would have resolved to. reference.resolve then replaces each marker with the observed value and records the edge. A marker is a string because that's what a generated model's own validation accepts in a string field, so it survives model_validate and resource.update untouched. Given the model, the stand-in follows the model's fields: a typo fails where it's written, and a field whose JSON name is a Python keyword (from, class) is recorded under its JSON name. It has no value, so a truth test or str() raises rather than taking the wrong branch the way an always-truthy placeholder would. A reference whose source doesn't exist yet leaves the field out, and the edge means Crossplane doesn't create the resource until it does. A resource that already exists keeps the value it has, so an apply doesn't unset it. On a Crossplane without CAPABILITY_DEPENDENCIES the edge would be ignored and the resource created without the field, so there a resource with an unresolved reference is left out instead. The runtime fails a response that still contains a marker, so forgetting to call resolve can't send one to the cluster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
stevendborrelli
added a commit
to stevendborrelli/modelplane
that referenced
this pull request
Sep 29, 2026
Bumps function-sdk-python to b8ca8e0, which adds crossplane.function.reference: a dependency recorded from a field that refers to another resource, rather than declared with add_dependency. It's in review as crossplane/function-sdk-python#241, with two styles still on the table. Nothing here uses it yet. The add_dependency calls these functions make are unchanged, and every function's tests pass against the new SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Porting Modelplane to reference.composing turned up two gaps.
A scope that read a reference but didn't compose its resource, because
the value it needed wasn't there yet, still declared the dependency.
Crossplane ignores a dependency whose resource it doesn't know about, and
says so in an event on every reconcile. A scope now declares
dependencies only for a resource that's composed or already exists.
And a resource that exists, but that the block didn't compose because
the value was missing, was left out of desired state, which deletes it.
The fallback only covered a resource the block had written. It now keeps
an existing resource either way: its current spec under whatever the
block wrote, or its spec alone if the block wrote nothing, with a warning
saying why. That's the natural way to write it -
secrets = c.ref(cluster.status.secrets)
if secrets:
compose_backend(secrets)
- and it shouldn't delete the backend when the cluster's secrets
briefly disappear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A resource named with its model gives c.ref the field's type, and the signature promises it: c.ref(cluster.status.secrets) is typed list[Secret]. It returned the JSON observed state stores instead, a list of dicts, so code written against the type failed at runtime, and code written against the JSON failed type checking. Porting Modelplane hit both. c.ref now validates the value against the field's type with a pydantic TypeAdapter, so a nested object comes back as its model and a list of them as a list of models. A value that doesn't fit is an error naming the field, rather than a model built from the wrong data. Without a model there's no type to fit, and the value is the JSON as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The module offered two styles over the same named resources: marker strings resolved in a pass over desired state afterwards, following the TypeScript SDK, and composing, which resolves a reference as it's read. Porting Modelplane used composing throughout and never wanted markers, so they go, and with them the marker format, resolve, unresolved and the runtime guard that failed a response still carrying one. runtime.py is as it was before references. The tests that covered markers covered behavior composing has too - external names, list indexes, a dependency declared twice or on itself, required resources matched by name and namespace - so those moved over rather than going with them. Inside a scope, None means a value that isn't available yet, and sending it asks the API server to clear the field. Only c.update left it out; resource.update, and any helper built on it, sent it as a null. The scope now strips None from its resource when the block exits, however it was written, so existing helpers work inside a scope unchanged. The module docstring says what composing can't do without CAPABILITY_DEPENDENCIES: it holds back a resource whose value is missing, but nothing waits for a source to be ready, and nothing orders deletion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crossplane.function.reference was easy to confuse with managed resource
references - vpcIdRef, vpcIdSelector - which are what it's meant to
replace. crossplane.function.dependency says what it records, and reads
alongside response.add_dependency:
vpc = dependency.named("vpc", VPC)
with dependency.composing(req, rsp, "subnet") as c:
...
Two error messages still told the reader to wrap a field in
reference.ref(), which went with the marker style. They now point at
c.ref() inside dependency.composing(), as does named()'s docstring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a value a resource is built from goes missing and the scope keeps the resource's current spec, it said so in a warning result. That's right the first time, but it repeats on every reconcile for as long as the value is missing - through a whole cluster teardown, say - and each one is a Warning event on the XR. composing now reports on an XR condition, DependencyValuesAvailable: False with reason KeptCurrentSpec while any resource was kept, naming each and the value it's waiting for, and True otherwise. Every scope reports, so the condition is returned on every reconcile: Crossplane keeps a condition a function set earlier until it's set again, so one returned only while False would never clear. Scopes in one function share it, and one with nothing kept doesn't turn another's False back to True. The message is built from resource names and field paths only, so it holds still while the situation does, and Crossplane leaves an unchanged condition alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Support composed resource dependencies
Description of your changes
This adds SDK support for the
dependenciesfield that #7842 adds toRunFunctionRequestandRunFunctionResponse, in two layers.Declaring dependencies directly. The vendored protos are synced with the
prototype's, which adds
Dependency,RequiredResourceDependency, theDependencyLifecycleenum andCAPABILITY_DEPENDENCIES. Along with them:response.tocarries the request's dependencies forward, the way it carriesdesired state and context, so a function that adds a dependency keeps the ones
earlier functions declared. It copies them only when the request has them,
because an unset field means "no opinion" and an empty one means "drop every
constraint".
response.add_dependency,response.add_required_resource_dependencyandresponse.clear_dependencies, plusrequest.get_dependencies.Recording dependencies from field references. The new
crossplane.function.dependencymodule records a dependency whenever onecomposed resource's field is filled in from another resource. It's meant to
replace provider reference selectors such as
vpcIdSelector, which hide therelationship from Crossplane:
composingscopes the work to one composed resource, so a reference resolvesto its value as soon as it's read, in a field of any type, and the scope
records the dependency when the block exits.
c.refreturnsNone, and the field isleft out. When the block exits,
Noneis stripped from the resource howeverit was written -
c.update,resource.update, or a helper that calls it -because sending it would clear the field. The dependency means Crossplane
doesn't create the resource until the source is ready.
under whatever the block wrote. If the block didn't compose it at all because
the value was missing, it's kept rather than deleted. Either way the XR's
DependencyValuesAvailablecondition turnsFalsewith reasonKeptCurrentSpec, naming each kept resource and the value it's waiting for.It's returned
Trueotherwise, on every reconcile, because Crossplane keepsa condition a function set earlier until it's set again. A condition rather
than a warning result, because a result becomes an event on every reconcile
for as long as the value is missing.
about dependencies of a resource it doesn't know.
types them, so
c.ref(cluster.status.secrets)is a list ofSecretmodels,not of dicts.
where it's written, and a field whose JSON name is a Python keyword (
from,class) is read by its JSON name.str()on it,raises an error, so code can't branch on a value that doesn't exist yet.
Dependencies order resources only on a Crossplane that advertises
CAPABILITY_DEPENDENCIES. On any other, a resource whose value is missing isheld back by leaving it out of desired state, but nothing waits for a source
to be ready, and nothing orders deletion - that would need
Usages, which isfunction-sequencer's job.An earlier revision also offered marker strings resolved in a pass afterwards,
following the TypeScript SDK prototype. Porting Modelplane used the scoped
style throughout, so markers, and the runtime guard that caught unresolved
ones, were removed.
Open questions
specunderthe new one. It can also take ownership of fields the provider filled in
itself. It only happens when a source disappears.
named("vpc", VPC)is typed asVPC, so strict type checkers warn thatvpc.statusmay beNoneon every read. That would need generated stubs.composingshare theDependencyValuesAvailablecondition type, so the later one's report wins.Testing
hatch test: 58 tests pass, 32 of them intests/test_dependency.py.hatch fmt --checkpasses.datamodel-codegenmodels (the Upbound AWSVPCandSubnet).uses both layers:
add_dependencyfor ordering on existence, anddependency.composingincompose-inference-cluster, where each cloud'sbackend and ProviderConfig are built from the cluster's
status.secrets.All 14 of its functions' tests pass against this branch, and its end-to-end
suite passes on this branch's head, against the #7842 prototype's
v2.5.0-ordering.2release. The suite uses an existing cluster, so itdoesn't exercise the provisioned-cloud paths the port changed, which only
unit tests cover.
I have:
🤖 Generated with Claude Code