Skip to content

Support composed resource dependencies - #241

Draft
stevendborrelli wants to merge 8 commits into
crossplane:mainfrom
stevendborrelli:composed-resource-dependencies
Draft

stevendborrelli wants to merge 8 commits into
crossplane:mainfrom
stevendborrelli:composed-resource-dependencies

Conversation

@stevendborrelli

@stevendborrelli stevendborrelli commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Support composed resource dependencies

Work in progress. Please don't merge. This follows
crossplane/crossplane#7842,
the draft prototype that adds dependencies to the function protocol and has
Crossplane order composed resource creation and deletion from them. The
design is in
crossplane/crossplane#7841.
The proto messages here come from that prototype and aren't upstream yet, so
this can't land before it does.

Description of your changes

This adds SDK support for the dependencies field that #7842 adds to
RunFunctionRequest and RunFunctionResponse, in two layers.

Declaring dependencies directly. The vendored protos are synced with the
prototype's, which adds Dependency, RequiredResourceDependency, the
DependencyLifecycle enum and CAPABILITY_DEPENDENCIES. Along with them:

  • response.to carries the request's dependencies forward, the way it carries
    desired 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_dependency and
    response.clear_dependencies, plus request.get_dependencies.
if request.has_capability(req, fnv1.CAPABILITY_DEPENDENCIES):
    response.add_dependency(rsp, "subnet", "vpc")

Recording dependencies from field references. The new
crossplane.function.dependency module records a dependency whenever one
composed resource's field is filled in from another resource. It's meant to
replace provider reference selectors such as vpcIdSelector, which hide the
relationship from Crossplane:

vpc = dependency.named("vpc", VPC)  # typed stand-in; typos fail here

with dependency.composing(req, rsp, "subnet") as c:
    c.update(Subnet(spec={"forProvider": {
        "vpcId": c.external_name(vpc),                        # the real value, or None
        "mapPublicIpOnLaunch": c.ref(vpc.spec.forProvider.enableDnsHostnames),
    }}))
# subnet -> vpc is recorded when the block exits

composing scopes the work to one composed resource, so a reference resolves
to its value as soon as it's read, in a field of any type, and the scope
records the dependency when the block exits.

  • The source doesn't exist yet: c.ref returns None, and the field is
    left out. When the block exits, None is stripped from the resource however
    it 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.
  • The resource exists but its source is gone: it keeps its current spec
    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
    DependencyValuesAvailable condition turns False with reason
    KeptCurrentSpec, naming each kept resource and the value it's waiting for.
    It's returned True otherwise, on every reconcile, because Crossplane keeps
    a 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.
  • A block that composes nothing declares nothing, so Crossplane isn't told
    about dependencies of a resource it doesn't know.
  • Typed values: a resource named with its model returns values as the model
    types them, so c.ref(cluster.status.secrets) is a list of Secret models,
    not of dicts.
  • Model fields: the stand-in follows the model's fields, so a typo fails
    where it's written, and a field whose JSON name is a Python keyword (from,
    class) is read by its JSON name.
  • No accidental values: truth-testing a stand-in, or calling 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 is
held 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 is
function-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

  • The fallback for an existing resource keeps its whole observed spec under
    the 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 as VPC, so strict type checkers warn that
    vpc.status may be None on every read. That would need generated stubs.
  • Two functions in one pipeline that both use composing share the
    DependencyValuesAvailable condition type, so the later one's report wins.

Testing

  • hatch test: 58 tests pass, 32 of them in tests/test_dependency.py.
  • hatch fmt --check passes.
  • Checked against real datamodel-codegen models (the Upbound AWS VPC and
    Subnet).
  • Modelplane's ordering conversion
    uses both layers: add_dependency for ordering on existence, and
    dependency.composing in compose-inference-cluster, where each cloud's
    backend 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.2 release. The suite uses an existing cluster, so it
    doesn't exercise the provisioned-cloud paths the port changed, which only
    unit tests cover.

I have:

🤖 Generated with Claude Code

stevendborrelli and others added 3 commits September 19, 2026 21:46
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>
stevendborrelli and others added 5 commits September 29, 2026 13:25
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant