From 8e5a0b2427d47c4e8fd0bbb8e3888677f8e489d7 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 19 Sep 2026 21:46:38 +0100 Subject: [PATCH 1/9] Support the dependency messages in the proto 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 --- .../function/proto/v1/run_function.proto | 89 ++++++++ .../function/proto/v1/run_function_pb2.py | 140 ++++++------ .../function/proto/v1/run_function_pb2.pyi | 49 +++- .../function/proto/v1beta1/run_function.proto | 89 ++++++++ .../proto/v1beta1/run_function_pb2.py | 140 ++++++------ .../proto/v1beta1/run_function_pb2.pyi | 49 +++- crossplane/function/request.py | 24 ++ crossplane/function/response.py | 117 +++++++++- tests/test_request.py | 46 ++++ tests/test_response.py | 212 ++++++++++++++++++ 10 files changed, 813 insertions(+), 142 deletions(-) diff --git a/crossplane/function/proto/v1/run_function.proto b/crossplane/function/proto/v1/run_function.proto index e58a4b5..25bf397 100644 --- a/crossplane/function/proto/v1/run_function.proto +++ b/crossplane/function/proto/v1/run_function.proto @@ -97,6 +97,10 @@ message RunFunctionRequest { // sets the map key to an empty Schema message to indicate that it attempted // to satisfy the request. map required_schemas = 9; + + // Optional ordering constraints over composed resources, accumulated by the + // functions that ran before this one. + Dependencies dependencies = 10; } // Credentials that a function may use to communicate with an external system. @@ -118,6 +122,78 @@ message Resources { repeated Resource items = 1; } +// Dependencies is a set of ordering constraints over composed resources. +// +// This is a message wrapping a repeated field, rather than a bare repeated +// field, so that an unset value can be told apart from an empty one. A bare +// repeated field can't express that difference: proto3 has no presence for +// repeated fields, and an empty list and an unset field are both zero bytes on +// the wire. Crossplane relies on the difference, because an unset value means +// "this function has no opinion, carry my constraints forward" while an empty +// one means "this function wants no constraints at all." State wraps desired +// and observed resources for the same reason. +message Dependencies { + repeated Dependency items = 1; +} + +// A Dependency declares that one composed resource must be created after, and +// deleted before, another resource. It expresses ordering only. It doesn't move +// any data between resources. +message Dependency { + // Name of the composed resource that has the dependency. A key into the + // desired or observed State.resources map. + string resource = 1; + + // What the resource depends on. + oneof depends_on { + // Name of another composed resource. A key into the desired or observed + // State.resources map. + string composed_resource = 2; + + // A resource the pipeline required, rather than composed. Crossplane never + // deletes a resource it didn't compose, so these constrain only the order + // resources are created and updated. + RequiredResourceDependency required_resource = 4; + } + + // How this dependency constrains the order its endpoints are created and + // deleted. Defaults to symmetric ordering. + DependencyLifecycle lifecycle = 3; +} + +// A DependencyLifecycle is how a dependency constrains the order its endpoints +// are created and deleted. +enum DependencyLifecycle { + // Symmetric ordering: the resource is created only once what it depends on + // is ready, and what it depends on is deleted only once the resource is + // gone. This is the default, and the only valid value when depends_on is a + // required resource, because Crossplane never deletes a resource it didn't + // compose. + DEPENDENCY_LIFECYCLE_UNSPECIFIED = 0; + + // The resource may be created without waiting for what it depends on to be + // deleted. It must still exist and be ready before what it depends on is + // deleted. Use for a replacement that must exist before its predecessor is + // torn down. Only valid when depends_on is a composed resource. + DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY = 1; +} + +// A RequiredResourceDependency identifies a resource the pipeline required, +// for the purpose of ordering. +message RequiredResourceDependency { + // The requirement name. A key into a RunFunctionRequest's required_resources + // map, and into a RunFunctionResponse's requirements.resources map. + string requirement_name = 1; + + // Optional name of a single resource within the set the requirement matched. + // If unset, every resource the requirement matched must be ready. + optional string name = 2; + + // Namespace of name for a namespaced resource. Leave unset for a + // cluster-scoped resource. name and namespace together identify one match. + optional string namespace = 3; +} + // A RunFunctionResponse contains the result of a function run. message RunFunctionResponse { // Metadata pertaining to this response. @@ -156,6 +232,14 @@ message RunFunctionResponse { // // Only Operations use function output. XRs will discard any function output. optional google.protobuf.Struct output = 7; + + // Optional ordering constraints over composed resources. A function that has + // an opinion about ordering must return the full set it wants going forward, + // including edges it received and still wants. Leaving this field unset means + // "no opinion" - Crossplane carries forward whatever it sent in the request. + // + // Dependencies are only used for composition. They're ignored by Operations. + Dependencies dependencies = 8; } // RequestMeta contains metadata pertaining to a RunFunctionRequest. @@ -201,6 +285,11 @@ enum Capability { // OpenAPI schemas and Crossplane will return them in required_schemas. Added // in Crossplane v2.2. CAPABILITY_REQUIRED_SCHEMAS = 5; + + // Crossplane supports the dependencies field. Functions can declare ordering + // constraints over composed resources, and Crossplane will sequence the + // resources it creates, updates and deletes accordingly. + CAPABILITY_DEPENDENCIES = 6; } // Requirements that must be satisfied for a function to run successfully. diff --git a/crossplane/function/proto/v1/run_function_pb2.py b/crossplane/function/proto/v1/run_function_pb2.py index 5fe00b0..278c288 100644 --- a/crossplane/function/proto/v1/run_function_pb2.py +++ b/crossplane/function/proto/v1/run_function_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n/crossplane/function/proto/v1/run_function.proto\x12\x19\x61piextensions.fn.proto.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x8c\x08\n\x12RunFunctionRequest\x12\x34\n\x04meta\x18\x01 \x01(\x0b\x32&.apiextensions.fn.proto.v1.RequestMeta\x12\x32\n\x08observed\x18\x02 \x01(\x0b\x32 .apiextensions.fn.proto.v1.State\x12\x31\n\x07\x64\x65sired\x18\x03 \x01(\x0b\x32 .apiextensions.fn.proto.v1.State\x12+\n\x05input\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12-\n\x07\x63ontext\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x12^\n\x0f\x65xtra_resources\x18\x06 \x03(\x0b\x32\x41.apiextensions.fn.proto.v1.RunFunctionRequest.ExtraResourcesEntryB\x02\x18\x01\x12S\n\x0b\x63redentials\x18\x07 \x03(\x0b\x32>.apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntry\x12`\n\x12required_resources\x18\x08 \x03(\x0b\x32\x44.apiextensions.fn.proto.v1.RunFunctionRequest.RequiredResourcesEntry\x12\\\n\x10required_schemas\x18\t \x03(\x0b\x32\x42.apiextensions.fn.proto.v1.RunFunctionRequest.RequiredSchemasEntry\x1a[\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.apiextensions.fn.proto.v1.Resources:\x02\x38\x01\x1aZ\n\x10\x43redentialsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x35\n\x05value\x18\x02 \x01(\x0b\x32&.apiextensions.fn.proto.v1.Credentials:\x02\x38\x01\x1a^\n\x16RequiredResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.apiextensions.fn.proto.v1.Resources:\x02\x38\x01\x1aY\n\x14RequiredSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x30\n\x05value\x18\x02 \x01(\x0b\x32!.apiextensions.fn.proto.v1.Schema:\x02\x38\x01\x42\x08\n\x06_inputB\n\n\x08_context\"]\n\x0b\x43redentials\x12\x44\n\x0f\x63redential_data\x18\x01 \x01(\x0b\x32).apiextensions.fn.proto.v1.CredentialDataH\x00\x42\x08\n\x06source\"\x80\x01\n\x0e\x43redentialData\x12\x41\n\x04\x64\x61ta\x18\x01 \x03(\x0b\x32\x33.apiextensions.fn.proto.v1.CredentialData.DataEntry\x1a+\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"?\n\tResources\x12\x32\n\x05items\x18\x01 \x03(\x0b\x32#.apiextensions.fn.proto.v1.Resource\"\xa0\x03\n\x13RunFunctionResponse\x12\x35\n\x04meta\x18\x01 \x01(\x0b\x32\'.apiextensions.fn.proto.v1.ResponseMeta\x12\x31\n\x07\x64\x65sired\x18\x02 \x01(\x0b\x32 .apiextensions.fn.proto.v1.State\x12\x32\n\x07results\x18\x03 \x03(\x0b\x32!.apiextensions.fn.proto.v1.Result\x12-\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12=\n\x0crequirements\x18\x05 \x01(\x0b\x32\'.apiextensions.fn.proto.v1.Requirements\x12\x38\n\nconditions\x18\x06 \x03(\x0b\x32$.apiextensions.fn.proto.v1.Condition\x12,\n\x06output\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x42\n\n\x08_contextB\t\n\x07_output\"W\n\x0bRequestMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12;\n\x0c\x63\x61pabilities\x18\x02 \x03(\x0e\x32%.apiextensions.fn.proto.v1.Capability\"\x98\x04\n\x0cRequirements\x12X\n\x0f\x65xtra_resources\x18\x01 \x03(\x0b\x32;.apiextensions.fn.proto.v1.Requirements.ExtraResourcesEntryB\x02\x18\x01\x12I\n\tresources\x18\x02 \x03(\x0b\x32\x36.apiextensions.fn.proto.v1.Requirements.ResourcesEntry\x12\x45\n\x07schemas\x18\x03 \x03(\x0b\x32\x34.apiextensions.fn.proto.v1.Requirements.SchemasEntry\x1a\x62\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.apiextensions.fn.proto.v1.ResourceSelector:\x02\x38\x01\x1a]\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.apiextensions.fn.proto.v1.ResourceSelector:\x02\x38\x01\x1aY\n\x0cSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).apiextensions.fn.proto.v1.SchemaSelector:\x02\x38\x01\"3\n\x0eSchemaSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\"I\n\x06Schema\x12\x30\n\nopenapi_v3\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\r\n\x0b_openapi_v3\"\xba\x01\n\x10ResourceSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x14\n\nmatch_name\x18\x03 \x01(\tH\x00\x12>\n\x0cmatch_labels\x18\x04 \x01(\x0b\x32&.apiextensions.fn.proto.v1.MatchLabelsH\x00\x12\x16\n\tnamespace\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05matchB\x0c\n\n_namespace\"\x80\x01\n\x0bMatchLabels\x12\x42\n\x06labels\x18\x01 \x03(\x0b\x32\x32.apiextensions.fn.proto.v1.MatchLabels.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"P\n\x0cResponseMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12+\n\x03ttl\x18\x02 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00\x88\x01\x01\x42\x06\n\x04_ttl\"\xda\x01\n\x05State\x12\x36\n\tcomposite\x18\x01 \x01(\x0b\x32#.apiextensions.fn.proto.v1.Resource\x12\x42\n\tresources\x18\x02 \x03(\x0b\x32/.apiextensions.fn.proto.v1.State.ResourcesEntry\x1aU\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.apiextensions.fn.proto.v1.Resource:\x02\x38\x01\"\xf8\x01\n\x08Resource\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12V\n\x12\x63onnection_details\x18\x02 \x03(\x0b\x32:.apiextensions.fn.proto.v1.Resource.ConnectionDetailsEntry\x12/\n\x05ready\x18\x03 \x01(\x0e\x32 .apiextensions.fn.proto.v1.Ready\x1a\x38\n\x16\x43onnectionDetailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xb3\x01\n\x06Result\x12\x35\n\x08severity\x18\x01 \x01(\x0e\x32#.apiextensions.fn.proto.v1.Severity\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x06reason\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x36\n\x06target\x18\x04 \x01(\x0e\x32!.apiextensions.fn.proto.v1.TargetH\x01\x88\x01\x01\x42\t\n\x07_reasonB\t\n\x07_target\"\xc1\x01\n\tCondition\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x31\n\x06status\x18\x02 \x01(\x0e\x32!.apiextensions.fn.proto.v1.Status\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x14\n\x07message\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x36\n\x06target\x18\x05 \x01(\x0e\x32!.apiextensions.fn.proto.v1.TargetH\x01\x88\x01\x01\x42\n\n\x08_messageB\t\n\x07_target*\xc0\x01\n\nCapability\x12\x1a\n\x16\x43\x41PABILITY_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43\x41PABILITY_CAPABILITIES\x10\x01\x12!\n\x1d\x43\x41PABILITY_REQUIRED_RESOURCES\x10\x02\x12\x1a\n\x16\x43\x41PABILITY_CREDENTIALS\x10\x03\x12\x19\n\x15\x43\x41PABILITY_CONDITIONS\x10\x04\x12\x1f\n\x1b\x43\x41PABILITY_REQUIRED_SCHEMAS\x10\x05*?\n\x05Ready\x12\x15\n\x11READY_UNSPECIFIED\x10\x00\x12\x0e\n\nREADY_TRUE\x10\x01\x12\x0f\n\x0bREADY_FALSE\x10\x02*c\n\x08Severity\x12\x18\n\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eSEVERITY_FATAL\x10\x01\x12\x14\n\x10SEVERITY_WARNING\x10\x02\x12\x13\n\x0fSEVERITY_NORMAL\x10\x03*V\n\x06Target\x12\x16\n\x12TARGET_UNSPECIFIED\x10\x00\x12\x14\n\x10TARGET_COMPOSITE\x10\x01\x12\x1e\n\x1aTARGET_COMPOSITE_AND_CLAIM\x10\x02*\x7f\n\x06Status\x12 \n\x1cSTATUS_CONDITION_UNSPECIFIED\x10\x00\x12\x1c\n\x18STATUS_CONDITION_UNKNOWN\x10\x01\x12\x19\n\x15STATUS_CONDITION_TRUE\x10\x02\x12\x1a\n\x16STATUS_CONDITION_FALSE\x10\x03\x32\x87\x01\n\x15\x46unctionRunnerService\x12n\n\x0bRunFunction\x12-.apiextensions.fn.proto.v1.RunFunctionRequest\x1a..apiextensions.fn.proto.v1.RunFunctionResponse\"\x00\x42\x31Z/github.com/crossplane/crossplane/v2/proto/fn/v1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n/crossplane/function/proto/v1/run_function.proto\x12\x19\x61piextensions.fn.proto.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xcb\x08\n\x12RunFunctionRequest\x12\x34\n\x04meta\x18\x01 \x01(\x0b\x32&.apiextensions.fn.proto.v1.RequestMeta\x12\x32\n\x08observed\x18\x02 \x01(\x0b\x32 .apiextensions.fn.proto.v1.State\x12\x31\n\x07\x64\x65sired\x18\x03 \x01(\x0b\x32 .apiextensions.fn.proto.v1.State\x12+\n\x05input\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12-\n\x07\x63ontext\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x12^\n\x0f\x65xtra_resources\x18\x06 \x03(\x0b\x32\x41.apiextensions.fn.proto.v1.RunFunctionRequest.ExtraResourcesEntryB\x02\x18\x01\x12S\n\x0b\x63redentials\x18\x07 \x03(\x0b\x32>.apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntry\x12`\n\x12required_resources\x18\x08 \x03(\x0b\x32\x44.apiextensions.fn.proto.v1.RunFunctionRequest.RequiredResourcesEntry\x12\\\n\x10required_schemas\x18\t \x03(\x0b\x32\x42.apiextensions.fn.proto.v1.RunFunctionRequest.RequiredSchemasEntry\x12=\n\x0c\x64\x65pendencies\x18\n \x01(\x0b\x32\'.apiextensions.fn.proto.v1.Dependencies\x1a[\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.apiextensions.fn.proto.v1.Resources:\x02\x38\x01\x1aZ\n\x10\x43redentialsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x35\n\x05value\x18\x02 \x01(\x0b\x32&.apiextensions.fn.proto.v1.Credentials:\x02\x38\x01\x1a^\n\x16RequiredResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.apiextensions.fn.proto.v1.Resources:\x02\x38\x01\x1aY\n\x14RequiredSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x30\n\x05value\x18\x02 \x01(\x0b\x32!.apiextensions.fn.proto.v1.Schema:\x02\x38\x01\x42\x08\n\x06_inputB\n\n\x08_context\"]\n\x0b\x43redentials\x12\x44\n\x0f\x63redential_data\x18\x01 \x01(\x0b\x32).apiextensions.fn.proto.v1.CredentialDataH\x00\x42\x08\n\x06source\"\x80\x01\n\x0e\x43redentialData\x12\x41\n\x04\x64\x61ta\x18\x01 \x03(\x0b\x32\x33.apiextensions.fn.proto.v1.CredentialData.DataEntry\x1a+\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"?\n\tResources\x12\x32\n\x05items\x18\x01 \x03(\x0b\x32#.apiextensions.fn.proto.v1.Resource\"D\n\x0c\x44\x65pendencies\x12\x34\n\x05items\x18\x01 \x03(\x0b\x32%.apiextensions.fn.proto.v1.Dependency\"\xe0\x01\n\nDependency\x12\x10\n\x08resource\x18\x01 \x01(\t\x12\x1b\n\x11\x63omposed_resource\x18\x02 \x01(\tH\x00\x12R\n\x11required_resource\x18\x04 \x01(\x0b\x32\x35.apiextensions.fn.proto.v1.RequiredResourceDependencyH\x00\x12\x41\n\tlifecycle\x18\x03 \x01(\x0e\x32..apiextensions.fn.proto.v1.DependencyLifecycleB\x0c\n\ndepends_on\"x\n\x1aRequiredResourceDependency\x12\x18\n\x10requirement_name\x18\x01 \x01(\t\x12\x11\n\x04name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x16\n\tnamespace\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\x0c\n\n_namespace\"\xdf\x03\n\x13RunFunctionResponse\x12\x35\n\x04meta\x18\x01 \x01(\x0b\x32\'.apiextensions.fn.proto.v1.ResponseMeta\x12\x31\n\x07\x64\x65sired\x18\x02 \x01(\x0b\x32 .apiextensions.fn.proto.v1.State\x12\x32\n\x07results\x18\x03 \x03(\x0b\x32!.apiextensions.fn.proto.v1.Result\x12-\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12=\n\x0crequirements\x18\x05 \x01(\x0b\x32\'.apiextensions.fn.proto.v1.Requirements\x12\x38\n\nconditions\x18\x06 \x03(\x0b\x32$.apiextensions.fn.proto.v1.Condition\x12,\n\x06output\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x12=\n\x0c\x64\x65pendencies\x18\x08 \x01(\x0b\x32\'.apiextensions.fn.proto.v1.DependenciesB\n\n\x08_contextB\t\n\x07_output\"W\n\x0bRequestMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12;\n\x0c\x63\x61pabilities\x18\x02 \x03(\x0e\x32%.apiextensions.fn.proto.v1.Capability\"\x98\x04\n\x0cRequirements\x12X\n\x0f\x65xtra_resources\x18\x01 \x03(\x0b\x32;.apiextensions.fn.proto.v1.Requirements.ExtraResourcesEntryB\x02\x18\x01\x12I\n\tresources\x18\x02 \x03(\x0b\x32\x36.apiextensions.fn.proto.v1.Requirements.ResourcesEntry\x12\x45\n\x07schemas\x18\x03 \x03(\x0b\x32\x34.apiextensions.fn.proto.v1.Requirements.SchemasEntry\x1a\x62\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.apiextensions.fn.proto.v1.ResourceSelector:\x02\x38\x01\x1a]\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.apiextensions.fn.proto.v1.ResourceSelector:\x02\x38\x01\x1aY\n\x0cSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).apiextensions.fn.proto.v1.SchemaSelector:\x02\x38\x01\"3\n\x0eSchemaSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\"I\n\x06Schema\x12\x30\n\nopenapi_v3\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\r\n\x0b_openapi_v3\"\xba\x01\n\x10ResourceSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x14\n\nmatch_name\x18\x03 \x01(\tH\x00\x12>\n\x0cmatch_labels\x18\x04 \x01(\x0b\x32&.apiextensions.fn.proto.v1.MatchLabelsH\x00\x12\x16\n\tnamespace\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05matchB\x0c\n\n_namespace\"\x80\x01\n\x0bMatchLabels\x12\x42\n\x06labels\x18\x01 \x03(\x0b\x32\x32.apiextensions.fn.proto.v1.MatchLabels.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"P\n\x0cResponseMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12+\n\x03ttl\x18\x02 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00\x88\x01\x01\x42\x06\n\x04_ttl\"\xda\x01\n\x05State\x12\x36\n\tcomposite\x18\x01 \x01(\x0b\x32#.apiextensions.fn.proto.v1.Resource\x12\x42\n\tresources\x18\x02 \x03(\x0b\x32/.apiextensions.fn.proto.v1.State.ResourcesEntry\x1aU\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.apiextensions.fn.proto.v1.Resource:\x02\x38\x01\"\xf8\x01\n\x08Resource\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12V\n\x12\x63onnection_details\x18\x02 \x03(\x0b\x32:.apiextensions.fn.proto.v1.Resource.ConnectionDetailsEntry\x12/\n\x05ready\x18\x03 \x01(\x0e\x32 .apiextensions.fn.proto.v1.Ready\x1a\x38\n\x16\x43onnectionDetailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xb3\x01\n\x06Result\x12\x35\n\x08severity\x18\x01 \x01(\x0e\x32#.apiextensions.fn.proto.v1.Severity\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x06reason\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x36\n\x06target\x18\x04 \x01(\x0e\x32!.apiextensions.fn.proto.v1.TargetH\x01\x88\x01\x01\x42\t\n\x07_reasonB\t\n\x07_target\"\xc1\x01\n\tCondition\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x31\n\x06status\x18\x02 \x01(\x0e\x32!.apiextensions.fn.proto.v1.Status\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x14\n\x07message\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x36\n\x06target\x18\x05 \x01(\x0e\x32!.apiextensions.fn.proto.v1.TargetH\x01\x88\x01\x01\x42\n\n\x08_messageB\t\n\x07_target*k\n\x13\x44\x65pendencyLifecycle\x12$\n DEPENDENCY_LIFECYCLE_UNSPECIFIED\x10\x00\x12.\n*DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY\x10\x01*\xdd\x01\n\nCapability\x12\x1a\n\x16\x43\x41PABILITY_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43\x41PABILITY_CAPABILITIES\x10\x01\x12!\n\x1d\x43\x41PABILITY_REQUIRED_RESOURCES\x10\x02\x12\x1a\n\x16\x43\x41PABILITY_CREDENTIALS\x10\x03\x12\x19\n\x15\x43\x41PABILITY_CONDITIONS\x10\x04\x12\x1f\n\x1b\x43\x41PABILITY_REQUIRED_SCHEMAS\x10\x05\x12\x1b\n\x17\x43\x41PABILITY_DEPENDENCIES\x10\x06*?\n\x05Ready\x12\x15\n\x11READY_UNSPECIFIED\x10\x00\x12\x0e\n\nREADY_TRUE\x10\x01\x12\x0f\n\x0bREADY_FALSE\x10\x02*c\n\x08Severity\x12\x18\n\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eSEVERITY_FATAL\x10\x01\x12\x14\n\x10SEVERITY_WARNING\x10\x02\x12\x13\n\x0fSEVERITY_NORMAL\x10\x03*V\n\x06Target\x12\x16\n\x12TARGET_UNSPECIFIED\x10\x00\x12\x14\n\x10TARGET_COMPOSITE\x10\x01\x12\x1e\n\x1aTARGET_COMPOSITE_AND_CLAIM\x10\x02*\x7f\n\x06Status\x12 \n\x1cSTATUS_CONDITION_UNSPECIFIED\x10\x00\x12\x1c\n\x18STATUS_CONDITION_UNKNOWN\x10\x01\x12\x19\n\x15STATUS_CONDITION_TRUE\x10\x02\x12\x1a\n\x16STATUS_CONDITION_FALSE\x10\x03\x32\x87\x01\n\x15\x46unctionRunnerService\x12n\n\x0bRunFunction\x12-.apiextensions.fn.proto.v1.RunFunctionRequest\x1a..apiextensions.fn.proto.v1.RunFunctionResponse\"\x00\x42\x31Z/github.com/crossplane/crossplane/v2/proto/fn/v1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -60,70 +60,78 @@ _globals['_STATE_RESOURCESENTRY']._serialized_options = b'8\001' _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._loaded_options = None _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_options = b'8\001' - _globals['_CAPABILITY']._serialized_start=3898 - _globals['_CAPABILITY']._serialized_end=4090 - _globals['_READY']._serialized_start=4092 - _globals['_READY']._serialized_end=4155 - _globals['_SEVERITY']._serialized_start=4157 - _globals['_SEVERITY']._serialized_end=4256 - _globals['_TARGET']._serialized_start=4258 - _globals['_TARGET']._serialized_end=4344 - _globals['_STATUS']._serialized_start=4346 - _globals['_STATUS']._serialized_end=4473 + _globals['_DEPENDENCYLIFECYCLE']._serialized_start=4442 + _globals['_DEPENDENCYLIFECYCLE']._serialized_end=4549 + _globals['_CAPABILITY']._serialized_start=4552 + _globals['_CAPABILITY']._serialized_end=4773 + _globals['_READY']._serialized_start=4775 + _globals['_READY']._serialized_end=4838 + _globals['_SEVERITY']._serialized_start=4840 + _globals['_SEVERITY']._serialized_end=4939 + _globals['_TARGET']._serialized_start=4941 + _globals['_TARGET']._serialized_end=5027 + _globals['_STATUS']._serialized_start=5029 + _globals['_STATUS']._serialized_end=5156 _globals['_RUNFUNCTIONREQUEST']._serialized_start=141 - _globals['_RUNFUNCTIONREQUEST']._serialized_end=1177 - _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_start=785 - _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_end=876 - _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_start=878 - _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_end=968 - _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_start=970 - _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_end=1064 - _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_start=1066 - _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_end=1155 - _globals['_CREDENTIALS']._serialized_start=1179 - _globals['_CREDENTIALS']._serialized_end=1272 - _globals['_CREDENTIALDATA']._serialized_start=1275 - _globals['_CREDENTIALDATA']._serialized_end=1403 - _globals['_CREDENTIALDATA_DATAENTRY']._serialized_start=1360 - _globals['_CREDENTIALDATA_DATAENTRY']._serialized_end=1403 - _globals['_RESOURCES']._serialized_start=1405 - _globals['_RESOURCES']._serialized_end=1468 - _globals['_RUNFUNCTIONRESPONSE']._serialized_start=1471 - _globals['_RUNFUNCTIONRESPONSE']._serialized_end=1887 - _globals['_REQUESTMETA']._serialized_start=1889 - _globals['_REQUESTMETA']._serialized_end=1976 - _globals['_REQUIREMENTS']._serialized_start=1979 - _globals['_REQUIREMENTS']._serialized_end=2515 - _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_start=2231 - _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_end=2329 - _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_start=2331 - _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_end=2424 - _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_start=2426 - _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_end=2515 - _globals['_SCHEMASELECTOR']._serialized_start=2517 - _globals['_SCHEMASELECTOR']._serialized_end=2568 - _globals['_SCHEMA']._serialized_start=2570 - _globals['_SCHEMA']._serialized_end=2643 - _globals['_RESOURCESELECTOR']._serialized_start=2646 - _globals['_RESOURCESELECTOR']._serialized_end=2832 - _globals['_MATCHLABELS']._serialized_start=2835 - _globals['_MATCHLABELS']._serialized_end=2963 - _globals['_MATCHLABELS_LABELSENTRY']._serialized_start=2918 - _globals['_MATCHLABELS_LABELSENTRY']._serialized_end=2963 - _globals['_RESPONSEMETA']._serialized_start=2965 - _globals['_RESPONSEMETA']._serialized_end=3045 - _globals['_STATE']._serialized_start=3048 - _globals['_STATE']._serialized_end=3266 - _globals['_STATE_RESOURCESENTRY']._serialized_start=3181 - _globals['_STATE_RESOURCESENTRY']._serialized_end=3266 - _globals['_RESOURCE']._serialized_start=3269 - _globals['_RESOURCE']._serialized_end=3517 - _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_start=3461 - _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_end=3517 - _globals['_RESULT']._serialized_start=3520 - _globals['_RESULT']._serialized_end=3699 - _globals['_CONDITION']._serialized_start=3702 - _globals['_CONDITION']._serialized_end=3895 - _globals['_FUNCTIONRUNNERSERVICE']._serialized_start=4476 - _globals['_FUNCTIONRUNNERSERVICE']._serialized_end=4611 + _globals['_RUNFUNCTIONREQUEST']._serialized_end=1240 + _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_start=848 + _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_end=939 + _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_start=941 + _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_end=1031 + _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_start=1033 + _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_end=1127 + _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_start=1129 + _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_end=1218 + _globals['_CREDENTIALS']._serialized_start=1242 + _globals['_CREDENTIALS']._serialized_end=1335 + _globals['_CREDENTIALDATA']._serialized_start=1338 + _globals['_CREDENTIALDATA']._serialized_end=1466 + _globals['_CREDENTIALDATA_DATAENTRY']._serialized_start=1423 + _globals['_CREDENTIALDATA_DATAENTRY']._serialized_end=1466 + _globals['_RESOURCES']._serialized_start=1468 + _globals['_RESOURCES']._serialized_end=1531 + _globals['_DEPENDENCIES']._serialized_start=1533 + _globals['_DEPENDENCIES']._serialized_end=1601 + _globals['_DEPENDENCY']._serialized_start=1604 + _globals['_DEPENDENCY']._serialized_end=1828 + _globals['_REQUIREDRESOURCEDEPENDENCY']._serialized_start=1830 + _globals['_REQUIREDRESOURCEDEPENDENCY']._serialized_end=1950 + _globals['_RUNFUNCTIONRESPONSE']._serialized_start=1953 + _globals['_RUNFUNCTIONRESPONSE']._serialized_end=2432 + _globals['_REQUESTMETA']._serialized_start=2434 + _globals['_REQUESTMETA']._serialized_end=2521 + _globals['_REQUIREMENTS']._serialized_start=2524 + _globals['_REQUIREMENTS']._serialized_end=3060 + _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_start=2776 + _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_end=2874 + _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_start=2876 + _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_end=2969 + _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_start=2971 + _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_end=3060 + _globals['_SCHEMASELECTOR']._serialized_start=3062 + _globals['_SCHEMASELECTOR']._serialized_end=3113 + _globals['_SCHEMA']._serialized_start=3115 + _globals['_SCHEMA']._serialized_end=3188 + _globals['_RESOURCESELECTOR']._serialized_start=3191 + _globals['_RESOURCESELECTOR']._serialized_end=3377 + _globals['_MATCHLABELS']._serialized_start=3380 + _globals['_MATCHLABELS']._serialized_end=3508 + _globals['_MATCHLABELS_LABELSENTRY']._serialized_start=3463 + _globals['_MATCHLABELS_LABELSENTRY']._serialized_end=3508 + _globals['_RESPONSEMETA']._serialized_start=3510 + _globals['_RESPONSEMETA']._serialized_end=3590 + _globals['_STATE']._serialized_start=3593 + _globals['_STATE']._serialized_end=3811 + _globals['_STATE_RESOURCESENTRY']._serialized_start=3726 + _globals['_STATE_RESOURCESENTRY']._serialized_end=3811 + _globals['_RESOURCE']._serialized_start=3814 + _globals['_RESOURCE']._serialized_end=4062 + _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_start=4006 + _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_end=4062 + _globals['_RESULT']._serialized_start=4065 + _globals['_RESULT']._serialized_end=4244 + _globals['_CONDITION']._serialized_start=4247 + _globals['_CONDITION']._serialized_end=4440 + _globals['_FUNCTIONRUNNERSERVICE']._serialized_start=5159 + _globals['_FUNCTIONRUNNERSERVICE']._serialized_end=5294 # @@protoc_insertion_point(module_scope) diff --git a/crossplane/function/proto/v1/run_function_pb2.pyi b/crossplane/function/proto/v1/run_function_pb2.pyi index d0d29ca..6aeafc9 100644 --- a/crossplane/function/proto/v1/run_function_pb2.pyi +++ b/crossplane/function/proto/v1/run_function_pb2.pyi @@ -11,6 +11,11 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor +class DependencyLifecycle(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + DEPENDENCY_LIFECYCLE_UNSPECIFIED: _ClassVar[DependencyLifecycle] + DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY: _ClassVar[DependencyLifecycle] + class Capability(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () CAPABILITY_UNSPECIFIED: _ClassVar[Capability] @@ -19,6 +24,7 @@ class Capability(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): CAPABILITY_CREDENTIALS: _ClassVar[Capability] CAPABILITY_CONDITIONS: _ClassVar[Capability] CAPABILITY_REQUIRED_SCHEMAS: _ClassVar[Capability] + CAPABILITY_DEPENDENCIES: _ClassVar[Capability] class Ready(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -45,12 +51,15 @@ class Status(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): STATUS_CONDITION_UNKNOWN: _ClassVar[Status] STATUS_CONDITION_TRUE: _ClassVar[Status] STATUS_CONDITION_FALSE: _ClassVar[Status] +DEPENDENCY_LIFECYCLE_UNSPECIFIED: DependencyLifecycle +DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY: DependencyLifecycle CAPABILITY_UNSPECIFIED: Capability CAPABILITY_CAPABILITIES: Capability CAPABILITY_REQUIRED_RESOURCES: Capability CAPABILITY_CREDENTIALS: Capability CAPABILITY_CONDITIONS: Capability CAPABILITY_REQUIRED_SCHEMAS: Capability +CAPABILITY_DEPENDENCIES: Capability READY_UNSPECIFIED: Ready READY_TRUE: Ready READY_FALSE: Ready @@ -67,7 +76,7 @@ STATUS_CONDITION_TRUE: Status STATUS_CONDITION_FALSE: Status class RunFunctionRequest(_message.Message): - __slots__ = ("meta", "observed", "desired", "input", "context", "extra_resources", "credentials", "required_resources", "required_schemas") + __slots__ = ("meta", "observed", "desired", "input", "context", "extra_resources", "credentials", "required_resources", "required_schemas", "dependencies") class ExtraResourcesEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -105,6 +114,7 @@ class RunFunctionRequest(_message.Message): CREDENTIALS_FIELD_NUMBER: _ClassVar[int] REQUIRED_RESOURCES_FIELD_NUMBER: _ClassVar[int] REQUIRED_SCHEMAS_FIELD_NUMBER: _ClassVar[int] + DEPENDENCIES_FIELD_NUMBER: _ClassVar[int] meta: RequestMeta observed: State desired: State @@ -114,7 +124,8 @@ class RunFunctionRequest(_message.Message): credentials: _containers.MessageMap[str, Credentials] required_resources: _containers.MessageMap[str, Resources] required_schemas: _containers.MessageMap[str, Schema] - def __init__(self, meta: _Optional[_Union[RequestMeta, _Mapping]] = ..., observed: _Optional[_Union[State, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., extra_resources: _Optional[_Mapping[str, Resources]] = ..., credentials: _Optional[_Mapping[str, Credentials]] = ..., required_resources: _Optional[_Mapping[str, Resources]] = ..., required_schemas: _Optional[_Mapping[str, Schema]] = ...) -> None: ... + dependencies: Dependencies + def __init__(self, meta: _Optional[_Union[RequestMeta, _Mapping]] = ..., observed: _Optional[_Union[State, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., extra_resources: _Optional[_Mapping[str, Resources]] = ..., credentials: _Optional[_Mapping[str, Credentials]] = ..., required_resources: _Optional[_Mapping[str, Resources]] = ..., required_schemas: _Optional[_Mapping[str, Schema]] = ..., dependencies: _Optional[_Union[Dependencies, _Mapping]] = ...) -> None: ... class Credentials(_message.Message): __slots__ = ("credential_data",) @@ -141,8 +152,36 @@ class Resources(_message.Message): items: _containers.RepeatedCompositeFieldContainer[Resource] def __init__(self, items: _Optional[_Iterable[_Union[Resource, _Mapping]]] = ...) -> None: ... +class Dependencies(_message.Message): + __slots__ = ("items",) + ITEMS_FIELD_NUMBER: _ClassVar[int] + items: _containers.RepeatedCompositeFieldContainer[Dependency] + def __init__(self, items: _Optional[_Iterable[_Union[Dependency, _Mapping]]] = ...) -> None: ... + +class Dependency(_message.Message): + __slots__ = ("resource", "composed_resource", "required_resource", "lifecycle") + RESOURCE_FIELD_NUMBER: _ClassVar[int] + COMPOSED_RESOURCE_FIELD_NUMBER: _ClassVar[int] + REQUIRED_RESOURCE_FIELD_NUMBER: _ClassVar[int] + LIFECYCLE_FIELD_NUMBER: _ClassVar[int] + resource: str + composed_resource: str + required_resource: RequiredResourceDependency + lifecycle: DependencyLifecycle + def __init__(self, resource: _Optional[str] = ..., composed_resource: _Optional[str] = ..., required_resource: _Optional[_Union[RequiredResourceDependency, _Mapping]] = ..., lifecycle: _Optional[_Union[DependencyLifecycle, str]] = ...) -> None: ... + +class RequiredResourceDependency(_message.Message): + __slots__ = ("requirement_name", "name", "namespace") + REQUIREMENT_NAME_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + NAMESPACE_FIELD_NUMBER: _ClassVar[int] + requirement_name: str + name: str + namespace: str + def __init__(self, requirement_name: _Optional[str] = ..., name: _Optional[str] = ..., namespace: _Optional[str] = ...) -> None: ... + class RunFunctionResponse(_message.Message): - __slots__ = ("meta", "desired", "results", "context", "requirements", "conditions", "output") + __slots__ = ("meta", "desired", "results", "context", "requirements", "conditions", "output", "dependencies") META_FIELD_NUMBER: _ClassVar[int] DESIRED_FIELD_NUMBER: _ClassVar[int] RESULTS_FIELD_NUMBER: _ClassVar[int] @@ -150,6 +189,7 @@ class RunFunctionResponse(_message.Message): REQUIREMENTS_FIELD_NUMBER: _ClassVar[int] CONDITIONS_FIELD_NUMBER: _ClassVar[int] OUTPUT_FIELD_NUMBER: _ClassVar[int] + DEPENDENCIES_FIELD_NUMBER: _ClassVar[int] meta: ResponseMeta desired: State results: _containers.RepeatedCompositeFieldContainer[Result] @@ -157,7 +197,8 @@ class RunFunctionResponse(_message.Message): requirements: Requirements conditions: _containers.RepeatedCompositeFieldContainer[Condition] output: _struct_pb2.Struct - def __init__(self, meta: _Optional[_Union[ResponseMeta, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., results: _Optional[_Iterable[_Union[Result, _Mapping]]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., requirements: _Optional[_Union[Requirements, _Mapping]] = ..., conditions: _Optional[_Iterable[_Union[Condition, _Mapping]]] = ..., output: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... + dependencies: Dependencies + def __init__(self, meta: _Optional[_Union[ResponseMeta, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., results: _Optional[_Iterable[_Union[Result, _Mapping]]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., requirements: _Optional[_Union[Requirements, _Mapping]] = ..., conditions: _Optional[_Iterable[_Union[Condition, _Mapping]]] = ..., output: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., dependencies: _Optional[_Union[Dependencies, _Mapping]] = ...) -> None: ... class RequestMeta(_message.Message): __slots__ = ("tag", "capabilities") diff --git a/crossplane/function/proto/v1beta1/run_function.proto b/crossplane/function/proto/v1beta1/run_function.proto index 667c740..132a6c9 100644 --- a/crossplane/function/proto/v1beta1/run_function.proto +++ b/crossplane/function/proto/v1beta1/run_function.proto @@ -99,6 +99,10 @@ message RunFunctionRequest { // sets the map key to an empty Schema message to indicate that it attempted // to satisfy the request. map required_schemas = 9; + + // Optional ordering constraints over composed resources, accumulated by the + // functions that ran before this one. + Dependencies dependencies = 10; } // Credentials that a function may use to communicate with an external system. @@ -120,6 +124,78 @@ message Resources { repeated Resource items = 1; } +// Dependencies is a set of ordering constraints over composed resources. +// +// This is a message wrapping a repeated field, rather than a bare repeated +// field, so that an unset value can be told apart from an empty one. A bare +// repeated field can't express that difference: proto3 has no presence for +// repeated fields, and an empty list and an unset field are both zero bytes on +// the wire. Crossplane relies on the difference, because an unset value means +// "this function has no opinion, carry my constraints forward" while an empty +// one means "this function wants no constraints at all." State wraps desired +// and observed resources for the same reason. +message Dependencies { + repeated Dependency items = 1; +} + +// A Dependency declares that one composed resource must be created after, and +// deleted before, another resource. It expresses ordering only. It doesn't move +// any data between resources. +message Dependency { + // Name of the composed resource that has the dependency. A key into the + // desired or observed State.resources map. + string resource = 1; + + // What the resource depends on. + oneof depends_on { + // Name of another composed resource. A key into the desired or observed + // State.resources map. + string composed_resource = 2; + + // A resource the pipeline required, rather than composed. Crossplane never + // deletes a resource it didn't compose, so these constrain only the order + // resources are created and updated. + RequiredResourceDependency required_resource = 4; + } + + // How this dependency constrains the order its endpoints are created and + // deleted. Defaults to symmetric ordering. + DependencyLifecycle lifecycle = 3; +} + +// A DependencyLifecycle is how a dependency constrains the order its endpoints +// are created and deleted. +enum DependencyLifecycle { + // Symmetric ordering: the resource is created only once what it depends on + // is ready, and what it depends on is deleted only once the resource is + // gone. This is the default, and the only valid value when depends_on is a + // required resource, because Crossplane never deletes a resource it didn't + // compose. + DEPENDENCY_LIFECYCLE_UNSPECIFIED = 0; + + // The resource may be created without waiting for what it depends on to be + // deleted. It must still exist and be ready before what it depends on is + // deleted. Use for a replacement that must exist before its predecessor is + // torn down. Only valid when depends_on is a composed resource. + DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY = 1; +} + +// A RequiredResourceDependency identifies a resource the pipeline required, +// for the purpose of ordering. +message RequiredResourceDependency { + // The requirement name. A key into a RunFunctionRequest's required_resources + // map, and into a RunFunctionResponse's requirements.resources map. + string requirement_name = 1; + + // Optional name of a single resource within the set the requirement matched. + // If unset, every resource the requirement matched must be ready. + optional string name = 2; + + // Namespace of name for a namespaced resource. Leave unset for a + // cluster-scoped resource. name and namespace together identify one match. + optional string namespace = 3; +} + // A RunFunctionResponse contains the result of a function run. message RunFunctionResponse { // Metadata pertaining to this response. @@ -158,6 +234,14 @@ message RunFunctionResponse { // // Only Operations use function output. XRs will discard any function output. optional google.protobuf.Struct output = 7; + + // Optional ordering constraints over composed resources. A function that has + // an opinion about ordering must return the full set it wants going forward, + // including edges it received and still wants. Leaving this field unset means + // "no opinion" - Crossplane carries forward whatever it sent in the request. + // + // Dependencies are only used for composition. They're ignored by Operations. + Dependencies dependencies = 8; } // RequestMeta contains metadata pertaining to a RunFunctionRequest. @@ -203,6 +287,11 @@ enum Capability { // OpenAPI schemas and Crossplane will return them in required_schemas. Added // in Crossplane v2.2. CAPABILITY_REQUIRED_SCHEMAS = 5; + + // Crossplane supports the dependencies field. Functions can declare ordering + // constraints over composed resources, and Crossplane will sequence the + // resources it creates, updates and deletes accordingly. + CAPABILITY_DEPENDENCIES = 6; } // Requirements that must be satisfied for a function to run successfully. diff --git a/crossplane/function/proto/v1beta1/run_function_pb2.py b/crossplane/function/proto/v1beta1/run_function_pb2.py index 3f3ebd4..93b6f9a 100644 --- a/crossplane/function/proto/v1beta1/run_function_pb2.py +++ b/crossplane/function/proto/v1beta1/run_function_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n4crossplane/function/proto/v1beta1/run_function.proto\x12\x1e\x61piextensions.fn.proto.v1beta1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xc3\x08\n\x12RunFunctionRequest\x12\x39\n\x04meta\x18\x01 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.RequestMeta\x12\x37\n\x08observed\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x36\n\x07\x64\x65sired\x18\x03 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12+\n\x05input\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12-\n\x07\x63ontext\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x12\x63\n\x0f\x65xtra_resources\x18\x06 \x03(\x0b\x32\x46.apiextensions.fn.proto.v1beta1.RunFunctionRequest.ExtraResourcesEntryB\x02\x18\x01\x12X\n\x0b\x63redentials\x18\x07 \x03(\x0b\x32\x43.apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntry\x12\x65\n\x12required_resources\x18\x08 \x03(\x0b\x32I.apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredResourcesEntry\x12\x61\n\x10required_schemas\x18\t \x03(\x0b\x32G.apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredSchemasEntry\x1a`\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).apiextensions.fn.proto.v1beta1.Resources:\x02\x38\x01\x1a_\n\x10\x43redentialsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.Credentials:\x02\x38\x01\x1a\x63\n\x16RequiredResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).apiextensions.fn.proto.v1beta1.Resources:\x02\x38\x01\x1a^\n\x14RequiredSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x35\n\x05value\x18\x02 \x01(\x0b\x32&.apiextensions.fn.proto.v1beta1.Schema:\x02\x38\x01\x42\x08\n\x06_inputB\n\n\x08_context\"b\n\x0b\x43redentials\x12I\n\x0f\x63redential_data\x18\x01 \x01(\x0b\x32..apiextensions.fn.proto.v1beta1.CredentialDataH\x00\x42\x08\n\x06source\"\x85\x01\n\x0e\x43redentialData\x12\x46\n\x04\x64\x61ta\x18\x01 \x03(\x0b\x32\x38.apiextensions.fn.proto.v1beta1.CredentialData.DataEntry\x1a+\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"D\n\tResources\x12\x37\n\x05items\x18\x01 \x03(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource\"\xb9\x03\n\x13RunFunctionResponse\x12:\n\x04meta\x18\x01 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.ResponseMeta\x12\x36\n\x07\x64\x65sired\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x37\n\x07results\x18\x03 \x03(\x0b\x32&.apiextensions.fn.proto.v1beta1.Result\x12-\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12\x42\n\x0crequirements\x18\x05 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.Requirements\x12=\n\nconditions\x18\x06 \x03(\x0b\x32).apiextensions.fn.proto.v1beta1.Condition\x12,\n\x06output\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x42\n\n\x08_contextB\t\n\x07_output\"\\\n\x0bRequestMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12@\n\x0c\x63\x61pabilities\x18\x02 \x03(\x0e\x32*.apiextensions.fn.proto.v1beta1.Capability\"\xb6\x04\n\x0cRequirements\x12]\n\x0f\x65xtra_resources\x18\x01 \x03(\x0b\x32@.apiextensions.fn.proto.v1beta1.Requirements.ExtraResourcesEntryB\x02\x18\x01\x12N\n\tresources\x18\x02 \x03(\x0b\x32;.apiextensions.fn.proto.v1beta1.Requirements.ResourcesEntry\x12J\n\x07schemas\x18\x03 \x03(\x0b\x32\x39.apiextensions.fn.proto.v1beta1.Requirements.SchemasEntry\x1ag\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12?\n\x05value\x18\x02 \x01(\x0b\x32\x30.apiextensions.fn.proto.v1beta1.ResourceSelector:\x02\x38\x01\x1a\x62\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12?\n\x05value\x18\x02 \x01(\x0b\x32\x30.apiextensions.fn.proto.v1beta1.ResourceSelector:\x02\x38\x01\x1a^\n\x0cSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12=\n\x05value\x18\x02 \x01(\x0b\x32..apiextensions.fn.proto.v1beta1.SchemaSelector:\x02\x38\x01\"3\n\x0eSchemaSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\"I\n\x06Schema\x12\x30\n\nopenapi_v3\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\r\n\x0b_openapi_v3\"\xbf\x01\n\x10ResourceSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x14\n\nmatch_name\x18\x03 \x01(\tH\x00\x12\x43\n\x0cmatch_labels\x18\x04 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.MatchLabelsH\x00\x12\x16\n\tnamespace\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05matchB\x0c\n\n_namespace\"\x85\x01\n\x0bMatchLabels\x12G\n\x06labels\x18\x01 \x03(\x0b\x32\x37.apiextensions.fn.proto.v1beta1.MatchLabels.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"P\n\x0cResponseMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12+\n\x03ttl\x18\x02 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00\x88\x01\x01\x42\x06\n\x04_ttl\"\xe9\x01\n\x05State\x12;\n\tcomposite\x18\x01 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource\x12G\n\tresources\x18\x02 \x03(\x0b\x32\x34.apiextensions.fn.proto.v1beta1.State.ResourcesEntry\x1aZ\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x37\n\x05value\x18\x02 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource:\x02\x38\x01\"\x82\x02\n\x08Resource\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12[\n\x12\x63onnection_details\x18\x02 \x03(\x0b\x32?.apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry\x12\x34\n\x05ready\x18\x03 \x01(\x0e\x32%.apiextensions.fn.proto.v1beta1.Ready\x1a\x38\n\x16\x43onnectionDetailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xbd\x01\n\x06Result\x12:\n\x08severity\x18\x01 \x01(\x0e\x32(.apiextensions.fn.proto.v1beta1.Severity\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x06reason\x18\x03 \x01(\tH\x00\x88\x01\x01\x12;\n\x06target\x18\x04 \x01(\x0e\x32&.apiextensions.fn.proto.v1beta1.TargetH\x01\x88\x01\x01\x42\t\n\x07_reasonB\t\n\x07_target\"\xcb\x01\n\tCondition\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x36\n\x06status\x18\x02 \x01(\x0e\x32&.apiextensions.fn.proto.v1beta1.Status\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x14\n\x07message\x18\x04 \x01(\tH\x00\x88\x01\x01\x12;\n\x06target\x18\x05 \x01(\x0e\x32&.apiextensions.fn.proto.v1beta1.TargetH\x01\x88\x01\x01\x42\n\n\x08_messageB\t\n\x07_target*\xc0\x01\n\nCapability\x12\x1a\n\x16\x43\x41PABILITY_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43\x41PABILITY_CAPABILITIES\x10\x01\x12!\n\x1d\x43\x41PABILITY_REQUIRED_RESOURCES\x10\x02\x12\x1a\n\x16\x43\x41PABILITY_CREDENTIALS\x10\x03\x12\x19\n\x15\x43\x41PABILITY_CONDITIONS\x10\x04\x12\x1f\n\x1b\x43\x41PABILITY_REQUIRED_SCHEMAS\x10\x05*?\n\x05Ready\x12\x15\n\x11READY_UNSPECIFIED\x10\x00\x12\x0e\n\nREADY_TRUE\x10\x01\x12\x0f\n\x0bREADY_FALSE\x10\x02*c\n\x08Severity\x12\x18\n\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eSEVERITY_FATAL\x10\x01\x12\x14\n\x10SEVERITY_WARNING\x10\x02\x12\x13\n\x0fSEVERITY_NORMAL\x10\x03*V\n\x06Target\x12\x16\n\x12TARGET_UNSPECIFIED\x10\x00\x12\x14\n\x10TARGET_COMPOSITE\x10\x01\x12\x1e\n\x1aTARGET_COMPOSITE_AND_CLAIM\x10\x02*\x7f\n\x06Status\x12 \n\x1cSTATUS_CONDITION_UNSPECIFIED\x10\x00\x12\x1c\n\x18STATUS_CONDITION_UNKNOWN\x10\x01\x12\x19\n\x15STATUS_CONDITION_TRUE\x10\x02\x12\x1a\n\x16STATUS_CONDITION_FALSE\x10\x03\x32\x91\x01\n\x15\x46unctionRunnerService\x12x\n\x0bRunFunction\x12\x32.apiextensions.fn.proto.v1beta1.RunFunctionRequest\x1a\x33.apiextensions.fn.proto.v1beta1.RunFunctionResponse\"\x00\x42\x36Z4github.com/crossplane/crossplane/v2/proto/fn/v1beta1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n4crossplane/function/proto/v1beta1/run_function.proto\x12\x1e\x61piextensions.fn.proto.v1beta1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x87\t\n\x12RunFunctionRequest\x12\x39\n\x04meta\x18\x01 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.RequestMeta\x12\x37\n\x08observed\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x36\n\x07\x64\x65sired\x18\x03 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12+\n\x05input\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12-\n\x07\x63ontext\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x12\x63\n\x0f\x65xtra_resources\x18\x06 \x03(\x0b\x32\x46.apiextensions.fn.proto.v1beta1.RunFunctionRequest.ExtraResourcesEntryB\x02\x18\x01\x12X\n\x0b\x63redentials\x18\x07 \x03(\x0b\x32\x43.apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntry\x12\x65\n\x12required_resources\x18\x08 \x03(\x0b\x32I.apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredResourcesEntry\x12\x61\n\x10required_schemas\x18\t \x03(\x0b\x32G.apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredSchemasEntry\x12\x42\n\x0c\x64\x65pendencies\x18\n \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.Dependencies\x1a`\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).apiextensions.fn.proto.v1beta1.Resources:\x02\x38\x01\x1a_\n\x10\x43redentialsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.Credentials:\x02\x38\x01\x1a\x63\n\x16RequiredResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x01(\x0b\x32).apiextensions.fn.proto.v1beta1.Resources:\x02\x38\x01\x1a^\n\x14RequiredSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x35\n\x05value\x18\x02 \x01(\x0b\x32&.apiextensions.fn.proto.v1beta1.Schema:\x02\x38\x01\x42\x08\n\x06_inputB\n\n\x08_context\"b\n\x0b\x43redentials\x12I\n\x0f\x63redential_data\x18\x01 \x01(\x0b\x32..apiextensions.fn.proto.v1beta1.CredentialDataH\x00\x42\x08\n\x06source\"\x85\x01\n\x0e\x43redentialData\x12\x46\n\x04\x64\x61ta\x18\x01 \x03(\x0b\x32\x38.apiextensions.fn.proto.v1beta1.CredentialData.DataEntry\x1a+\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"D\n\tResources\x12\x37\n\x05items\x18\x01 \x03(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource\"I\n\x0c\x44\x65pendencies\x12\x39\n\x05items\x18\x01 \x03(\x0b\x32*.apiextensions.fn.proto.v1beta1.Dependency\"\xea\x01\n\nDependency\x12\x10\n\x08resource\x18\x01 \x01(\t\x12\x1b\n\x11\x63omposed_resource\x18\x02 \x01(\tH\x00\x12W\n\x11required_resource\x18\x04 \x01(\x0b\x32:.apiextensions.fn.proto.v1beta1.RequiredResourceDependencyH\x00\x12\x46\n\tlifecycle\x18\x03 \x01(\x0e\x32\x33.apiextensions.fn.proto.v1beta1.DependencyLifecycleB\x0c\n\ndepends_on\"x\n\x1aRequiredResourceDependency\x12\x18\n\x10requirement_name\x18\x01 \x01(\t\x12\x11\n\x04name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x16\n\tnamespace\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\x0c\n\n_namespace\"\xfd\x03\n\x13RunFunctionResponse\x12:\n\x04meta\x18\x01 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.ResponseMeta\x12\x36\n\x07\x64\x65sired\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x37\n\x07results\x18\x03 \x03(\x0b\x32&.apiextensions.fn.proto.v1beta1.Result\x12-\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12\x42\n\x0crequirements\x18\x05 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.Requirements\x12=\n\nconditions\x18\x06 \x03(\x0b\x32).apiextensions.fn.proto.v1beta1.Condition\x12,\n\x06output\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x12\x42\n\x0c\x64\x65pendencies\x18\x08 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.DependenciesB\n\n\x08_contextB\t\n\x07_output\"\\\n\x0bRequestMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12@\n\x0c\x63\x61pabilities\x18\x02 \x03(\x0e\x32*.apiextensions.fn.proto.v1beta1.Capability\"\xb6\x04\n\x0cRequirements\x12]\n\x0f\x65xtra_resources\x18\x01 \x03(\x0b\x32@.apiextensions.fn.proto.v1beta1.Requirements.ExtraResourcesEntryB\x02\x18\x01\x12N\n\tresources\x18\x02 \x03(\x0b\x32;.apiextensions.fn.proto.v1beta1.Requirements.ResourcesEntry\x12J\n\x07schemas\x18\x03 \x03(\x0b\x32\x39.apiextensions.fn.proto.v1beta1.Requirements.SchemasEntry\x1ag\n\x13\x45xtraResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12?\n\x05value\x18\x02 \x01(\x0b\x32\x30.apiextensions.fn.proto.v1beta1.ResourceSelector:\x02\x38\x01\x1a\x62\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12?\n\x05value\x18\x02 \x01(\x0b\x32\x30.apiextensions.fn.proto.v1beta1.ResourceSelector:\x02\x38\x01\x1a^\n\x0cSchemasEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12=\n\x05value\x18\x02 \x01(\x0b\x32..apiextensions.fn.proto.v1beta1.SchemaSelector:\x02\x38\x01\"3\n\x0eSchemaSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\"I\n\x06Schema\x12\x30\n\nopenapi_v3\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\r\n\x0b_openapi_v3\"\xbf\x01\n\x10ResourceSelector\x12\x13\n\x0b\x61pi_version\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x14\n\nmatch_name\x18\x03 \x01(\tH\x00\x12\x43\n\x0cmatch_labels\x18\x04 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.MatchLabelsH\x00\x12\x16\n\tnamespace\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05matchB\x0c\n\n_namespace\"\x85\x01\n\x0bMatchLabels\x12G\n\x06labels\x18\x01 \x03(\x0b\x32\x37.apiextensions.fn.proto.v1beta1.MatchLabels.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"P\n\x0cResponseMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12+\n\x03ttl\x18\x02 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00\x88\x01\x01\x42\x06\n\x04_ttl\"\xe9\x01\n\x05State\x12;\n\tcomposite\x18\x01 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource\x12G\n\tresources\x18\x02 \x03(\x0b\x32\x34.apiextensions.fn.proto.v1beta1.State.ResourcesEntry\x1aZ\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x37\n\x05value\x18\x02 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource:\x02\x38\x01\"\x82\x02\n\x08Resource\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12[\n\x12\x63onnection_details\x18\x02 \x03(\x0b\x32?.apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry\x12\x34\n\x05ready\x18\x03 \x01(\x0e\x32%.apiextensions.fn.proto.v1beta1.Ready\x1a\x38\n\x16\x43onnectionDetailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xbd\x01\n\x06Result\x12:\n\x08severity\x18\x01 \x01(\x0e\x32(.apiextensions.fn.proto.v1beta1.Severity\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x06reason\x18\x03 \x01(\tH\x00\x88\x01\x01\x12;\n\x06target\x18\x04 \x01(\x0e\x32&.apiextensions.fn.proto.v1beta1.TargetH\x01\x88\x01\x01\x42\t\n\x07_reasonB\t\n\x07_target\"\xcb\x01\n\tCondition\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x36\n\x06status\x18\x02 \x01(\x0e\x32&.apiextensions.fn.proto.v1beta1.Status\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x14\n\x07message\x18\x04 \x01(\tH\x00\x88\x01\x01\x12;\n\x06target\x18\x05 \x01(\x0e\x32&.apiextensions.fn.proto.v1beta1.TargetH\x01\x88\x01\x01\x42\n\n\x08_messageB\t\n\x07_target*k\n\x13\x44\x65pendencyLifecycle\x12$\n DEPENDENCY_LIFECYCLE_UNSPECIFIED\x10\x00\x12.\n*DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY\x10\x01*\xdd\x01\n\nCapability\x12\x1a\n\x16\x43\x41PABILITY_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43\x41PABILITY_CAPABILITIES\x10\x01\x12!\n\x1d\x43\x41PABILITY_REQUIRED_RESOURCES\x10\x02\x12\x1a\n\x16\x43\x41PABILITY_CREDENTIALS\x10\x03\x12\x19\n\x15\x43\x41PABILITY_CONDITIONS\x10\x04\x12\x1f\n\x1b\x43\x41PABILITY_REQUIRED_SCHEMAS\x10\x05\x12\x1b\n\x17\x43\x41PABILITY_DEPENDENCIES\x10\x06*?\n\x05Ready\x12\x15\n\x11READY_UNSPECIFIED\x10\x00\x12\x0e\n\nREADY_TRUE\x10\x01\x12\x0f\n\x0bREADY_FALSE\x10\x02*c\n\x08Severity\x12\x18\n\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eSEVERITY_FATAL\x10\x01\x12\x14\n\x10SEVERITY_WARNING\x10\x02\x12\x13\n\x0fSEVERITY_NORMAL\x10\x03*V\n\x06Target\x12\x16\n\x12TARGET_UNSPECIFIED\x10\x00\x12\x14\n\x10TARGET_COMPOSITE\x10\x01\x12\x1e\n\x1aTARGET_COMPOSITE_AND_CLAIM\x10\x02*\x7f\n\x06Status\x12 \n\x1cSTATUS_CONDITION_UNSPECIFIED\x10\x00\x12\x1c\n\x18STATUS_CONDITION_UNKNOWN\x10\x01\x12\x19\n\x15STATUS_CONDITION_TRUE\x10\x02\x12\x1a\n\x16STATUS_CONDITION_FALSE\x10\x03\x32\x91\x01\n\x15\x46unctionRunnerService\x12x\n\x0bRunFunction\x12\x32.apiextensions.fn.proto.v1beta1.RunFunctionRequest\x1a\x33.apiextensions.fn.proto.v1beta1.RunFunctionResponse\"\x00\x42\x36Z4github.com/crossplane/crossplane/v2/proto/fn/v1beta1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -60,70 +60,78 @@ _globals['_STATE_RESOURCESENTRY']._serialized_options = b'8\001' _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._loaded_options = None _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_options = b'8\001' - _globals['_CAPABILITY']._serialized_start=4093 - _globals['_CAPABILITY']._serialized_end=4285 - _globals['_READY']._serialized_start=4287 - _globals['_READY']._serialized_end=4350 - _globals['_SEVERITY']._serialized_start=4352 - _globals['_SEVERITY']._serialized_end=4451 - _globals['_TARGET']._serialized_start=4453 - _globals['_TARGET']._serialized_end=4539 - _globals['_STATUS']._serialized_start=4541 - _globals['_STATUS']._serialized_end=4668 + _globals['_DEPENDENCYLIFECYCLE']._serialized_start=4662 + _globals['_DEPENDENCYLIFECYCLE']._serialized_end=4769 + _globals['_CAPABILITY']._serialized_start=4772 + _globals['_CAPABILITY']._serialized_end=4993 + _globals['_READY']._serialized_start=4995 + _globals['_READY']._serialized_end=5058 + _globals['_SEVERITY']._serialized_start=5060 + _globals['_SEVERITY']._serialized_end=5159 + _globals['_TARGET']._serialized_start=5161 + _globals['_TARGET']._serialized_end=5247 + _globals['_STATUS']._serialized_start=5249 + _globals['_STATUS']._serialized_end=5376 _globals['_RUNFUNCTIONREQUEST']._serialized_start=151 - _globals['_RUNFUNCTIONREQUEST']._serialized_end=1242 - _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_start=830 - _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_end=926 - _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_start=928 - _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_end=1023 - _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_start=1025 - _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_end=1124 - _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_start=1126 - _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_end=1220 - _globals['_CREDENTIALS']._serialized_start=1244 - _globals['_CREDENTIALS']._serialized_end=1342 - _globals['_CREDENTIALDATA']._serialized_start=1345 - _globals['_CREDENTIALDATA']._serialized_end=1478 - _globals['_CREDENTIALDATA_DATAENTRY']._serialized_start=1435 - _globals['_CREDENTIALDATA_DATAENTRY']._serialized_end=1478 - _globals['_RESOURCES']._serialized_start=1480 - _globals['_RESOURCES']._serialized_end=1548 - _globals['_RUNFUNCTIONRESPONSE']._serialized_start=1551 - _globals['_RUNFUNCTIONRESPONSE']._serialized_end=1992 - _globals['_REQUESTMETA']._serialized_start=1994 - _globals['_REQUESTMETA']._serialized_end=2086 - _globals['_REQUIREMENTS']._serialized_start=2089 - _globals['_REQUIREMENTS']._serialized_end=2655 - _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_start=2356 - _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_end=2459 - _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_start=2461 - _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_end=2559 - _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_start=2561 - _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_end=2655 - _globals['_SCHEMASELECTOR']._serialized_start=2657 - _globals['_SCHEMASELECTOR']._serialized_end=2708 - _globals['_SCHEMA']._serialized_start=2710 - _globals['_SCHEMA']._serialized_end=2783 - _globals['_RESOURCESELECTOR']._serialized_start=2786 - _globals['_RESOURCESELECTOR']._serialized_end=2977 - _globals['_MATCHLABELS']._serialized_start=2980 - _globals['_MATCHLABELS']._serialized_end=3113 - _globals['_MATCHLABELS_LABELSENTRY']._serialized_start=3068 - _globals['_MATCHLABELS_LABELSENTRY']._serialized_end=3113 - _globals['_RESPONSEMETA']._serialized_start=3115 - _globals['_RESPONSEMETA']._serialized_end=3195 - _globals['_STATE']._serialized_start=3198 - _globals['_STATE']._serialized_end=3431 - _globals['_STATE_RESOURCESENTRY']._serialized_start=3341 - _globals['_STATE_RESOURCESENTRY']._serialized_end=3431 - _globals['_RESOURCE']._serialized_start=3434 - _globals['_RESOURCE']._serialized_end=3692 - _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_start=3636 - _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_end=3692 - _globals['_RESULT']._serialized_start=3695 - _globals['_RESULT']._serialized_end=3884 - _globals['_CONDITION']._serialized_start=3887 - _globals['_CONDITION']._serialized_end=4090 - _globals['_FUNCTIONRUNNERSERVICE']._serialized_start=4671 - _globals['_FUNCTIONRUNNERSERVICE']._serialized_end=4816 + _globals['_RUNFUNCTIONREQUEST']._serialized_end=1310 + _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_start=898 + _globals['_RUNFUNCTIONREQUEST_EXTRARESOURCESENTRY']._serialized_end=994 + _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_start=996 + _globals['_RUNFUNCTIONREQUEST_CREDENTIALSENTRY']._serialized_end=1091 + _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_start=1093 + _globals['_RUNFUNCTIONREQUEST_REQUIREDRESOURCESENTRY']._serialized_end=1192 + _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_start=1194 + _globals['_RUNFUNCTIONREQUEST_REQUIREDSCHEMASENTRY']._serialized_end=1288 + _globals['_CREDENTIALS']._serialized_start=1312 + _globals['_CREDENTIALS']._serialized_end=1410 + _globals['_CREDENTIALDATA']._serialized_start=1413 + _globals['_CREDENTIALDATA']._serialized_end=1546 + _globals['_CREDENTIALDATA_DATAENTRY']._serialized_start=1503 + _globals['_CREDENTIALDATA_DATAENTRY']._serialized_end=1546 + _globals['_RESOURCES']._serialized_start=1548 + _globals['_RESOURCES']._serialized_end=1616 + _globals['_DEPENDENCIES']._serialized_start=1618 + _globals['_DEPENDENCIES']._serialized_end=1691 + _globals['_DEPENDENCY']._serialized_start=1694 + _globals['_DEPENDENCY']._serialized_end=1928 + _globals['_REQUIREDRESOURCEDEPENDENCY']._serialized_start=1930 + _globals['_REQUIREDRESOURCEDEPENDENCY']._serialized_end=2050 + _globals['_RUNFUNCTIONRESPONSE']._serialized_start=2053 + _globals['_RUNFUNCTIONRESPONSE']._serialized_end=2562 + _globals['_REQUESTMETA']._serialized_start=2564 + _globals['_REQUESTMETA']._serialized_end=2656 + _globals['_REQUIREMENTS']._serialized_start=2659 + _globals['_REQUIREMENTS']._serialized_end=3225 + _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_start=2926 + _globals['_REQUIREMENTS_EXTRARESOURCESENTRY']._serialized_end=3029 + _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_start=3031 + _globals['_REQUIREMENTS_RESOURCESENTRY']._serialized_end=3129 + _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_start=3131 + _globals['_REQUIREMENTS_SCHEMASENTRY']._serialized_end=3225 + _globals['_SCHEMASELECTOR']._serialized_start=3227 + _globals['_SCHEMASELECTOR']._serialized_end=3278 + _globals['_SCHEMA']._serialized_start=3280 + _globals['_SCHEMA']._serialized_end=3353 + _globals['_RESOURCESELECTOR']._serialized_start=3356 + _globals['_RESOURCESELECTOR']._serialized_end=3547 + _globals['_MATCHLABELS']._serialized_start=3550 + _globals['_MATCHLABELS']._serialized_end=3683 + _globals['_MATCHLABELS_LABELSENTRY']._serialized_start=3638 + _globals['_MATCHLABELS_LABELSENTRY']._serialized_end=3683 + _globals['_RESPONSEMETA']._serialized_start=3685 + _globals['_RESPONSEMETA']._serialized_end=3765 + _globals['_STATE']._serialized_start=3768 + _globals['_STATE']._serialized_end=4001 + _globals['_STATE_RESOURCESENTRY']._serialized_start=3911 + _globals['_STATE_RESOURCESENTRY']._serialized_end=4001 + _globals['_RESOURCE']._serialized_start=4004 + _globals['_RESOURCE']._serialized_end=4262 + _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_start=4206 + _globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_end=4262 + _globals['_RESULT']._serialized_start=4265 + _globals['_RESULT']._serialized_end=4454 + _globals['_CONDITION']._serialized_start=4457 + _globals['_CONDITION']._serialized_end=4660 + _globals['_FUNCTIONRUNNERSERVICE']._serialized_start=5379 + _globals['_FUNCTIONRUNNERSERVICE']._serialized_end=5524 # @@protoc_insertion_point(module_scope) diff --git a/crossplane/function/proto/v1beta1/run_function_pb2.pyi b/crossplane/function/proto/v1beta1/run_function_pb2.pyi index d0d29ca..6aeafc9 100644 --- a/crossplane/function/proto/v1beta1/run_function_pb2.pyi +++ b/crossplane/function/proto/v1beta1/run_function_pb2.pyi @@ -11,6 +11,11 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor +class DependencyLifecycle(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + DEPENDENCY_LIFECYCLE_UNSPECIFIED: _ClassVar[DependencyLifecycle] + DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY: _ClassVar[DependencyLifecycle] + class Capability(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () CAPABILITY_UNSPECIFIED: _ClassVar[Capability] @@ -19,6 +24,7 @@ class Capability(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): CAPABILITY_CREDENTIALS: _ClassVar[Capability] CAPABILITY_CONDITIONS: _ClassVar[Capability] CAPABILITY_REQUIRED_SCHEMAS: _ClassVar[Capability] + CAPABILITY_DEPENDENCIES: _ClassVar[Capability] class Ready(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -45,12 +51,15 @@ class Status(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): STATUS_CONDITION_UNKNOWN: _ClassVar[Status] STATUS_CONDITION_TRUE: _ClassVar[Status] STATUS_CONDITION_FALSE: _ClassVar[Status] +DEPENDENCY_LIFECYCLE_UNSPECIFIED: DependencyLifecycle +DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY: DependencyLifecycle CAPABILITY_UNSPECIFIED: Capability CAPABILITY_CAPABILITIES: Capability CAPABILITY_REQUIRED_RESOURCES: Capability CAPABILITY_CREDENTIALS: Capability CAPABILITY_CONDITIONS: Capability CAPABILITY_REQUIRED_SCHEMAS: Capability +CAPABILITY_DEPENDENCIES: Capability READY_UNSPECIFIED: Ready READY_TRUE: Ready READY_FALSE: Ready @@ -67,7 +76,7 @@ STATUS_CONDITION_TRUE: Status STATUS_CONDITION_FALSE: Status class RunFunctionRequest(_message.Message): - __slots__ = ("meta", "observed", "desired", "input", "context", "extra_resources", "credentials", "required_resources", "required_schemas") + __slots__ = ("meta", "observed", "desired", "input", "context", "extra_resources", "credentials", "required_resources", "required_schemas", "dependencies") class ExtraResourcesEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -105,6 +114,7 @@ class RunFunctionRequest(_message.Message): CREDENTIALS_FIELD_NUMBER: _ClassVar[int] REQUIRED_RESOURCES_FIELD_NUMBER: _ClassVar[int] REQUIRED_SCHEMAS_FIELD_NUMBER: _ClassVar[int] + DEPENDENCIES_FIELD_NUMBER: _ClassVar[int] meta: RequestMeta observed: State desired: State @@ -114,7 +124,8 @@ class RunFunctionRequest(_message.Message): credentials: _containers.MessageMap[str, Credentials] required_resources: _containers.MessageMap[str, Resources] required_schemas: _containers.MessageMap[str, Schema] - def __init__(self, meta: _Optional[_Union[RequestMeta, _Mapping]] = ..., observed: _Optional[_Union[State, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., extra_resources: _Optional[_Mapping[str, Resources]] = ..., credentials: _Optional[_Mapping[str, Credentials]] = ..., required_resources: _Optional[_Mapping[str, Resources]] = ..., required_schemas: _Optional[_Mapping[str, Schema]] = ...) -> None: ... + dependencies: Dependencies + def __init__(self, meta: _Optional[_Union[RequestMeta, _Mapping]] = ..., observed: _Optional[_Union[State, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., extra_resources: _Optional[_Mapping[str, Resources]] = ..., credentials: _Optional[_Mapping[str, Credentials]] = ..., required_resources: _Optional[_Mapping[str, Resources]] = ..., required_schemas: _Optional[_Mapping[str, Schema]] = ..., dependencies: _Optional[_Union[Dependencies, _Mapping]] = ...) -> None: ... class Credentials(_message.Message): __slots__ = ("credential_data",) @@ -141,8 +152,36 @@ class Resources(_message.Message): items: _containers.RepeatedCompositeFieldContainer[Resource] def __init__(self, items: _Optional[_Iterable[_Union[Resource, _Mapping]]] = ...) -> None: ... +class Dependencies(_message.Message): + __slots__ = ("items",) + ITEMS_FIELD_NUMBER: _ClassVar[int] + items: _containers.RepeatedCompositeFieldContainer[Dependency] + def __init__(self, items: _Optional[_Iterable[_Union[Dependency, _Mapping]]] = ...) -> None: ... + +class Dependency(_message.Message): + __slots__ = ("resource", "composed_resource", "required_resource", "lifecycle") + RESOURCE_FIELD_NUMBER: _ClassVar[int] + COMPOSED_RESOURCE_FIELD_NUMBER: _ClassVar[int] + REQUIRED_RESOURCE_FIELD_NUMBER: _ClassVar[int] + LIFECYCLE_FIELD_NUMBER: _ClassVar[int] + resource: str + composed_resource: str + required_resource: RequiredResourceDependency + lifecycle: DependencyLifecycle + def __init__(self, resource: _Optional[str] = ..., composed_resource: _Optional[str] = ..., required_resource: _Optional[_Union[RequiredResourceDependency, _Mapping]] = ..., lifecycle: _Optional[_Union[DependencyLifecycle, str]] = ...) -> None: ... + +class RequiredResourceDependency(_message.Message): + __slots__ = ("requirement_name", "name", "namespace") + REQUIREMENT_NAME_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + NAMESPACE_FIELD_NUMBER: _ClassVar[int] + requirement_name: str + name: str + namespace: str + def __init__(self, requirement_name: _Optional[str] = ..., name: _Optional[str] = ..., namespace: _Optional[str] = ...) -> None: ... + class RunFunctionResponse(_message.Message): - __slots__ = ("meta", "desired", "results", "context", "requirements", "conditions", "output") + __slots__ = ("meta", "desired", "results", "context", "requirements", "conditions", "output", "dependencies") META_FIELD_NUMBER: _ClassVar[int] DESIRED_FIELD_NUMBER: _ClassVar[int] RESULTS_FIELD_NUMBER: _ClassVar[int] @@ -150,6 +189,7 @@ class RunFunctionResponse(_message.Message): REQUIREMENTS_FIELD_NUMBER: _ClassVar[int] CONDITIONS_FIELD_NUMBER: _ClassVar[int] OUTPUT_FIELD_NUMBER: _ClassVar[int] + DEPENDENCIES_FIELD_NUMBER: _ClassVar[int] meta: ResponseMeta desired: State results: _containers.RepeatedCompositeFieldContainer[Result] @@ -157,7 +197,8 @@ class RunFunctionResponse(_message.Message): requirements: Requirements conditions: _containers.RepeatedCompositeFieldContainer[Condition] output: _struct_pb2.Struct - def __init__(self, meta: _Optional[_Union[ResponseMeta, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., results: _Optional[_Iterable[_Union[Result, _Mapping]]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., requirements: _Optional[_Union[Requirements, _Mapping]] = ..., conditions: _Optional[_Iterable[_Union[Condition, _Mapping]]] = ..., output: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... + dependencies: Dependencies + def __init__(self, meta: _Optional[_Union[ResponseMeta, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., results: _Optional[_Iterable[_Union[Result, _Mapping]]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., requirements: _Optional[_Union[Requirements, _Mapping]] = ..., conditions: _Optional[_Iterable[_Union[Condition, _Mapping]]] = ..., output: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., dependencies: _Optional[_Union[Dependencies, _Mapping]] = ...) -> None: ... class RequestMeta(_message.Message): __slots__ = ("tag", "capabilities") diff --git a/crossplane/function/request.py b/crossplane/function/request.py index e059f16..839a340 100644 --- a/crossplane/function/request.py +++ b/crossplane/function/request.py @@ -224,3 +224,27 @@ def get_required_schema(req: fnv1.RunFunctionRequest, name: str) -> dict | None: return None return resource.struct_to_dict(schema.openapi_v3) + + +def get_dependencies(req: fnv1.RunFunctionRequest) -> list[fnv1.Dependency]: + """Get the ordering constraints accumulated so far in the pipeline. + + Args: + req: The RunFunctionRequest containing dependencies. + + Returns: + The dependencies returned by the functions that ran before this one. + + A dependency declares that one composed resource must be created after, + and deleted before, another resource. It expresses ordering only - it + doesn't move any data between resources. + + A function that has an opinion about ordering must return the full set it + wants going forward, including the edges it received here and still wants. + response.to copies them to the response for you, so most functions can + just add to them: + + rsp = response.to(req) + response.add_dependency(rsp, "database", "network") + """ + return list(req.dependencies.items) diff --git a/crossplane/function/response.py b/crossplane/function/response.py index d886725..4602dea 100644 --- a/crossplane/function/response.py +++ b/crossplane/function/response.py @@ -39,8 +39,15 @@ def to( Returns: A response to the supplied request. - The request's tag, desired resources, and context is automatically copied to - the response. Using response.to is a good pattern to ensure + The request's tag, desired resources, context, and dependencies are + automatically copied to the response. Using response.to is a good pattern + to ensure + + Dependencies are copied only if the request has them. An unset + Dependencies means "no opinion" and tells Crossplane to carry forward the + constraints it already has, while an empty one tells Crossplane to drop + them. Copying an unset field as an empty one would turn the former into + the latter. """ dttl = durationpb.Duration() dttl.FromTimedelta(ttl) @@ -48,6 +55,7 @@ def to( meta=fnv1.ResponseMeta(tag=req.meta.tag, ttl=dttl), desired=req.desired, context=req.context, + dependencies=req.dependencies if req.HasField("dependencies") else None, ) @@ -219,3 +227,108 @@ def require_schema( kind=kind, ) rsp.requirements.schemas[name].CopyFrom(selector) + + +def clear_dependencies(rsp: fnv1.RunFunctionResponse) -> None: + """Declare that no composed resources should be ordered. + + Args: + rsp: The RunFunctionResponse to update. + + This returns an empty set of dependencies, which tells Crossplane to drop + the constraints declared by the functions before this one. It's different + from leaving dependencies unset, which means "no opinion" and carries the + existing constraints forward. + + Note that response.to copies the request's dependencies to the response. + Call this on a response it created to drop them. + """ + rsp.dependencies.Clear() + rsp.dependencies.SetInParent() + + +def add_dependency( + rsp: fnv1.RunFunctionResponse, + resource_name: str, + depends_on: str, + *, + create_before_destroy: bool = False, +) -> None: + """Declare that one composed resource depends on another. + + Args: + rsp: The RunFunctionResponse to update. + resource_name: Name of the composed resource that has the dependency. A key + into the desired or observed state's resources. + depends_on: Name of the composed resource it depends on. Also a key + into the desired or observed state's resources. + create_before_destroy: Let the resource be created without waiting for + what it depends on to be deleted. Use for a replacement that must + exist before its predecessor is torn down. + + By default ordering is symmetric: the resource is created only once what + it depends on is ready, and what it depends on is deleted only once the + resource is gone. + + Dependencies express ordering only. They don't move any data between + resources. + + Remember that a function must return the full set of dependencies it + wants. response.to copies forward the ones the request carried, so build + on a response created by it rather than an empty one. + + Only Crossplane versions that advertise CAPABILITY_DEPENDENCIES honor + dependencies. Use request.has_capability to check before relying on them: + + if request.has_capability(req, fnv1.CAPABILITY_DEPENDENCIES): + response.add_dependency(rsp, "database", "network") + """ + lifecycle = ( + fnv1.DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY + if create_before_destroy + else fnv1.DEPENDENCY_LIFECYCLE_UNSPECIFIED + ) + rsp.dependencies.items.append( + fnv1.Dependency( + resource=resource_name, + composed_resource=depends_on, + lifecycle=lifecycle, + ) + ) + + +def add_required_resource_dependency( + rsp: fnv1.RunFunctionResponse, + resource_name: str, + requirement_name: str, + *, + name: str | None = None, + namespace: str | None = None, +) -> None: + """Declare that a composed resource depends on a required resource. + + Args: + rsp: The RunFunctionResponse to update. + resource_name: Name of the composed resource that has the dependency. A key + into the desired or observed state's resources. + requirement_name: The requirement name, as passed to require_resources. + name: Name of a single resource within the set the requirement + matched. If unset, every matched resource must be ready. + namespace: Namespace of name, for a namespaced resource. Leave unset + for a cluster scoped resource. + + Crossplane never deletes a resource it didn't compose, so a dependency on + a required resource constrains only the order resources are created and + updated, never the order they're deleted. + """ + required = fnv1.RequiredResourceDependency(requirement_name=requirement_name) + + if name is not None: + required.name = name + + if namespace is not None: + required.namespace = namespace + + rsp.dependencies.items.append( + fnv1.Dependency(resource=resource_name, required_resource=required) + ) diff --git a/tests/test_request.py b/tests/test_request.py index f7fdf00..ebd1e2c 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -407,6 +407,52 @@ class TestCase: got = request.get_required_schema(case.req, case.name) self.assertEqual(case.want, got, case.reason) + def test_get_dependencies(self) -> None: + @dataclasses.dataclass + class TestCase: + reason: str + req: fnv1.RunFunctionRequest + want: list[fnv1.Dependency] + + cases = [ + TestCase( + reason="No dependencies should return an empty list.", + req=fnv1.RunFunctionRequest(), + want=[], + ), + TestCase( + reason="Should return the dependencies accumulated so far.", + req=fnv1.RunFunctionRequest( + dependencies=fnv1.Dependencies( + items=[ + fnv1.Dependency( + resource="database", composed_resource="network" + ), + fnv1.Dependency( + resource="database", + required_resource=fnv1.RequiredResourceDependency( + requirement_name="cluster" + ), + ), + ] + ) + ), + want=[ + fnv1.Dependency(resource="database", composed_resource="network"), + fnv1.Dependency( + resource="database", + required_resource=fnv1.RequiredResourceDependency( + requirement_name="cluster" + ), + ), + ], + ), + ] + + for case in cases: + got = request.get_dependencies(case.req) + self.assertEqual(case.want, got, case.reason) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_response.py b/tests/test_response.py index fa8edc9..999909b 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -61,10 +61,75 @@ class TestCase: context=resource.dict_to_struct({"cool-key": "cool-value"}), ), ), + TestCase( + reason="Dependencies should be copied.", + req=fnv1.RunFunctionRequest( + meta=fnv1.RequestMeta(tag="hi"), + dependencies=fnv1.Dependencies( + items=[ + fnv1.Dependency( + resource="database", composed_resource="network" + ), + ] + ), + ), + ttl=datetime.timedelta(minutes=10), + want=fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta( + tag="hi", ttl=durationpb.Duration(seconds=60 * 10) + ), + desired=fnv1.State(), + context=structpb.Struct(), + dependencies=fnv1.Dependencies( + items=[ + fnv1.Dependency( + resource="database", composed_resource="network" + ), + ] + ), + ), + ), + TestCase( + reason="Unset dependencies should stay unset, not become empty.", + req=fnv1.RunFunctionRequest(meta=fnv1.RequestMeta(tag="hi")), + ttl=datetime.timedelta(minutes=10), + want=fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta( + tag="hi", ttl=durationpb.Duration(seconds=60 * 10) + ), + desired=fnv1.State(), + context=structpb.Struct(), + ), + ), + TestCase( + reason="Empty dependencies should stay empty, not become unset.", + req=fnv1.RunFunctionRequest( + meta=fnv1.RequestMeta(tag="hi"), + dependencies=fnv1.Dependencies(), + ), + ttl=datetime.timedelta(minutes=10), + want=fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta( + tag="hi", ttl=durationpb.Duration(seconds=60 * 10) + ), + desired=fnv1.State(), + context=structpb.Struct(), + dependencies=fnv1.Dependencies(), + ), + ), ] for case in cases: got = response.to(case.req, case.ttl) + + # An unset Dependencies means "no opinion". An empty one means + # "no constraints at all". They must not be confused. + self.assertEqual( + case.want.HasField("dependencies"), + got.HasField("dependencies"), + case.reason, + ) + self.assertEqual( json_format.MessageToJson(case.want, sort_keys=True), json_format.MessageToJson(got, sort_keys=True), @@ -338,6 +403,153 @@ class TestCase: case.reason, ) + def test_dependencies(self) -> None: + @dataclasses.dataclass + class TestCase: + reason: str + rsp: fnv1.RunFunctionResponse + mutate: object + want: fnv1.RunFunctionResponse + + def no_opinion(_rsp) -> None: + pass + + def add_composed(rsp) -> None: + response.add_dependency(rsp, "database", "network") + + def add_replacement(rsp) -> None: + response.add_dependency( + rsp, "database-v2", "database", create_before_destroy=True + ) + + def add_required(rsp) -> None: + response.add_required_resource_dependency( + rsp, "database", "cluster", name="prod", namespace="default" + ) + + def add_required_set(rsp) -> None: + response.add_required_resource_dependency(rsp, "database", "cluster") + + def add_to_existing(rsp) -> None: + response.add_dependency(rsp, "cache", "database") + + def clear(rsp) -> None: + response.clear_dependencies(rsp) + + existing = fnv1.Dependency(resource="database", composed_resource="network") + + cases = [ + TestCase( + reason="A response should have no opinion about ordering by default.", + rsp=fnv1.RunFunctionResponse(), + mutate=no_opinion, + want=fnv1.RunFunctionResponse(), + ), + TestCase( + reason="Should add a dependency on another composed resource.", + rsp=fnv1.RunFunctionResponse(), + mutate=add_composed, + want=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies(items=[existing]) + ), + ), + TestCase( + reason="Should add a create before destroy dependency.", + rsp=fnv1.RunFunctionResponse(), + mutate=add_replacement, + want=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies( + items=[ + fnv1.Dependency( + resource="database-v2", + composed_resource="database", + lifecycle=fnv1.DEPENDENCY_LIFECYCLE_CREATE_BEFORE_DESTROY, + ) + ] + ) + ), + ), + TestCase( + reason="Should add a dependency on one required resource.", + rsp=fnv1.RunFunctionResponse(), + mutate=add_required, + want=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies( + items=[ + fnv1.Dependency( + resource="database", + required_resource=fnv1.RequiredResourceDependency( + requirement_name="cluster", + name="prod", + namespace="default", + ), + ) + ] + ) + ), + ), + TestCase( + reason="Should add a dependency on every matched required resource.", + rsp=fnv1.RunFunctionResponse(), + mutate=add_required_set, + want=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies( + items=[ + fnv1.Dependency( + resource="database", + required_resource=fnv1.RequiredResourceDependency( + requirement_name="cluster" + ), + ) + ] + ) + ), + ), + TestCase( + reason="Should add to the dependencies already on the response.", + rsp=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies(items=[existing]) + ), + mutate=add_to_existing, + want=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies( + items=[ + existing, + fnv1.Dependency( + resource="cache", composed_resource="database" + ), + ] + ) + ), + ), + TestCase( + reason="Clearing should return an empty set, not an unset one.", + rsp=fnv1.RunFunctionResponse( + dependencies=fnv1.Dependencies(items=[existing]) + ), + mutate=clear, + want=fnv1.RunFunctionResponse(dependencies=fnv1.Dependencies()), + ), + ] + + for case in cases: + got = case.rsp + case.mutate(got) + + self.assertEqual( + json_format.MessageToJson(case.want, sort_keys=True), + json_format.MessageToJson(got, sort_keys=True), + case.reason, + ) + + # An unset Dependencies means "no opinion". An empty one means + # "no constraints at all". They must not be confused. + self.assertEqual( + case.want.HasField("dependencies"), + got.HasField("dependencies"), + case.reason, + ) + if __name__ == "__main__": unittest.main() From 2d891530ea953e52c17f2ef79a3a7e92d0ab9584 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 07:36:46 +0200 Subject: [PATCH 2/9] Record a dependency when a field refers to another resource 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 --- crossplane/function/reference.py | 541 +++++++++++++++++++++++++++++++ crossplane/function/runtime.py | 36 +- tests/test_reference.py | 487 ++++++++++++++++++++++++++++ 3 files changed, 1062 insertions(+), 2 deletions(-) create mode 100644 crossplane/function/reference.py create mode 100644 tests/test_reference.py diff --git a/crossplane/function/reference.py b/crossplane/function/reference.py new file mode 100644 index 0000000..959ccbc --- /dev/null +++ b/crossplane/function/reference.py @@ -0,0 +1,541 @@ +# Copyright 2026 The Crossplane Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dependencies declared by referring to another resource's fields. + +Providers resolve cross-resource references themselves, through fields like +vpcIdRef and vpcIdSelector that the provider turns into a value at reconcile +time. That works, but Crossplane never sees the relationship: it can't order +anything, and a provider that can't resolve a reference yet retries against +the cloud API until it can. + +Writing the reference as a field value instead makes the relationship +visible. The function says where the value comes from, the SDK records the +dependency, and Crossplane waits rather than letting the provider discover +the problem: + + from crossplane.function import reference, resource + + vpc = reference.named("vpc", VPC) + + subnet = Subnet( + spec={ + "forProvider": { + "region": "us-east-1", + "vpcId": reference.external_name(vpc), # instead of vpcIdSelector + } + } + ) + resource.update(rsp.desired.resources["subnet"], subnet) + + reference.resolve(req, rsp) + +That records that subnet depends on vpc, and fills in the VPC's external name +once it exists. For any other field, read it through the named resource and +wrap it in ref: + + "arn": reference.ref(vpc.status.atProvider.arn), + +A reference is a marker string until resolve replaces it, because a string is +what a model's own validation accepts in a string field. So references work +only in string fields. Anywhere else the model rejects them, which is at +least a loud failure rather than a silent one. +""" + +import json +import types +import typing + +import pydantic +from google.protobuf import struct_pb2 as structpb + +from crossplane.function import request, resource, response +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 + +PREFIX = "${xp-ref:" +"""Starts every reference marker. The SDK's runtime looks for it.""" + +_SUFFIX = "}" + +_EXTERNAL_NAME = "@externalName" +_EXTERNAL_NAME_ANNOTATION = "crossplane.io/external-name" + +_COMPOSED = "composed" +_REQUIRED = "required" + +M = typing.TypeVar("M", bound=pydantic.BaseModel) +V = typing.TypeVar("V") + + +class _Source(typing.NamedTuple): + """The resource a reference points at.""" + + kind: str # _COMPOSED or _REQUIRED. + name: str # A composed resource name, or a requirement name. + resource_name: str | None = None # Required only: one of the matches. + namespace: str | None = None # Required only: namespace of resource_name. + + def describe(self) -> str: + if self.kind == _COMPOSED: + return self.name + s = f"requirement {self.name}" + if self.resource_name: + ns = f"{self.namespace}/" if self.namespace else "" + s += f" ({ns}{self.resource_name})" + return s + + +class _Field: + """A stand-in for a resource, or a field of one, that records its path. + + Reading an attribute or an item returns another stand-in one step deeper. + Given the resource's model it follows the model's own fields, so a typo + fails here rather than producing a reference to nothing, and a field whose + JSON name isn't a valid Python name (from, class) is recorded under its + JSON name. + + It deliberately has no value. Anything that would need one - a truth + test, str(), iteration - raises, because code that branches on a field + that doesn't exist yet would otherwise take the wrong branch silently. + """ + + __slots__ = ("_model", "_path", "_source") + + def __init__(self, source: _Source, path: tuple, model: typing.Any = None): + object.__setattr__(self, "_source", source) + object.__setattr__(self, "_path", path) + object.__setattr__(self, "_model", model) + + def __getattr__(self, attr: str) -> "_Field": + # Dunder lookups come from copy, pickle, pydantic and friends probing + # for protocols. Answering them would make a stand-in look like it + # supports things it doesn't. + if attr.startswith("__"): + raise AttributeError(attr) + + key, model = _field_of(self._model, attr, self._describe()) + return _Field(self._source, (*self._path, key), model) + + def __getitem__(self, key: str | int) -> "_Field": + return _Field(self._source, (*self._path, key), _item_of(self._model)) + + def __setattr__(self, attr: str, value: typing.Any) -> None: + msg = f"cannot set {attr} on {self._describe()}: references are read-only" + raise AttributeError(msg) + + def __bool__(self) -> bool: + msg = ( + f"{self._describe()} has no value yet. Wrap it in reference.ref() to " + "assign it; read the observed resource instead to branch on it." + ) + raise TypeError(msg) + + def __iter__(self) -> typing.NoReturn: + msg = f"cannot iterate {self._describe()}; index it instead, e.g. [0]" + raise TypeError(msg) + + def __str__(self) -> str: + msg = f"{self._describe()} has no value yet. Wrap it in reference.ref()." + raise TypeError(msg) + + def __repr__(self) -> str: + return f"" + + def _describe(self) -> str: + path = "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in self._path) + return f"{self._source.describe()}{path}" + + +def _unwrap(annotation: typing.Any) -> typing.Any: + """Strip Optional, unions with None, and Annotated from a field's type.""" + while True: + origin = typing.get_origin(annotation) + if origin is typing.Annotated: + annotation = typing.get_args(annotation)[0] + continue + if origin in (typing.Union, types.UnionType): + args = [a for a in typing.get_args(annotation) if a is not type(None)] + if len(args) == 1: + annotation = args[0] + continue + return annotation + + +def _is_model(t: typing.Any) -> bool: + return isinstance(t, type) and issubclass(t, pydantic.BaseModel) + + +def _field_of(model: typing.Any, attr: str, where: str) -> tuple[str, typing.Any]: + """Return the JSON key for attr on model, and the type of that field. + + Without a model there is nothing to check against, so the attribute is the + key and the result is untyped. + """ + if not _is_model(model): + return attr, None + + fields = model.model_fields + if attr in fields: + info = fields[attr] + return info.alias or attr, _unwrap(info.annotation) + + # Accept the JSON name too, so from and from_ both work. + for info in fields.values(): + if info.alias == attr: + return attr, _unwrap(info.annotation) + + msg = f"{where} has no field {attr!r}; {model.__name__} has {', '.join(fields)}" + raise AttributeError(msg) + + +def _item_of(model: typing.Any) -> typing.Any: + """Return the element type of a list or dict type, if it has one.""" + origin, args = typing.get_origin(model), typing.get_args(model) + if origin is list and args: + return _unwrap(args[0]) + if origin is dict and len(args) == 2: # noqa: PLR2004 + return _unwrap(args[1]) + return None + + +@typing.overload +def named(name: str, model: type[M]) -> M: ... +@typing.overload +def named(name: str, model: None = None) -> typing.Any: ... +def named(name: str, model: type[M] | None = None) -> typing.Any: + """Name a composed resource so its fields can be referenced. + + Args: + name: The composed resource's name, the key into desired and observed + state. + model: The resource's generated model, if it has one. Fields are then + checked against it as they're read, and editors can complete them. + + Returns: + A stand-in that reads like the resource itself: + + vpc = reference.named("vpc", VPC) + "vpcId": reference.ref(vpc.status.atProvider.id), + """ + return _Field(_Source(_COMPOSED, name), (), model) + + +@typing.overload +def named_required( + requirement_name: str, + model: type[M], + *, + name: str | None = None, + namespace: str | None = None, +) -> M: ... +@typing.overload +def named_required( + requirement_name: str, + model: None = None, + *, + name: str | None = None, + namespace: str | None = None, +) -> typing.Any: ... +def named_required( + requirement_name: str, + model: type[M] | None = None, + *, + name: str | None = None, + namespace: str | None = None, +) -> typing.Any: + """Name a resource the function requires but doesn't compose. + + Args: + requirement_name: The requirement name, as passed to + response.require_resources. + model: The resource's generated model, if it has one. + name: Name of one resource the requirement matched. Needed when it + can match more than one; a reference declines to guess. + namespace: Namespace of name, for a namespaced resource. + + Returns: + A stand-in that reads like the resource itself. + + Crossplane never deletes a resource it didn't compose, so the dependency + this records orders only creation and updates. + """ + return _Field(_Source(_REQUIRED, requirement_name, name, namespace), (), model) + + +def ref(value: V) -> V: + """Turn a field of a named resource into a reference you can assign. + + Args: + value: A field read through a stand-in from named or named_required, + for example vpc.status.atProvider.id. + + Returns: + A marker string that resolve replaces with the field's value. It's + typed as the field is, so it goes wherever that type is expected. + + Raises: + TypeError: value isn't a field of a named resource. That would + otherwise silently record no dependency at all. + ValueError: value is the whole resource rather than a field of it. + """ + if not isinstance(value, _Field): + msg = ( + "ref() expects a field of a resource from named() or named_required(), " + f"for example ref(vpc.status.atProvider.id), not {type(value).__name__}" + ) + raise TypeError(msg) + if not value._path: + msg = ( + f"ref() needs a field, not the whole {value._describe()} resource. " + "Use external_name() to reference its external name." + ) + raise ValueError(msg) + return typing.cast(V, _encode(value._source, list(value._path))) + + +def external_name(named_resource: typing.Any) -> str: + """Reference a resource's external name, its identifier outside Kubernetes. + + Args: + named_resource: A stand-in from named or named_required. + + Returns: + A marker string that resolve replaces with the external name. + + This is what a provider's own somethingIdRef would have resolved to, so it + directly replaces a reference or selector field: + + "vpcId": reference.external_name(vpc), # instead of vpcIdSelector + + Raises: + TypeError: named_resource isn't a stand-in for a resource. + """ + if not isinstance(named_resource, _Field) or named_resource._path: + msg = "external_name() expects a resource from named() or named_required()" + raise TypeError(msg) + return _encode(named_resource._source, [_EXTERNAL_NAME]) + + +def _encode(source: _Source, path: list) -> str: + # JSON rather than a dotted path, because keys like crossplane.io/name + # contain dots and lists are indexed by number. + body = json.dumps([*source, path], separators=(",", ":")) + return f"{PREFIX}{body}{_SUFFIX}" + + +def _decode(v: typing.Any) -> tuple[_Source, list] | None: + if not isinstance(v, str) or not v.startswith(PREFIX) or not v.endswith(_SUFFIX): + return None + try: + kind, name, resource_name, namespace, path = json.loads( + v[len(PREFIX) : -len(_SUFFIX)] + ) + except (ValueError, TypeError): + return None + return _Source(kind, name, resource_name, namespace), path + + +_MISSING = object() + + +def _value_at(obj: typing.Any, path: list) -> typing.Any: + for key in path: + if isinstance(key, int): + if not isinstance(obj, list) or not -len(obj) <= key < len(obj): + return _MISSING + obj = obj[key] + elif isinstance(obj, dict) and key in obj: + obj = obj[key] + else: + return _MISSING + return obj + + +class _Resolver: + """Resolves the references in one desired composed resource.""" + + def __init__(self, req: fnv1.RunFunctionRequest, name: str): + self.req = req + self.name = name + self.sources: set[_Source] = set() + self.unresolved = False + + # The dependent as it was last observed, if it exists. A reference + # whose source has gone falls back to the value the dependent already + # has, rather than dropping the field and having the apply unset it. + self.observed = _MISSING + if name in req.observed.resources: + self.observed = resource.struct_to_dict( + req.observed.resources[name].resource + ) + + def source(self, s: _Source) -> typing.Any: + if s.kind == _COMPOSED: + if s.name not in self.req.observed.resources: + return _MISSING + return resource.struct_to_dict(self.req.observed.resources[s.name].resource) + + items = request.get_required_resources(self.req, s.name) + if s.resource_name is None: + # With several matches and nothing to pick by there is no right + # answer, so decline rather than guess. + return items[0] if len(items) == 1 else _MISSING + for item in items: + meta = item.get("metadata", {}) + if meta.get("name") == s.resource_name and ( + s.namespace is None or meta.get("namespace") == s.namespace + ): + return item + return _MISSING + + def value(self, marker: tuple[_Source, list], at: list) -> typing.Any: + s, path = marker + self.sources.add(s) + + src = self.source(s) + if src is not _MISSING: + if path == [_EXTERNAL_NAME]: + v = _value_at( + src, ["metadata", "annotations", _EXTERNAL_NAME_ANNOTATION] + ) + if v is _MISSING: + v = _value_at(src, ["metadata", "name"]) + else: + v = _value_at(src, path) + if v is not _MISSING and v is not None: + return v + + kept = _value_at(self.observed, at) + if kept is not _MISSING: + return kept + + self.unresolved = True + return _MISSING + + def walk(self, v: typing.Any, at: list) -> typing.Any: + marker = _decode(v) + if marker is not None: + return self.value(marker, at) + if isinstance(v, dict): + out = {} + for k, child in v.items(): + r = self.walk(child, [*at, k]) + # Leave out a field whose value isn't available yet rather + # than sending a null a provider would try to apply. + if r is not _MISSING: + out[k] = r + return out + if isinstance(v, list): + walked = (self.walk(child, [*at, i]) for i, child in enumerate(v)) + return [r for r in walked if r is not _MISSING] + return v + + +def resolve(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) -> None: + """Replace references in desired state, and record the dependencies. + + Args: + req: The RunFunctionRequest, whose observed and required resources the + references are resolved against. + rsp: The RunFunctionResponse to update. Call this once desired state is + complete, just before returning it. + + Every desired composed resource that refers to another gets a dependency + on it, alongside any already declared. A reference whose value isn't + available yet is left out, and the dependency means Crossplane doesn't + create the resource until it is. A resource that already exists keeps the + value it has. + + A Crossplane that doesn't advertise CAPABILITY_DEPENDENCIES ignores the + dependencies, and would create a resource with the field missing. So + there, a resource with a reference that can't be resolved yet is left out + of desired state until it can. + """ + ordered = request.has_capability(req, fnv1.CAPABILITY_DEPENDENCIES) + declared = { + (d.resource, d.WhichOneof("depends_on"), _target(d)) + for d in rsp.dependencies.items + } + + for name in list(rsp.desired.resources): + r = rsp.desired.resources[name] + if not _contains_marker(r.resource): + continue + + resolver = _Resolver(req, name) + body = resolver.walk(resource.struct_to_dict(r.resource), []) + + if resolver.unresolved and not ordered and resolver.observed is _MISSING: + del rsp.desired.resources[name] + continue + + r.resource.CopyFrom(resource.dict_to_struct(body)) + + for s in sorted(resolver.sources, key=lambda s: tuple(p or "" for p in s)): + if s.kind == _COMPOSED: + if s.name == name or (name, "composed_resource", s.name) in declared: + continue + response.add_dependency(rsp, name, s.name) + declared.add((name, "composed_resource", s.name)) + continue + + key = (name, "required_resource", (s.name, s.resource_name, s.namespace)) + if key in declared: + continue + response.add_required_resource_dependency( + rsp, name, s.name, name=s.resource_name, namespace=s.namespace + ) + declared.add(key) + + +def _target(d: fnv1.Dependency) -> typing.Any: + if d.WhichOneof("depends_on") == "required_resource": + r = d.required_resource + return ( + r.requirement_name, + r.name if r.HasField("name") else None, + r.namespace if r.HasField("namespace") else None, + ) + return d.composed_resource + + +def unresolved(rsp: fnv1.RunFunctionResponse) -> list[str]: + """Return the desired composed resources that still contain references. + + Args: + rsp: The RunFunctionResponse to check. + + Returns: + The names of desired composed resources with a reference in them, + which means resolve wasn't called after they were built. + """ + return [ + name + for name, r in rsp.desired.resources.items() + if _contains_marker(r.resource) + ] + + +def _contains_marker(s: structpb.Struct) -> bool: + def walk(v: structpb.Value) -> bool: + kind = v.WhichOneof("kind") + if kind == "string_value": + return v.string_value.startswith(PREFIX) + if kind == "struct_value": + return any(walk(x) for x in v.struct_value.fields.values()) + if kind == "list_value": + return any(walk(x) for x in v.list_value.values) + return False + + return any(walk(v) for v in s.fields.values()) diff --git a/crossplane/function/runtime.py b/crossplane/function/runtime.py index a6e465e..f93eee4 100644 --- a/crossplane/function/runtime.py +++ b/crossplane/function/runtime.py @@ -27,6 +27,7 @@ import crossplane.function.proto.v1.run_function_pb2_grpc as grpcv1 import crossplane.function.proto.v1beta1.run_function_pb2 as fnv1beta1 import crossplane.function.proto.v1beta1.run_function_pb2_grpc as grpcv1beta1 +from crossplane.function import reference, response SERVICE_NAMES = ( reflection.SERVICE_NAME, @@ -107,9 +108,10 @@ def serve( lambda: asyncio.ensure_future(server.stop(grace=SHUTDOWN_GRACE_PERIOD_SECONDS)), ) - grpcv1.add_FunctionRunnerServiceServicer_to_server(function, server) + guarded = ReferenceGuard(wrapped=function) + grpcv1.add_FunctionRunnerServiceServicer_to_server(guarded, server) grpcv1beta1.add_FunctionRunnerServiceServicer_to_server( - BetaFunctionRunner(wrapped=function), server + BetaFunctionRunner(wrapped=guarded), server ) reflection.enable_server_reflection(SERVICE_NAMES, server) @@ -137,6 +139,36 @@ async def start(): loop.close() +class ReferenceGuard(grpcv1.FunctionRunnerServiceServicer): + """A ReferenceGuard fails a response that still contains references. + + A reference from crossplane.function.reference is a marker string until + reference.resolve replaces it. A function that forgets to call resolve + would otherwise send the marker to the cluster as the field's value. + """ + + def __init__(self, wrapped: grpcv1.FunctionRunnerServiceServicer): + """Create a new ReferenceGuard.""" + self.wrapped = wrapped + + async def RunFunction( # noqa: N802 # gRPC requires this name. # pyright: ignore[reportIncompatibleMethodOverride] + self, req: fnv1.RunFunctionRequest, context: grpc.aio.ServicerContext + ) -> fnv1.RunFunctionResponse: + """Run the underlying function, then check its desired state.""" + rsp = await self.wrapped.RunFunction(req, context) + + names = reference.unresolved(rsp) + if names: + response.fatal( + rsp, + f"desired composed resources {', '.join(sorted(names))} contain " + "unresolved references; call reference.resolve(req, rsp) " + "before returning", + ) + + return rsp + + class BetaFunctionRunner(grpcv1beta1.FunctionRunnerServiceServicer): """A BetaFunctionRunner handles beta gRPC RunFunctionRequests. diff --git a/tests/test_reference.py b/tests/test_reference.py new file mode 100644 index 0000000..5c39815 --- /dev/null +++ b/tests/test_reference.py @@ -0,0 +1,487 @@ +# Copyright 2026 The Crossplane Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import unittest + +import pydantic +from google.protobuf import json_format + +from crossplane.function import logging, reference, resource, response, runtime +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 + +# Models shaped the way datamodel-codegen generates them from CRDs: JSON names +# as attribute names, and an alias only where the JSON name is a Python +# keyword. + + +class Metadata(pydantic.BaseModel): + name: str | None = None + annotations: dict[str, str] | None = None + + +class AtProvider(pydantic.BaseModel): + id: str | None = None + arn: str | None = None + + +class VPCStatus(pydantic.BaseModel): + atProvider: AtProvider | None = None # noqa: N815 # Generated models use JSON names. + + +class VPC(pydantic.BaseModel): + metadata: Metadata | None = None + status: VPCStatus | None = None + + +class Range(pydantic.BaseModel): + from_: str | None = pydantic.Field(None, alias="from") + + +class Pool(pydantic.BaseModel): + ranges: list[Range] | None = None + + +class SubnetForProvider(pydantic.BaseModel): + region: str | None = None + vpcId: str | None = None # noqa: N815 # Generated models use JSON names. + tags: list[str] | None = None + + +class SubnetSpec(pydantic.BaseModel): + forProvider: SubnetForProvider # noqa: N815 # Generated models use JSON names. + + +class Subnet(pydantic.BaseModel): + apiVersion: str = "ec2.aws.m.upbound.io/v1beta1" # noqa: N815 # Generated models use JSON names. + kind: str = "Subnet" + spec: SubnetSpec + + +def observed(**resources: dict) -> fnv1.State: + return fnv1.State( + resources={ + name: fnv1.Resource(resource=resource.dict_to_struct(body)) + for name, body in resources.items() + } + ) + + +VPC_OBSERVED = { + "apiVersion": "ec2.aws.m.upbound.io/v1beta1", + "kind": "VPC", + "metadata": { + "name": "xr-vpc-abc12", + "annotations": {"crossplane.io/external-name": "vpc-0123"}, + }, + "status": {"atProvider": {"id": "vpc-0123", "arn": "arn:aws:ec2:vpc/vpc-0123"}}, +} + +ORDERED = fnv1.RequestMeta( + capabilities=[fnv1.CAPABILITY_CAPABILITIES, fnv1.CAPABILITY_DEPENDENCIES] +) +UNORDERED = fnv1.RequestMeta(capabilities=[fnv1.CAPABILITY_CAPABILITIES]) + + +def desired_subnet(rsp: fnv1.RunFunctionResponse, vpc_id: str) -> None: + subnet = Subnet( + spec=SubnetSpec(forProvider=SubnetForProvider(region="us-east-1", vpcId=vpc_id)) + ) + resource.update(rsp.desired.resources["subnet"], subnet) + + +def edges(rsp: fnv1.RunFunctionResponse) -> list[dict]: + return [ + json_format.MessageToDict(d, preserving_proto_field_name=True) + for d in rsp.dependencies.items + ] + + +def body(rsp: fnv1.RunFunctionResponse, name: str) -> dict: + return resource.struct_to_dict(rsp.desired.resources[name].resource) + + +class TestNamed(unittest.TestCase): + def test_follows_model_fields(self) -> None: + vpc = reference.named("vpc", VPC) + marker = reference.ref(vpc.status.atProvider.arn) + self.assertTrue(marker.startswith(reference.PREFIX)) + self.assertIn('["status","atProvider","arn"]', marker) + + def test_typo_fails_where_it_is_written(self) -> None: + vpc = reference.named("vpc", VPC) + with self.assertRaisesRegex( + AttributeError, "vpc.status has no field 'atprovider'" + ): + _ = vpc.status.atprovider + + def test_keyword_field_is_recorded_under_its_json_name(self) -> None: + pool = reference.named("pool", Pool) + for field in (pool.ranges[0].from_, pool.ranges[0]["from"]): + self.assertIn('["ranges",0,"from"]', reference.ref(field)) + + def test_json_name_is_accepted_too(self) -> None: + pool = reference.named("pool", Pool) + self.assertIn('"from"', reference.ref(getattr(pool.ranges[0], "from"))) + + def test_untyped_records_what_it_is_given(self) -> None: + vpc = reference.named("vpc") + marker = reference.ref(vpc.metadata.annotations["crossplane.io/name"]) + self.assertIn('["metadata","annotations","crossplane.io/name"]', marker) + + def test_has_no_value(self) -> None: + vpc = reference.named("vpc", VPC) + field = vpc.status.atProvider.id + with self.assertRaisesRegex(TypeError, "has no value yet"): + bool(field) + with self.assertRaisesRegex(TypeError, "has no value yet"): + str(field) + with self.assertRaises(TypeError): + iter(field) + with self.assertRaises(AttributeError): + field.id = "x" + + def test_ref_rejects_values(self) -> None: + with self.assertRaisesRegex(TypeError, "expects a field"): + reference.ref("vpc-0123") + + def test_ref_rejects_whole_resource(self) -> None: + with self.assertRaisesRegex(ValueError, "external_name"): + reference.ref(reference.named("vpc", VPC)) + + def test_external_name_rejects_fields(self) -> None: + vpc = reference.named("vpc", VPC) + with self.assertRaises(TypeError): + reference.external_name(vpc.status) + + def test_marker_survives_model_validation(self) -> None: + vpc = reference.named("vpc", VPC) + marker = reference.ref(vpc.status.atProvider.id) + subnet = SubnetForProvider(vpcId=marker) + self.assertEqual(subnet.vpcId, marker) + + def test_forgetting_ref_fails_validation(self) -> None: + vpc = reference.named("vpc", VPC) + with self.assertRaises(pydantic.ValidationError): + SubnetForProvider(vpcId=vpc.status.atProvider.id) + + +class TestResolve(unittest.TestCase): + def setUp(self) -> None: + logging.configure(level=logging.Level.DISABLED) + + def test_substitutes_and_records_edge(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" + ) + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + + def test_external_name(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + desired_subnet(rsp, reference.external_name(reference.named("vpc"))) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" + ) + + def test_external_name_falls_back_to_name(self) -> None: + vpc = {"metadata": {"name": "xr-vpc-abc12"}} + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc)) + rsp = response.to(req) + desired_subnet(rsp, reference.external_name(reference.named("vpc"))) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "xr-vpc-abc12" + ) + + def test_missing_source_drops_field_and_keeps_edge(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"], {"region": "us-east-1"} + ) + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + + def test_missing_field_on_existing_source_drops_field(self) -> None: + vpc = {"metadata": {"name": "xr-vpc-abc12"}} # No status yet. + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc)) + rsp = response.to(req) + desired_subnet( + rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) + ) + + reference.resolve(req, rsp) + + self.assertNotIn("vpcId", body(rsp, "subnet")["spec"]["forProvider"]) + + def test_existing_dependent_keeps_its_value(self) -> None: + subnet = {"spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}}} + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(subnet=subnet)) + rsp = response.to(req) + desired_subnet( + rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) + ) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" + ) + + def test_list_elements(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + gone = reference.named("gone", VPC) + resource.update( + rsp.desired.resources["subnet"], + { + "spec": { + "forProvider": { + "tags": [ + "static", + reference.ref(vpc.status.atProvider.arn), + reference.ref(gone.status.atProvider.arn), + ] + } + } + }, + ) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"]["tags"], + ["static", "arn:aws:ec2:vpc/vpc-0123"], + ) + self.assertEqual( + edges(rsp), + [ + {"resource": "subnet", "composed_resource": "gone"}, + {"resource": "subnet", "composed_resource": "vpc"}, + ], + ) + + def test_does_not_duplicate_declared_edges(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + response.add_dependency(rsp, "subnet", "vpc") + vpc = reference.named("vpc", VPC) + resource.update( + rsp.desired.resources["subnet"], + { + "a": reference.ref(vpc.status.atProvider.id), + "b": reference.ref(vpc.status.atProvider.arn), + }, + ) + + reference.resolve(req, rsp) + + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + + def test_self_reference_records_no_edge(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + resource.update( + rsp.desired.resources["vpc"], + {"tag": reference.ref(vpc.status.atProvider.id)}, + ) + + reference.resolve(req, rsp) + + self.assertEqual(edges(rsp), []) + + def test_without_capability_holds_back_by_omission(self) -> None: + req = fnv1.RunFunctionRequest(meta=UNORDERED) + rsp = response.to(req) + desired_subnet( + rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) + ) + + reference.resolve(req, rsp) + + self.assertNotIn("subnet", rsp.desired.resources) + + def test_without_capability_resolved_resources_stay(self) -> None: + req = fnv1.RunFunctionRequest( + meta=UNORDERED, observed=observed(vpc=VPC_OBSERVED) + ) + rsp = response.to(req) + desired_subnet( + rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) + ) + + reference.resolve(req, rsp) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" + ) + + def test_leaves_resources_without_references_alone(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED) + rsp = response.to(req) + resource.update(rsp.desired.resources["plain"], {"spec": {"a": 1}}) + before = rsp.SerializeToString() + + reference.resolve(req, rsp) + + self.assertEqual(rsp.SerializeToString(), before) + + +class TestResolveRequired(unittest.TestCase): + def setUp(self) -> None: + logging.configure(level=logging.Level.DISABLED) + + def req(self, *items: dict) -> fnv1.RunFunctionRequest: + return fnv1.RunFunctionRequest( + meta=ORDERED, + required_resources={ + "dbs": fnv1.Resources( + items=[ + fnv1.Resource(resource=resource.dict_to_struct(i)) + for i in items + ] + ) + }, + ) + + def cm(self, rsp: fnv1.RunFunctionResponse, value: str) -> None: + resource.update( + rsp.desired.resources["app-config"], {"data": {"DB_HOST": value}} + ) + + def test_single_match(self) -> None: + req = self.req({"metadata": {"name": "a"}, "status": {"address": "db.a"}}) + rsp = response.to(req) + self.cm(rsp, reference.ref(reference.named_required("dbs").status.address)) + + reference.resolve(req, rsp) + + self.assertEqual(body(rsp, "app-config")["data"]["DB_HOST"], "db.a") + self.assertEqual( + edges(rsp), + [ + { + "resource": "app-config", + "required_resource": {"requirement_name": "dbs"}, + } + ], + ) + + def test_named_match(self) -> None: + req = self.req( + { + "metadata": {"name": "a", "namespace": "x"}, + "status": {"address": "db.xa"}, + }, + { + "metadata": {"name": "a", "namespace": "y"}, + "status": {"address": "db.ya"}, + }, + ) + rsp = response.to(req) + db = reference.named_required("dbs", name="a", namespace="y") + self.cm(rsp, reference.ref(db.status.address)) + + reference.resolve(req, rsp) + + self.assertEqual(body(rsp, "app-config")["data"]["DB_HOST"], "db.ya") + self.assertEqual( + edges(rsp)[0]["required_resource"], + {"requirement_name": "dbs", "name": "a", "namespace": "y"}, + ) + + def test_declines_to_guess_between_matches(self) -> None: + req = self.req( + {"metadata": {"name": "a"}, "status": {"address": "db.a"}}, + {"metadata": {"name": "b"}, "status": {"address": "db.b"}}, + ) + rsp = response.to(req) + self.cm(rsp, reference.ref(reference.named_required("dbs").status.address)) + + reference.resolve(req, rsp) + + self.assertEqual(body(rsp, "app-config"), {"data": {}}) + + +class TestGuard(unittest.TestCase): + def setUp(self) -> None: + logging.configure(level=logging.Level.DISABLED) + + def test_unresolved(self) -> None: + rsp = fnv1.RunFunctionResponse() + desired_subnet( + rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) + ) + resource.update(rsp.desired.resources["plain"], {"a": "b"}) + + self.assertEqual(reference.unresolved(rsp), ["subnet"]) + + def test_runtime_fails_a_forgotten_resolve(self) -> None: + class Forgetful: + async def RunFunction(self, req, _context): # noqa: N802 + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) + return rsp + + guard = runtime.ReferenceGuard(wrapped=Forgetful()) + rsp = asyncio.run(guard.RunFunction(fnv1.RunFunctionRequest(), None)) + + self.assertEqual(len(rsp.results), 1) + self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_FATAL) + self.assertIn("reference.resolve", rsp.results[0].message) + + def test_runtime_passes_a_resolved_response(self) -> None: + class Careful: + async def RunFunction(self, req, _context): # noqa: N802 + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) + reference.resolve(req, rsp) + return rsp + + guard = runtime.ReferenceGuard(wrapped=Careful()) + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = asyncio.run(guard.RunFunction(req, None)) + + self.assertEqual(len(rsp.results), 0) + + +if __name__ == "__main__": + unittest.main() From b8ca8e0214fc1772ce6fb7088adb137baff6bdff Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 08:00:42 +0200 Subject: [PATCH 3/9] 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 --- crossplane/function/reference.py | 329 ++++++++++++++++++++++++++----- tests/test_reference.py | 200 +++++++++++++++++++ 2 files changed, 478 insertions(+), 51 deletions(-) diff --git a/crossplane/function/reference.py b/crossplane/function/reference.py index 959ccbc..acf3774 100644 --- a/crossplane/function/reference.py +++ b/crossplane/function/reference.py @@ -51,8 +51,31 @@ what a model's own validation accepts in a string field. So references work only in string fields. Anywhere else the model rejects them, which is at least a loud failure rather than a silent one. + +There is a second way to use the same named resources. composing scopes the +work to one composed resource, so a reference can resolve as soon as it's +read rather than being marked for later: + + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + c.update( + Subnet( + spec={ + "forProvider": { + "region": "us-east-1", + "vpcId": c.external_name(vpc), + } + } + ) + ) + +c.external_name and c.ref return the value itself, or None while it isn't +available, so they work in fields of any type and there is nothing to +resolve afterwards. The dependencies are recorded when the block exits. """ +import contextlib import json import types import typing @@ -362,6 +385,65 @@ def _value_at(obj: typing.Any, path: list) -> typing.Any: return obj +def _lookup(req: fnv1.RunFunctionRequest, s: _Source) -> typing.Any: + """Return the resource a reference points at, or _MISSING.""" + if s.kind == _COMPOSED: + if s.name not in req.observed.resources: + return _MISSING + return resource.struct_to_dict(req.observed.resources[s.name].resource) + + items = request.get_required_resources(req, s.name) + if s.resource_name is None: + # With several matches and nothing to pick by there is no right + # answer, so decline rather than guess. + return items[0] if len(items) == 1 else _MISSING + for item in items: + meta = item.get("metadata", {}) + if meta.get("name") == s.resource_name and ( + s.namespace is None or meta.get("namespace") == s.namespace + ): + return item + return _MISSING + + +def _read(req: fnv1.RunFunctionRequest, s: _Source, path: list) -> typing.Any: + """Return the value a reference points at, or _MISSING if there isn't one.""" + src = _lookup(req, s) + if src is _MISSING: + return _MISSING + if path == [_EXTERNAL_NAME]: + v = _value_at(src, ["metadata", "annotations", _EXTERNAL_NAME_ANNOTATION]) + if v is _MISSING: + v = _value_at(src, ["metadata", "name"]) + else: + v = _value_at(src, path) + return _MISSING if v is None else v + + +def _record( + rsp: fnv1.RunFunctionResponse, name: str, sources: typing.Iterable[_Source] +) -> None: + """Add a dependency of name on each source, skipping any already declared.""" + declared = { + (d.resource, d.WhichOneof("depends_on"), _target(d)) + for d in rsp.dependencies.items + } + for s in sorted(sources, key=lambda s: tuple(p or "" for p in s)): + if s.kind == _COMPOSED: + key = (name, "composed_resource", s.name) + if s.name == name or key in declared: + continue + response.add_dependency(rsp, name, s.name) + else: + key = (name, "required_resource", (s.name, s.resource_name, s.namespace)) + if key in declared: + continue + response.add_required_resource_dependency( + rsp, name, s.name, name=s.resource_name, namespace=s.namespace + ) + declared.add(key) + + class _Resolver: """Resolves the references in one desired composed resource.""" @@ -380,41 +462,13 @@ def __init__(self, req: fnv1.RunFunctionRequest, name: str): req.observed.resources[name].resource ) - def source(self, s: _Source) -> typing.Any: - if s.kind == _COMPOSED: - if s.name not in self.req.observed.resources: - return _MISSING - return resource.struct_to_dict(self.req.observed.resources[s.name].resource) - - items = request.get_required_resources(self.req, s.name) - if s.resource_name is None: - # With several matches and nothing to pick by there is no right - # answer, so decline rather than guess. - return items[0] if len(items) == 1 else _MISSING - for item in items: - meta = item.get("metadata", {}) - if meta.get("name") == s.resource_name and ( - s.namespace is None or meta.get("namespace") == s.namespace - ): - return item - return _MISSING - def value(self, marker: tuple[_Source, list], at: list) -> typing.Any: s, path = marker self.sources.add(s) - src = self.source(s) - if src is not _MISSING: - if path == [_EXTERNAL_NAME]: - v = _value_at( - src, ["metadata", "annotations", _EXTERNAL_NAME_ANNOTATION] - ) - if v is _MISSING: - v = _value_at(src, ["metadata", "name"]) - else: - v = _value_at(src, path) - if v is not _MISSING and v is not None: - return v + v = _read(self.req, s, path) + if v is not _MISSING: + return v kept = _value_at(self.observed, at) if kept is not _MISSING: @@ -463,10 +517,6 @@ def resolve(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) -> None of desired state until it can. """ ordered = request.has_capability(req, fnv1.CAPABILITY_DEPENDENCIES) - declared = { - (d.resource, d.WhichOneof("depends_on"), _target(d)) - for d in rsp.dependencies.items - } for name in list(rsp.desired.resources): r = rsp.desired.resources[name] @@ -481,22 +531,7 @@ def resolve(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) -> None continue r.resource.CopyFrom(resource.dict_to_struct(body)) - - for s in sorted(resolver.sources, key=lambda s: tuple(p or "" for p in s)): - if s.kind == _COMPOSED: - if s.name == name or (name, "composed_resource", s.name) in declared: - continue - response.add_dependency(rsp, name, s.name) - declared.add((name, "composed_resource", s.name)) - continue - - key = (name, "required_resource", (s.name, s.resource_name, s.namespace)) - if key in declared: - continue - response.add_required_resource_dependency( - rsp, name, s.name, name=s.resource_name, namespace=s.namespace - ) - declared.add(key) + _record(rsp, name, resolver.sources) def _target(d: fnv1.Dependency) -> typing.Any: @@ -539,3 +574,195 @@ def walk(v: structpb.Value) -> bool: return False return any(walk(v) for v in s.fields.values()) + + +class Scope: + """Resolves references for one composed resource as they're read. + + Get one from composing. Where ref and external_name at module level + return marker strings for resolve to replace later, a Scope's return the + value itself, because the scope already knows which resource is asking. + That means references work in fields of any type, not only strings, and + there's nothing left to resolve once the scope closes. + """ + + def __init__( + self, req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse, name: str + ): + """Create a Scope. Use composing rather than calling this directly.""" + self.req = req + self.rsp = rsp + self.name = name + self._sources: set[_Source] = set() + self._unresolved: list[str] = [] + + def ref(self, value: V) -> V: + """Read a field of a named resource, and depend on that resource. + + Args: + value: A field read through a stand-in from named or + named_required, for example vpc.status.atProvider.id. + + Returns: + The field's observed value, or None if it isn't available yet. + None leaves the field out when the model is written to desired + state, and the dependency means Crossplane doesn't create this + resource until the value exists. + + Raises: + TypeError: value isn't a field of a named resource. + ValueError: value is the whole resource rather than a field of it. + """ + marker = _decode(ref(value)) + assert marker is not None # noqa: S101 # ref always returns a marker. + return typing.cast(V, self._get(*marker, value._describe())) + + def external_name(self, named_resource: typing.Any) -> str | None: + """Read a named resource's external name, and depend on that resource. + + Args: + named_resource: A stand-in from named or named_required. + + Returns: + The external name, or None if the resource doesn't exist yet. + + Raises: + TypeError: named_resource isn't a stand-in for a resource. + """ + marker = _decode(external_name(named_resource)) + assert marker is not None # noqa: S101 # external_name always returns one. + return self._get(*marker, f"{named_resource._describe()} external name") + + def update(self, source: dict | structpb.Struct | pydantic.BaseModel) -> None: + """Write this scope's composed resource to desired state. + + Args: + source: The resource, as resource.update accepts it. + + Unlike resource.update, this leaves out fields whose value is None. In + a scope, None is what ref returns for a value that isn't available + yet, and sending it would ask the API server to clear the field. Use + resource.update directly to send an explicit null. + """ + r = self.rsp.desired.resources[self.name] + match source: + case pydantic.BaseModel(): + data = source.model_dump( + exclude_unset=True, exclude_none=True, by_alias=True, warnings=False + ) + # As resource.update: identify the resource even though + # apiVersion and kind are rarely set explicitly. + data["apiVersion"] = source.apiVersion + data["kind"] = source.kind + resource.update(r, data) + case structpb.Struct(): + resource.update(r, _without_none(resource.struct_to_dict(source))) + case dict(): + resource.update(r, _without_none(source)) + case _: + resource.update(r, source) + + def _get(self, s: _Source, path: list, what: str) -> typing.Any: + self._sources.add(s) + v = _read(self.req, s, path) + if v is _MISSING: + self._unresolved.append(what) + return None + return v + + def _close(self) -> None: + _record(self.rsp, self.name, self._sources) + + if not self._unresolved: + return + + exists = self.name in self.req.observed.resources + ordered = request.has_capability(self.req, fnv1.CAPABILITY_DEPENDENCIES) + + if not exists: + # Crossplane won't create the resource until the dependency is + # ready. One that doesn't enforce dependencies would create it + # without the field, so hold it back by leaving it out instead. + if not ordered: + self.rsp.desired.resources.pop(self.name, None) + return + + # The resource exists, but something it refers to doesn't. The fields + # that refer to it came back None and were left out, and applying + # that would unset them. Keep what the resource already has for any + # field this function no longer sets, and say so. + if self.name in self.rsp.desired.resources: + r = self.rsp.desired.resources[self.name] + observed = resource.struct_to_dict( + self.req.observed.resources[self.name].resource + ) + body = resource.struct_to_dict(r.resource) + if "spec" in observed: + body["spec"] = _overlay(observed["spec"], body.get("spec", {})) + r.resource.CopyFrom(resource.dict_to_struct(body)) + + response.warning( + self.rsp, + f"{self.name}: kept its current spec, because " + f"{', '.join(self._unresolved)} isn't available", + ) + + +def _without_none(v: typing.Any) -> typing.Any: + """Return v with every None value left out, recursing into dicts and lists.""" + if isinstance(v, dict): + return {k: _without_none(x) for k, x in v.items() if x is not None} + if isinstance(v, list): + return [_without_none(x) for x in v if x is not None] + return v + + +def _overlay(base: dict, top: dict) -> dict: + """Return base with top written over it, recursing into nested dicts.""" + out = dict(base) + for k, v in top.items(): + out[k] = ( + _overlay(out[k], v) + if isinstance(v, dict) and isinstance(out.get(k), dict) + else v + ) + return out + + +@contextlib.contextmanager +def composing( + req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse, name: str +) -> typing.Iterator[Scope]: + """Compose one resource, depending on whatever it reads from others. + + Args: + req: The RunFunctionRequest, whose observed and required resources + references are read from. + rsp: The RunFunctionResponse to update. + name: The composed resource being built, a key into desired state. + + Yields: + A Scope whose ref and external_name return real values, and record + that this resource depends on the resources they came from: + + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + c.update(Subnet(spec={"forProvider": { + "region": "us-east-1", + "vpcId": c.external_name(vpc), + "mapPublicIpOnLaunch": c.ref(vpc.spec.forProvider.enableDnsSupport), + }})) + + The dependencies are recorded when the block exits, so an exception + inside it records nothing. + + A value that isn't available yet comes back as None, and the field is + left out. If the resource doesn't exist yet that's what ordering is for: + Crossplane waits for the dependency before creating it. If it does exist, + it keeps its current spec for the fields that were left out, and the + response carries a warning saying why. + """ + scope = Scope(req, rsp, name) + yield scope + scope._close() diff --git a/tests/test_reference.py b/tests/test_reference.py index 5c39815..158965b 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -483,5 +483,205 @@ async def RunFunction(self, req, _context): # noqa: N802 self.assertEqual(len(rsp.results), 0) +class DnsForProvider(pydantic.BaseModel): + enableDnsSupport: bool | None = None # noqa: N815 # Generated models use JSON names. + + +class Dns(pydantic.BaseModel): + apiVersion: str = "example.org/v1" # noqa: N815 # Generated models use JSON names. + kind: str = "Dns" + spec: DnsForProvider + + +class TestComposing(unittest.TestCase): + def setUp(self) -> None: + logging.configure(level=logging.Level.DISABLED) + + def test_returns_values_and_records_edges(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(vpc.status.atProvider.id) + name = c.external_name(vpc) + desired_subnet(rsp, vpc_id) + + self.assertEqual(vpc_id, "vpc-0123") + self.assertEqual(name, "vpc-0123") + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + self.assertEqual(rsp.results, []) + + def test_works_in_fields_that_are_not_strings(self) -> None: + vpc_body = {"spec": {"forProvider": {"enableDnsSupport": True}}} + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc_body)) + rsp = response.to(req) + vpc = reference.named("vpc") + + with reference.composing(req, rsp, "subnet") as c: + c.update( + Dns( + spec=DnsForProvider( + enableDnsSupport=c.ref(vpc.spec.forProvider.enableDnsSupport) + ) + ) + ) + + self.assertEqual(body(rsp, "subnet")["spec"], {"enableDnsSupport": True}) + + def test_missing_source_returns_none_and_keeps_edge(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(vpc.status.atProvider.id) + c.update( + Subnet( + spec=SubnetSpec( + forProvider=SubnetForProvider(region="us-east-1", vpcId=vpc_id) + ) + ) + ) + + self.assertIsNone(vpc_id) + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"], {"region": "us-east-1"} + ) + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + self.assertEqual(rsp.results, []) + + def test_existing_resource_keeps_its_spec(self) -> None: + subnet = {"spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}}} + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(subnet=subnet)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + c.update( + Subnet( + spec=SubnetSpec( + forProvider=SubnetForProvider( + region="us-west-2", vpcId=c.ref(vpc.status.atProvider.id) + ) + ) + ) + ) + + # The function's own change wins; the field it couldn't fill is kept. + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"], + {"region": "us-west-2", "vpcId": "vpc-0123"}, + ) + self.assertEqual(len(rsp.results), 1) + self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_WARNING) + self.assertIn("vpc.status.atProvider.id", rsp.results[0].message) + + def test_without_capability_holds_back_by_omission(self) -> None: + req = fnv1.RunFunctionRequest(meta=UNORDERED) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + desired_subnet(rsp, c.ref(vpc.status.atProvider.id)) + + self.assertNotIn("subnet", rsp.desired.resources) + + def test_exception_records_nothing(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with ( + self.assertRaises(RuntimeError), + reference.composing(req, rsp, "subnet") as c, + ): + c.ref(vpc.status.atProvider.id) + raise RuntimeError + + self.assertEqual(edges(rsp), []) + + def test_required(self) -> None: + req = fnv1.RunFunctionRequest( + meta=ORDERED, + required_resources={ + "dbs": fnv1.Resources( + items=[ + fnv1.Resource( + resource=resource.dict_to_struct( + { + "metadata": {"name": "a"}, + "status": {"address": "db.a"}, + } + ) + ) + ] + ) + }, + ) + rsp = response.to(req) + db = reference.named_required("dbs") + + with reference.composing(req, rsp, "app-config") as c: + host = c.ref(db.status.address) + + self.assertEqual(host, "db.a") + self.assertEqual( + edges(rsp), + [ + { + "resource": "app-config", + "required_resource": {"requirement_name": "dbs"}, + } + ], + ) + + def test_rejects_values(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED) + rsp = response.to(req) + with ( + self.assertRaises(TypeError), + reference.composing(req, rsp, "subnet") as c, + ): + c.ref("vpc-0123") + + +class TestBothStylesAgree(unittest.TestCase): + """The two styles are two ways to write the same thing.""" + + def setUp(self) -> None: + logging.configure(level=logging.Level.DISABLED) + + def test_same_result(self) -> None: + for observed_state in (observed(vpc=VPC_OBSERVED), fnv1.State()): + with self.subTest(vpc_exists=bool(observed_state.resources)): + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed_state) + vpc = reference.named("vpc", VPC) + + marked = response.to(req) + desired_subnet(marked, reference.ref(vpc.status.atProvider.id)) + reference.resolve(req, marked) + + scoped = response.to(req) + with reference.composing(req, scoped, "subnet") as c: + c.update( + Subnet( + spec=SubnetSpec( + forProvider=SubnetForProvider( + region="us-east-1", + vpcId=c.ref(vpc.status.atProvider.id), + ) + ) + ) + ) + + self.assertEqual(body(marked, "subnet"), body(scoped, "subnet")) + self.assertEqual(edges(marked), edges(scoped)) + + if __name__ == "__main__": unittest.main() From 58353f23ca5bd60288d68058bbb8c19afe2b2882 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 13:25:13 +0200 Subject: [PATCH 4/9] Declare nothing for a scope that composes nothing, and keep what exists 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 --- crossplane/function/reference.py | 61 +++++++++++++++++++++----------- tests/test_reference.py | 49 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crossplane/function/reference.py b/crossplane/function/reference.py index acf3774..411a296 100644 --- a/crossplane/function/reference.py +++ b/crossplane/function/reference.py @@ -671,35 +671,51 @@ def _get(self, s: _Source, path: list, what: str) -> typing.Any: return v def _close(self) -> None: - _record(self.rsp, self.name, self._sources) - - if not self._unresolved: - return - exists = self.name in self.req.observed.resources - ordered = request.has_capability(self.req, fnv1.CAPABILITY_DEPENDENCIES) - if not exists: - # Crossplane won't create the resource until the dependency is - # ready. One that doesn't enforce dependencies would create it - # without the field, so hold it back by leaving it out instead. - if not ordered: + if self._unresolved: + if exists: + self._keep_current_spec() + elif not request.has_capability(self.req, fnv1.CAPABILITY_DEPENDENCIES): + # Crossplane won't create the resource until the dependency + # is ready. One that doesn't enforce dependencies would create + # it without the field, so hold it back by leaving it out. self.rsp.desired.resources.pop(self.name, None) - return - # The resource exists, but something it refers to doesn't. The fields - # that refer to it came back None and were left out, and applying - # that would unset them. Keep what the resource already has for any - # field this function no longer sets, and say so. + # Declare dependencies only for a resource that's composed or exists. + # A scope that composed nothing has nothing to order, and Crossplane + # would ignore the dependencies, with an event saying so. + if self.name in self.rsp.desired.resources or exists: + _record(self.rsp, self.name, self._sources) + + def _keep_current_spec(self) -> None: + """Keep an existing resource's spec while a reference it needs is gone. + + The fields that refer to what's missing came back None and were left + out, and applying that would unset them. A function that didn't + compose the resource at all, because the value it needed wasn't + there, would have it deleted. Either way, keep what the resource + already has for anything this function doesn't set, and say so. + """ + observed = resource.struct_to_dict( + self.req.observed.resources[self.name].resource + ) if self.name in self.rsp.desired.resources: r = self.rsp.desired.resources[self.name] - observed = resource.struct_to_dict( - self.req.observed.resources[self.name].resource - ) body = resource.struct_to_dict(r.resource) if "spec" in observed: body["spec"] = _overlay(observed["spec"], body.get("spec", {})) r.resource.CopyFrom(resource.dict_to_struct(body)) + else: + meta = observed.get("metadata", {}) + body = { + "apiVersion": observed.get("apiVersion"), + "kind": observed.get("kind"), + "metadata": {k: meta[k] for k in ("name", "namespace") if k in meta}, + } + if "spec" in observed: + body["spec"] = observed["spec"] + resource.update(self.rsp.desired.resources[self.name], body) response.warning( self.rsp, @@ -761,7 +777,12 @@ def composing( left out. If the resource doesn't exist yet that's what ordering is for: Crossplane waits for the dependency before creating it. If it does exist, it keeps its current spec for the fields that were left out, and the - response carries a warning saying why. + response carries a warning saying why. That holds even if the block + doesn't compose the resource at all because the value it needed is + missing: an existing resource is kept rather than deleted. + + Dependencies are declared only for a resource that's composed or already + exists. A block that composes nothing declares nothing. """ scope = Scope(req, rsp, name) yield scope diff --git a/tests/test_reference.py b/tests/test_reference.py index 158965b..3130be2 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -581,6 +581,52 @@ def test_existing_resource_keeps_its_spec(self) -> None: self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_WARNING) self.assertIn("vpc.status.atProvider.id", rsp.results[0].message) + def test_composing_nothing_declares_nothing(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(vpc.status.atProvider.id) + if vpc_id: + desired_subnet(rsp, vpc_id) + + self.assertNotIn("subnet", rsp.desired.resources) + self.assertEqual(edges(rsp), []) + + def test_existing_resource_not_composed_is_kept(self) -> None: + subnet = { + "apiVersion": "ec2.aws.m.upbound.io/v1beta1", + "kind": "Subnet", + "metadata": {"name": "xr-subnet-1", "uid": "abc", "resourceVersion": "7"}, + "spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}}, + "status": {"atProvider": {"id": "subnet-9"}}, + } + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(subnet=subnet)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(vpc.status.atProvider.id) + if vpc_id: + desired_subnet(rsp, vpc_id) + + # Kept rather than deleted, as its spec, without what the API server + # owns: uid, resourceVersion and status. + self.assertEqual( + body(rsp, "subnet"), + { + "apiVersion": "ec2.aws.m.upbound.io/v1beta1", + "kind": "Subnet", + "metadata": {"name": "xr-subnet-1"}, + "spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}}, + }, + ) + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_WARNING) + def test_without_capability_holds_back_by_omission(self) -> None: req = fnv1.RunFunctionRequest(meta=UNORDERED) rsp = response.to(req) @@ -628,6 +674,9 @@ def test_required(self) -> None: with reference.composing(req, rsp, "app-config") as c: host = c.ref(db.status.address) + c.update( + {"apiVersion": "v1", "kind": "ConfigMap", "data": {"DB_HOST": host}} + ) self.assertEqual(host, "db.a") self.assertEqual( From dfa3d571ac875981dd8a93c93a1de4b3bf29568e Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 13:33:13 +0200 Subject: [PATCH 5/9] Return a typed reference's value as its model types it 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 --- crossplane/function/reference.py | 17 ++++++++++-- tests/test_reference.py | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crossplane/function/reference.py b/crossplane/function/reference.py index 411a296..2dbc13a 100644 --- a/crossplane/function/reference.py +++ b/crossplane/function/reference.py @@ -609,13 +609,26 @@ def ref(self, value: V) -> V: state, and the dependency means Crossplane doesn't create this resource until the value exists. + A resource named with its model returns the value as the model + types it: a nested object comes back as its model, and a list of + them as a list of models, rather than as the JSON observed state + stores. Without a model the value is the JSON. + Raises: TypeError: value isn't a field of a named resource. - ValueError: value is the whole resource rather than a field of it. + ValueError: value is the whole resource rather than a field of + it, or the observed value doesn't fit the model's type for it. """ marker = _decode(ref(value)) assert marker is not None # noqa: S101 # ref always returns a marker. - return typing.cast(V, self._get(*marker, value._describe())) + v = self._get(*marker, value._describe()) + if v is None or value._model is None: + return typing.cast(V, v) + try: + return typing.cast(V, pydantic.TypeAdapter(value._model).validate_python(v)) + except pydantic.ValidationError as e: + msg = f"{value._describe()} doesn't match its model: {e}" + raise ValueError(msg) from e def external_name(self, named_resource: typing.Any) -> str | None: """Read a named resource's external name, and depend on that resource. diff --git a/tests/test_reference.py b/tests/test_reference.py index 3130be2..5d5e4ba 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -88,6 +88,8 @@ def observed(**resources: dict) -> fnv1.State: "status": {"atProvider": {"id": "vpc-0123", "arn": "arn:aws:ec2:vpc/vpc-0123"}}, } +POOL_OBSERVED = {"ranges": [{"from": "10.0.0.0"}, {"from": "10.1.0.0"}]} + ORDERED = fnv1.RequestMeta( capabilities=[fnv1.CAPABILITY_CAPABILITIES, fnv1.CAPABILITY_DEPENDENCIES] ) @@ -514,6 +516,49 @@ def test_returns_values_and_records_edges(self) -> None: ) self.assertEqual(rsp.results, []) + def test_typed_values_come_back_as_models(self) -> None: + req = fnv1.RunFunctionRequest( + meta=ORDERED, observed=observed(pool=POOL_OBSERVED) + ) + rsp = response.to(req) + pool = reference.named("pool", Pool) + + with reference.composing(req, rsp, "subnet") as c: + ranges = c.ref(pool.ranges) + first = c.ref(pool.ranges[0]) + start = c.ref(pool.ranges[0].from_) + + self.assertEqual( + ranges, [Range(**{"from": "10.0.0.0"}), Range(**{"from": "10.1.0.0"})] + ) + self.assertIsInstance(first, Range) + self.assertEqual(start, "10.0.0.0") + + def test_untyped_values_come_back_as_json(self) -> None: + req = fnv1.RunFunctionRequest( + meta=ORDERED, observed=observed(pool=POOL_OBSERVED) + ) + rsp = response.to(req) + pool = reference.named("pool") + + with reference.composing(req, rsp, "subnet") as c: + ranges = c.ref(pool.ranges) + + self.assertEqual(ranges, [{"from": "10.0.0.0"}, {"from": "10.1.0.0"}]) + + def test_values_that_dont_fit_the_model_fail(self) -> None: + req = fnv1.RunFunctionRequest( + meta=ORDERED, observed=observed(pool={"ranges": [{"from": 7}]}) + ) + rsp = response.to(req) + pool = reference.named("pool", Pool) + + with ( + self.assertRaisesRegex(ValueError, "pool.ranges doesn't match its model"), + reference.composing(req, rsp, "subnet") as c, + ): + c.ref(pool.ranges) + def test_works_in_fields_that_are_not_strings(self) -> None: vpc_body = {"spec": {"forProvider": {"enableDnsSupport": True}}} req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc_body)) From e0f7ac74a45e35a6809830bba0ffca28eaf8a248 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 13:46:30 +0200 Subject: [PATCH 6/9] Keep only the scoped style, and strip None however a resource is written 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 --- crossplane/function/reference.py | 327 ++++--------------- crossplane/function/runtime.py | 36 +- tests/test_reference.py | 542 ++++++++++--------------------- 3 files changed, 238 insertions(+), 667 deletions(-) diff --git a/crossplane/function/reference.py b/crossplane/function/reference.py index 2dbc13a..ef6abaa 100644 --- a/crossplane/function/reference.py +++ b/crossplane/function/reference.py @@ -20,41 +20,12 @@ anything, and a provider that can't resolve a reference yet retries against the cloud API until it can. -Writing the reference as a field value instead makes the relationship -visible. The function says where the value comes from, the SDK records the +Reading the value through this module instead makes the relationship +visible. The function says where a value comes from, the SDK records the dependency, and Crossplane waits rather than letting the provider discover the problem: - from crossplane.function import reference, resource - - vpc = reference.named("vpc", VPC) - - subnet = Subnet( - spec={ - "forProvider": { - "region": "us-east-1", - "vpcId": reference.external_name(vpc), # instead of vpcIdSelector - } - } - ) - resource.update(rsp.desired.resources["subnet"], subnet) - - reference.resolve(req, rsp) - -That records that subnet depends on vpc, and fills in the VPC's external name -once it exists. For any other field, read it through the named resource and -wrap it in ref: - - "arn": reference.ref(vpc.status.atProvider.arn), - -A reference is a marker string until resolve replaces it, because a string is -what a model's own validation accepts in a string field. So references work -only in string fields. Anywhere else the model rejects them, which is at -least a loud failure rather than a silent one. - -There is a second way to use the same named resources. composing scopes the -work to one composed resource, so a reference can resolve as soon as it's -read rather than being marked for later: + from crossplane.function import reference vpc = reference.named("vpc", VPC) @@ -64,19 +35,25 @@ spec={ "forProvider": { "region": "us-east-1", - "vpcId": c.external_name(vpc), + "vpcId": c.external_name(vpc), # instead of vpcIdSelector + "cidrBlock": c.ref(vpc.spec.forProvider.cidrBlock), } } ) ) -c.external_name and c.ref return the value itself, or None while it isn't -available, so they work in fields of any type and there is nothing to -resolve afterwards. The dependencies are recorded when the block exits. +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 that the resource depends on where each value came +from when the block exits. + +Dependencies order resources only on a Crossplane that advertises +CAPABILITY_DEPENDENCIES. On any other, a resource whose value isn't available +yet is held back by leaving it out of desired state, but nothing waits for a +source to be ready, and nothing orders deletion. """ import contextlib -import json import types import typing @@ -86,11 +63,6 @@ from crossplane.function import request, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -PREFIX = "${xp-ref:" -"""Starts every reference marker. The SDK's runtime looks for it.""" - -_SUFFIX = "}" - _EXTERNAL_NAME = "@externalName" _EXTERNAL_NAME_ANNOTATION = "crossplane.io/external-name" @@ -296,79 +268,6 @@ def named_required( return _Field(_Source(_REQUIRED, requirement_name, name, namespace), (), model) -def ref(value: V) -> V: - """Turn a field of a named resource into a reference you can assign. - - Args: - value: A field read through a stand-in from named or named_required, - for example vpc.status.atProvider.id. - - Returns: - A marker string that resolve replaces with the field's value. It's - typed as the field is, so it goes wherever that type is expected. - - Raises: - TypeError: value isn't a field of a named resource. That would - otherwise silently record no dependency at all. - ValueError: value is the whole resource rather than a field of it. - """ - if not isinstance(value, _Field): - msg = ( - "ref() expects a field of a resource from named() or named_required(), " - f"for example ref(vpc.status.atProvider.id), not {type(value).__name__}" - ) - raise TypeError(msg) - if not value._path: - msg = ( - f"ref() needs a field, not the whole {value._describe()} resource. " - "Use external_name() to reference its external name." - ) - raise ValueError(msg) - return typing.cast(V, _encode(value._source, list(value._path))) - - -def external_name(named_resource: typing.Any) -> str: - """Reference a resource's external name, its identifier outside Kubernetes. - - Args: - named_resource: A stand-in from named or named_required. - - Returns: - A marker string that resolve replaces with the external name. - - This is what a provider's own somethingIdRef would have resolved to, so it - directly replaces a reference or selector field: - - "vpcId": reference.external_name(vpc), # instead of vpcIdSelector - - Raises: - TypeError: named_resource isn't a stand-in for a resource. - """ - if not isinstance(named_resource, _Field) or named_resource._path: - msg = "external_name() expects a resource from named() or named_required()" - raise TypeError(msg) - return _encode(named_resource._source, [_EXTERNAL_NAME]) - - -def _encode(source: _Source, path: list) -> str: - # JSON rather than a dotted path, because keys like crossplane.io/name - # contain dots and lists are indexed by number. - body = json.dumps([*source, path], separators=(",", ":")) - return f"{PREFIX}{body}{_SUFFIX}" - - -def _decode(v: typing.Any) -> tuple[_Source, list] | None: - if not isinstance(v, str) or not v.startswith(PREFIX) or not v.endswith(_SUFFIX): - return None - try: - kind, name, resource_name, namespace, path = json.loads( - v[len(PREFIX) : -len(_SUFFIX)] - ) - except (ValueError, TypeError): - return None - return _Source(kind, name, resource_name, namespace), path - - _MISSING = object() @@ -444,96 +343,6 @@ def _record( declared.add(key) -class _Resolver: - """Resolves the references in one desired composed resource.""" - - def __init__(self, req: fnv1.RunFunctionRequest, name: str): - self.req = req - self.name = name - self.sources: set[_Source] = set() - self.unresolved = False - - # The dependent as it was last observed, if it exists. A reference - # whose source has gone falls back to the value the dependent already - # has, rather than dropping the field and having the apply unset it. - self.observed = _MISSING - if name in req.observed.resources: - self.observed = resource.struct_to_dict( - req.observed.resources[name].resource - ) - - def value(self, marker: tuple[_Source, list], at: list) -> typing.Any: - s, path = marker - self.sources.add(s) - - v = _read(self.req, s, path) - if v is not _MISSING: - return v - - kept = _value_at(self.observed, at) - if kept is not _MISSING: - return kept - - self.unresolved = True - return _MISSING - - def walk(self, v: typing.Any, at: list) -> typing.Any: - marker = _decode(v) - if marker is not None: - return self.value(marker, at) - if isinstance(v, dict): - out = {} - for k, child in v.items(): - r = self.walk(child, [*at, k]) - # Leave out a field whose value isn't available yet rather - # than sending a null a provider would try to apply. - if r is not _MISSING: - out[k] = r - return out - if isinstance(v, list): - walked = (self.walk(child, [*at, i]) for i, child in enumerate(v)) - return [r for r in walked if r is not _MISSING] - return v - - -def resolve(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) -> None: - """Replace references in desired state, and record the dependencies. - - Args: - req: The RunFunctionRequest, whose observed and required resources the - references are resolved against. - rsp: The RunFunctionResponse to update. Call this once desired state is - complete, just before returning it. - - Every desired composed resource that refers to another gets a dependency - on it, alongside any already declared. A reference whose value isn't - available yet is left out, and the dependency means Crossplane doesn't - create the resource until it is. A resource that already exists keeps the - value it has. - - A Crossplane that doesn't advertise CAPABILITY_DEPENDENCIES ignores the - dependencies, and would create a resource with the field missing. So - there, a resource with a reference that can't be resolved yet is left out - of desired state until it can. - """ - ordered = request.has_capability(req, fnv1.CAPABILITY_DEPENDENCIES) - - for name in list(rsp.desired.resources): - r = rsp.desired.resources[name] - if not _contains_marker(r.resource): - continue - - resolver = _Resolver(req, name) - body = resolver.walk(resource.struct_to_dict(r.resource), []) - - if resolver.unresolved and not ordered and resolver.observed is _MISSING: - del rsp.desired.resources[name] - continue - - r.resource.CopyFrom(resource.dict_to_struct(body)) - _record(rsp, name, resolver.sources) - - def _target(d: fnv1.Dependency) -> typing.Any: if d.WhichOneof("depends_on") == "required_resource": r = d.required_resource @@ -545,45 +354,12 @@ def _target(d: fnv1.Dependency) -> typing.Any: return d.composed_resource -def unresolved(rsp: fnv1.RunFunctionResponse) -> list[str]: - """Return the desired composed resources that still contain references. - - Args: - rsp: The RunFunctionResponse to check. - - Returns: - The names of desired composed resources with a reference in them, - which means resolve wasn't called after they were built. - """ - return [ - name - for name, r in rsp.desired.resources.items() - if _contains_marker(r.resource) - ] - - -def _contains_marker(s: structpb.Struct) -> bool: - def walk(v: structpb.Value) -> bool: - kind = v.WhichOneof("kind") - if kind == "string_value": - return v.string_value.startswith(PREFIX) - if kind == "struct_value": - return any(walk(x) for x in v.struct_value.fields.values()) - if kind == "list_value": - return any(walk(x) for x in v.list_value.values) - return False - - return any(walk(v) for v in s.fields.values()) - - class Scope: """Resolves references for one composed resource as they're read. - Get one from composing. Where ref and external_name at module level - return marker strings for resolve to replace later, a Scope's return the - value itself, because the scope already knows which resource is asking. - That means references work in fields of any type, not only strings, and - there's nothing left to resolve once the scope closes. + Get one from composing. The scope knows which resource is asking, 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. """ def __init__( @@ -619,9 +395,20 @@ def ref(self, value: V) -> V: ValueError: value is the whole resource rather than a field of it, or the observed value doesn't fit the model's type for it. """ - marker = _decode(ref(value)) - assert marker is not None # noqa: S101 # ref always returns a marker. - v = self._get(*marker, value._describe()) + if not isinstance(value, _Field): + msg = ( + "ref() expects a field of a resource from named() or named_required(), " + "for example c.ref(vpc.status.atProvider.id), " + f"not {type(value).__name__}" + ) + raise TypeError(msg) + if not value._path: + msg = ( + f"ref() needs a field, not the whole {value._describe()} resource. " + "Use external_name() to reference its external name." + ) + raise ValueError(msg) + v = self._get(value._source, list(value._path), value._describe()) if v is None or value._model is None: return typing.cast(V, v) try: @@ -642,9 +429,14 @@ def external_name(self, named_resource: typing.Any) -> str | None: Raises: TypeError: named_resource isn't a stand-in for a resource. """ - marker = _decode(external_name(named_resource)) - assert marker is not None # noqa: S101 # external_name always returns one. - return self._get(*marker, f"{named_resource._describe()} external name") + if not isinstance(named_resource, _Field) or named_resource._path: + msg = "external_name() expects a resource from named() or named_required()" + raise TypeError(msg) + return self._get( + named_resource._source, + [_EXTERNAL_NAME], + f"{named_resource._describe()} external name", + ) def update(self, source: dict | structpb.Struct | pydantic.BaseModel) -> None: """Write this scope's composed resource to desired state. @@ -652,28 +444,10 @@ def update(self, source: dict | structpb.Struct | pydantic.BaseModel) -> None: Args: source: The resource, as resource.update accepts it. - Unlike resource.update, this leaves out fields whose value is None. In - a scope, None is what ref returns for a value that isn't available - yet, and sending it would ask the API server to clear the field. Use - resource.update directly to send an explicit null. + Writing it with resource.update, or any helper that calls it, works + the same way. """ - r = self.rsp.desired.resources[self.name] - match source: - case pydantic.BaseModel(): - data = source.model_dump( - exclude_unset=True, exclude_none=True, by_alias=True, warnings=False - ) - # As resource.update: identify the resource even though - # apiVersion and kind are rarely set explicitly. - data["apiVersion"] = source.apiVersion - data["kind"] = source.kind - resource.update(r, data) - case structpb.Struct(): - resource.update(r, _without_none(resource.struct_to_dict(source))) - case dict(): - resource.update(r, _without_none(source)) - case _: - resource.update(r, source) + resource.update(self.rsp.desired.resources[self.name], source) def _get(self, s: _Source, path: list, what: str) -> typing.Any: self._sources.add(s) @@ -686,6 +460,17 @@ def _get(self, s: _Source, path: list, what: str) -> typing.Any: def _close(self) -> None: exists = self.name in self.req.observed.resources + # In a scope None is what a reference returns for a value that isn't + # available yet, and sending it would ask the API server to clear the + # field. Strip it however the resource was written: through c.update, + # resource.update, or a helper that calls it. + if self.name in self.rsp.desired.resources: + r = self.rsp.desired.resources[self.name] + body = resource.struct_to_dict(r.resource) + cleaned = _without_none(body) + if cleaned != body: + r.resource.CopyFrom(resource.dict_to_struct(cleaned)) + if self._unresolved: if exists: self._keep_current_spec() @@ -787,8 +572,10 @@ def composing( inside it records nothing. A value that isn't available yet comes back as None, and the field is - left out. If the resource doesn't exist yet that's what ordering is for: - Crossplane waits for the dependency before creating it. If it does exist, + left out: when the block exits, None fields are removed from the resource + however it was written, because sending one would clear the field. If the + resource doesn't exist yet that's what ordering is for: Crossplane waits + for the dependency before creating it. If it does exist, it keeps its current spec for the fields that were left out, and the response carries a warning saying why. That holds even if the block doesn't compose the resource at all because the value it needed is diff --git a/crossplane/function/runtime.py b/crossplane/function/runtime.py index f93eee4..a6e465e 100644 --- a/crossplane/function/runtime.py +++ b/crossplane/function/runtime.py @@ -27,7 +27,6 @@ import crossplane.function.proto.v1.run_function_pb2_grpc as grpcv1 import crossplane.function.proto.v1beta1.run_function_pb2 as fnv1beta1 import crossplane.function.proto.v1beta1.run_function_pb2_grpc as grpcv1beta1 -from crossplane.function import reference, response SERVICE_NAMES = ( reflection.SERVICE_NAME, @@ -108,10 +107,9 @@ def serve( lambda: asyncio.ensure_future(server.stop(grace=SHUTDOWN_GRACE_PERIOD_SECONDS)), ) - guarded = ReferenceGuard(wrapped=function) - grpcv1.add_FunctionRunnerServiceServicer_to_server(guarded, server) + grpcv1.add_FunctionRunnerServiceServicer_to_server(function, server) grpcv1beta1.add_FunctionRunnerServiceServicer_to_server( - BetaFunctionRunner(wrapped=guarded), server + BetaFunctionRunner(wrapped=function), server ) reflection.enable_server_reflection(SERVICE_NAMES, server) @@ -139,36 +137,6 @@ async def start(): loop.close() -class ReferenceGuard(grpcv1.FunctionRunnerServiceServicer): - """A ReferenceGuard fails a response that still contains references. - - A reference from crossplane.function.reference is a marker string until - reference.resolve replaces it. A function that forgets to call resolve - would otherwise send the marker to the cluster as the field's value. - """ - - def __init__(self, wrapped: grpcv1.FunctionRunnerServiceServicer): - """Create a new ReferenceGuard.""" - self.wrapped = wrapped - - async def RunFunction( # noqa: N802 # gRPC requires this name. # pyright: ignore[reportIncompatibleMethodOverride] - self, req: fnv1.RunFunctionRequest, context: grpc.aio.ServicerContext - ) -> fnv1.RunFunctionResponse: - """Run the underlying function, then check its desired state.""" - rsp = await self.wrapped.RunFunction(req, context) - - names = reference.unresolved(rsp) - if names: - response.fatal( - rsp, - f"desired composed resources {', '.join(sorted(names))} contain " - "unresolved references; call reference.resolve(req, rsp) " - "before returning", - ) - - return rsp - - class BetaFunctionRunner(grpcv1beta1.FunctionRunnerServiceServicer): """A BetaFunctionRunner handles beta gRPC RunFunctionRequests. diff --git a/tests/test_reference.py b/tests/test_reference.py index 5d5e4ba..aed54e1 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -12,13 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio +import typing import unittest import pydantic from google.protobuf import json_format -from crossplane.function import logging, reference, resource, response, runtime +from crossplane.function import logging, reference, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 # Models shaped the way datamodel-codegen generates them from CRDs: JSON names @@ -115,11 +115,24 @@ def body(rsp: fnv1.RunFunctionResponse, name: str) -> dict: class TestNamed(unittest.TestCase): + def setUp(self) -> None: + logging.configure(level=logging.Level.DISABLED) + + def read( + self, observed_state: fnv1.State, fn: typing.Callable[[reference.Scope], object] + ) -> object: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed_state) + with reference.composing(req, response.to(req), "subnet") as c: + return fn(c) + def test_follows_model_fields(self) -> None: vpc = reference.named("vpc", VPC) - marker = reference.ref(vpc.status.atProvider.arn) - self.assertTrue(marker.startswith(reference.PREFIX)) - self.assertIn('["status","atProvider","arn"]', marker) + self.assertEqual( + self.read( + observed(vpc=VPC_OBSERVED), lambda c: c.ref(vpc.status.atProvider.arn) + ), + "arn:aws:ec2:vpc/vpc-0123", + ) def test_typo_fails_where_it_is_written(self) -> None: vpc = reference.named("vpc", VPC) @@ -128,19 +141,25 @@ def test_typo_fails_where_it_is_written(self) -> None: ): _ = vpc.status.atprovider - def test_keyword_field_is_recorded_under_its_json_name(self) -> None: - pool = reference.named("pool", Pool) - for field in (pool.ranges[0].from_, pool.ranges[0]["from"]): - self.assertIn('["ranges",0,"from"]', reference.ref(field)) - - def test_json_name_is_accepted_too(self) -> None: + def test_keyword_field_is_read_by_its_json_name(self) -> None: pool = reference.named("pool", Pool) - self.assertIn('"from"', reference.ref(getattr(pool.ranges[0], "from"))) + for field in ( + pool.ranges[0].from_, + pool.ranges[0]["from"], + getattr(pool.ranges[0], "from"), + ): + self.assertEqual( + self.read(observed(pool=POOL_OBSERVED), lambda c, f=field: c.ref(f)), + "10.0.0.0", + ) - def test_untyped_records_what_it_is_given(self) -> None: + def test_untyped_reads_what_it_is_given(self) -> None: vpc = reference.named("vpc") - marker = reference.ref(vpc.metadata.annotations["crossplane.io/name"]) - self.assertIn('["metadata","annotations","crossplane.io/name"]', marker) + got = self.read( + observed(vpc=VPC_OBSERVED), + lambda c: c.ref(vpc.metadata.annotations["crossplane.io/external-name"]), + ) + self.assertEqual(got, "vpc-0123") def test_has_no_value(self) -> None: vpc = reference.named("vpc", VPC) @@ -156,22 +175,16 @@ def test_has_no_value(self) -> None: def test_ref_rejects_values(self) -> None: with self.assertRaisesRegex(TypeError, "expects a field"): - reference.ref("vpc-0123") + self.read(fnv1.State(), lambda c: c.ref("vpc-0123")) def test_ref_rejects_whole_resource(self) -> None: with self.assertRaisesRegex(ValueError, "external_name"): - reference.ref(reference.named("vpc", VPC)) + self.read(fnv1.State(), lambda c: c.ref(reference.named("vpc", VPC))) def test_external_name_rejects_fields(self) -> None: vpc = reference.named("vpc", VPC) with self.assertRaises(TypeError): - reference.external_name(vpc.status) - - def test_marker_survives_model_validation(self) -> None: - vpc = reference.named("vpc", VPC) - marker = reference.ref(vpc.status.atProvider.id) - subnet = SubnetForProvider(vpcId=marker) - self.assertEqual(subnet.vpcId, marker) + self.read(fnv1.State(), lambda c: c.external_name(vpc.status)) def test_forgetting_ref_fails_validation(self) -> None: vpc = reference.named("vpc", VPC) @@ -179,312 +192,6 @@ def test_forgetting_ref_fails_validation(self) -> None: SubnetForProvider(vpcId=vpc.status.atProvider.id) -class TestResolve(unittest.TestCase): - def setUp(self) -> None: - logging.configure(level=logging.Level.DISABLED) - - def test_substitutes_and_records_edge(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - rsp = response.to(req) - vpc = reference.named("vpc", VPC) - desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" - ) - self.assertEqual( - edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] - ) - - def test_external_name(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - rsp = response.to(req) - desired_subnet(rsp, reference.external_name(reference.named("vpc"))) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" - ) - - def test_external_name_falls_back_to_name(self) -> None: - vpc = {"metadata": {"name": "xr-vpc-abc12"}} - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc)) - rsp = response.to(req) - desired_subnet(rsp, reference.external_name(reference.named("vpc"))) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "xr-vpc-abc12" - ) - - def test_missing_source_drops_field_and_keeps_edge(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED) - rsp = response.to(req) - vpc = reference.named("vpc", VPC) - desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"], {"region": "us-east-1"} - ) - self.assertEqual( - edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] - ) - - def test_missing_field_on_existing_source_drops_field(self) -> None: - vpc = {"metadata": {"name": "xr-vpc-abc12"}} # No status yet. - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc)) - rsp = response.to(req) - desired_subnet( - rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) - ) - - reference.resolve(req, rsp) - - self.assertNotIn("vpcId", body(rsp, "subnet")["spec"]["forProvider"]) - - def test_existing_dependent_keeps_its_value(self) -> None: - subnet = {"spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}}} - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(subnet=subnet)) - rsp = response.to(req) - desired_subnet( - rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) - ) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" - ) - - def test_list_elements(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - rsp = response.to(req) - vpc = reference.named("vpc", VPC) - gone = reference.named("gone", VPC) - resource.update( - rsp.desired.resources["subnet"], - { - "spec": { - "forProvider": { - "tags": [ - "static", - reference.ref(vpc.status.atProvider.arn), - reference.ref(gone.status.atProvider.arn), - ] - } - } - }, - ) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"]["tags"], - ["static", "arn:aws:ec2:vpc/vpc-0123"], - ) - self.assertEqual( - edges(rsp), - [ - {"resource": "subnet", "composed_resource": "gone"}, - {"resource": "subnet", "composed_resource": "vpc"}, - ], - ) - - def test_does_not_duplicate_declared_edges(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - rsp = response.to(req) - response.add_dependency(rsp, "subnet", "vpc") - vpc = reference.named("vpc", VPC) - resource.update( - rsp.desired.resources["subnet"], - { - "a": reference.ref(vpc.status.atProvider.id), - "b": reference.ref(vpc.status.atProvider.arn), - }, - ) - - reference.resolve(req, rsp) - - self.assertEqual( - edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] - ) - - def test_self_reference_records_no_edge(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - rsp = response.to(req) - vpc = reference.named("vpc", VPC) - resource.update( - rsp.desired.resources["vpc"], - {"tag": reference.ref(vpc.status.atProvider.id)}, - ) - - reference.resolve(req, rsp) - - self.assertEqual(edges(rsp), []) - - def test_without_capability_holds_back_by_omission(self) -> None: - req = fnv1.RunFunctionRequest(meta=UNORDERED) - rsp = response.to(req) - desired_subnet( - rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) - ) - - reference.resolve(req, rsp) - - self.assertNotIn("subnet", rsp.desired.resources) - - def test_without_capability_resolved_resources_stay(self) -> None: - req = fnv1.RunFunctionRequest( - meta=UNORDERED, observed=observed(vpc=VPC_OBSERVED) - ) - rsp = response.to(req) - desired_subnet( - rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) - ) - - reference.resolve(req, rsp) - - self.assertEqual( - body(rsp, "subnet")["spec"]["forProvider"]["vpcId"], "vpc-0123" - ) - - def test_leaves_resources_without_references_alone(self) -> None: - req = fnv1.RunFunctionRequest(meta=ORDERED) - rsp = response.to(req) - resource.update(rsp.desired.resources["plain"], {"spec": {"a": 1}}) - before = rsp.SerializeToString() - - reference.resolve(req, rsp) - - self.assertEqual(rsp.SerializeToString(), before) - - -class TestResolveRequired(unittest.TestCase): - def setUp(self) -> None: - logging.configure(level=logging.Level.DISABLED) - - def req(self, *items: dict) -> fnv1.RunFunctionRequest: - return fnv1.RunFunctionRequest( - meta=ORDERED, - required_resources={ - "dbs": fnv1.Resources( - items=[ - fnv1.Resource(resource=resource.dict_to_struct(i)) - for i in items - ] - ) - }, - ) - - def cm(self, rsp: fnv1.RunFunctionResponse, value: str) -> None: - resource.update( - rsp.desired.resources["app-config"], {"data": {"DB_HOST": value}} - ) - - def test_single_match(self) -> None: - req = self.req({"metadata": {"name": "a"}, "status": {"address": "db.a"}}) - rsp = response.to(req) - self.cm(rsp, reference.ref(reference.named_required("dbs").status.address)) - - reference.resolve(req, rsp) - - self.assertEqual(body(rsp, "app-config")["data"]["DB_HOST"], "db.a") - self.assertEqual( - edges(rsp), - [ - { - "resource": "app-config", - "required_resource": {"requirement_name": "dbs"}, - } - ], - ) - - def test_named_match(self) -> None: - req = self.req( - { - "metadata": {"name": "a", "namespace": "x"}, - "status": {"address": "db.xa"}, - }, - { - "metadata": {"name": "a", "namespace": "y"}, - "status": {"address": "db.ya"}, - }, - ) - rsp = response.to(req) - db = reference.named_required("dbs", name="a", namespace="y") - self.cm(rsp, reference.ref(db.status.address)) - - reference.resolve(req, rsp) - - self.assertEqual(body(rsp, "app-config")["data"]["DB_HOST"], "db.ya") - self.assertEqual( - edges(rsp)[0]["required_resource"], - {"requirement_name": "dbs", "name": "a", "namespace": "y"}, - ) - - def test_declines_to_guess_between_matches(self) -> None: - req = self.req( - {"metadata": {"name": "a"}, "status": {"address": "db.a"}}, - {"metadata": {"name": "b"}, "status": {"address": "db.b"}}, - ) - rsp = response.to(req) - self.cm(rsp, reference.ref(reference.named_required("dbs").status.address)) - - reference.resolve(req, rsp) - - self.assertEqual(body(rsp, "app-config"), {"data": {}}) - - -class TestGuard(unittest.TestCase): - def setUp(self) -> None: - logging.configure(level=logging.Level.DISABLED) - - def test_unresolved(self) -> None: - rsp = fnv1.RunFunctionResponse() - desired_subnet( - rsp, reference.ref(reference.named("vpc", VPC).status.atProvider.id) - ) - resource.update(rsp.desired.resources["plain"], {"a": "b"}) - - self.assertEqual(reference.unresolved(rsp), ["subnet"]) - - def test_runtime_fails_a_forgotten_resolve(self) -> None: - class Forgetful: - async def RunFunction(self, req, _context): # noqa: N802 - rsp = response.to(req) - vpc = reference.named("vpc", VPC) - desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) - return rsp - - guard = runtime.ReferenceGuard(wrapped=Forgetful()) - rsp = asyncio.run(guard.RunFunction(fnv1.RunFunctionRequest(), None)) - - self.assertEqual(len(rsp.results), 1) - self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_FATAL) - self.assertIn("reference.resolve", rsp.results[0].message) - - def test_runtime_passes_a_resolved_response(self) -> None: - class Careful: - async def RunFunction(self, req, _context): # noqa: N802 - rsp = response.to(req) - vpc = reference.named("vpc", VPC) - desired_subnet(rsp, reference.ref(vpc.status.atProvider.id)) - reference.resolve(req, rsp) - return rsp - - guard = runtime.ReferenceGuard(wrapped=Careful()) - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - rsp = asyncio.run(guard.RunFunction(req, None)) - - self.assertEqual(len(rsp.results), 0) - - class DnsForProvider(pydantic.BaseModel): enableDnsSupport: bool | None = None # noqa: N815 # Generated models use JSON names. @@ -734,6 +441,148 @@ def test_required(self) -> None: ], ) + def test_external_name(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + with reference.composing(req, response.to(req), "subnet") as c: + self.assertEqual(c.external_name(reference.named("vpc")), "vpc-0123") + + def test_external_name_falls_back_to_name(self) -> None: + req = fnv1.RunFunctionRequest( + meta=ORDERED, observed=observed(vpc={"metadata": {"name": "xr-vpc-abc12"}}) + ) + with reference.composing(req, response.to(req), "subnet") as c: + self.assertEqual(c.external_name(reference.named("vpc")), "xr-vpc-abc12") + + def test_missing_field_on_existing_source_is_none(self) -> None: + req = fnv1.RunFunctionRequest( + meta=ORDERED, observed=observed(vpc={"metadata": {"name": "xr-vpc-abc12"}}) + ) + rsp = response.to(req) + with reference.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(reference.named("vpc", VPC).status.atProvider.id) + desired_subnet(rsp, vpc_id) + + self.assertIsNone(vpc_id) + self.assertNotIn("vpcId", body(rsp, "subnet")["spec"]["forProvider"]) + + def test_none_is_stripped_however_the_resource_is_written(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + gone = reference.named("gone", VPC) + + # resource.update rather than c.update, as a helper would call it. A + # model field set to None is emitted as a null, because it was set. + with reference.composing(req, rsp, "subnet") as c: + resource.update( + rsp.desired.resources["subnet"], + Subnet( + spec=SubnetSpec( + forProvider=SubnetForProvider( + region="us-east-1", vpcId=c.ref(gone.status.atProvider.id) + ) + ) + ), + ) + + # A dict can carry None nested, and in a list. + with reference.composing(req, rsp, "tags") as c: + resource.update( + rsp.desired.resources["tags"], + { + "spec": { + "vpcId": c.ref(gone.status.atProvider.id), + "tags": [ + "static", + c.ref(vpc.status.atProvider.arn), + c.ref(gone.status.atProvider.arn), + ], + } + }, + ) + + self.assertEqual( + body(rsp, "subnet")["spec"]["forProvider"], {"region": "us-east-1"} + ) + self.assertEqual( + body(rsp, "tags")["spec"], {"tags": ["static", "arn:aws:ec2:vpc/vpc-0123"]} + ) + + def test_does_not_duplicate_declared_dependencies(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + response.add_dependency(rsp, "subnet", "vpc") + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "subnet") as c: + c.update( + { + "a": c.ref(vpc.status.atProvider.id), + "b": c.ref(vpc.status.atProvider.arn), + } + ) + + self.assertEqual( + edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] + ) + + def test_self_reference_declares_nothing(self) -> None: + req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) + rsp = response.to(req) + vpc = reference.named("vpc", VPC) + + with reference.composing(req, rsp, "vpc") as c: + c.update({"tag": c.ref(vpc.status.atProvider.id)}) + + self.assertEqual(edges(rsp), []) + + def required(self, *items: dict) -> fnv1.RunFunctionRequest: + return fnv1.RunFunctionRequest( + meta=ORDERED, + required_resources={ + "dbs": fnv1.Resources( + items=[ + fnv1.Resource(resource=resource.dict_to_struct(i)) + for i in items + ] + ) + }, + ) + + def test_required_named_match(self) -> None: + req = self.required( + { + "metadata": {"name": "a", "namespace": "x"}, + "status": {"address": "db.xa"}, + }, + { + "metadata": {"name": "a", "namespace": "y"}, + "status": {"address": "db.ya"}, + }, + ) + rsp = response.to(req) + db = reference.named_required("dbs", name="a", namespace="y") + + with reference.composing(req, rsp, "app-config") as c: + host = c.ref(db.status.address) + c.update( + {"apiVersion": "v1", "kind": "ConfigMap", "data": {"DB_HOST": host}} + ) + + self.assertEqual(host, "db.ya") + self.assertEqual( + edges(rsp)[0]["required_resource"], + {"requirement_name": "dbs", "name": "a", "namespace": "y"}, + ) + + def test_required_declines_to_guess_between_matches(self) -> None: + req = self.required( + {"metadata": {"name": "a"}, "status": {"address": "db.a"}}, + {"metadata": {"name": "b"}, "status": {"address": "db.b"}}, + ) + with reference.composing(req, response.to(req), "app-config") as c: + self.assertIsNone(c.ref(reference.named_required("dbs").status.address)) + def test_rejects_values(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED) rsp = response.to(req) @@ -744,38 +593,5 @@ def test_rejects_values(self) -> None: c.ref("vpc-0123") -class TestBothStylesAgree(unittest.TestCase): - """The two styles are two ways to write the same thing.""" - - def setUp(self) -> None: - logging.configure(level=logging.Level.DISABLED) - - def test_same_result(self) -> None: - for observed_state in (observed(vpc=VPC_OBSERVED), fnv1.State()): - with self.subTest(vpc_exists=bool(observed_state.resources)): - req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed_state) - vpc = reference.named("vpc", VPC) - - marked = response.to(req) - desired_subnet(marked, reference.ref(vpc.status.atProvider.id)) - reference.resolve(req, marked) - - scoped = response.to(req) - with reference.composing(req, scoped, "subnet") as c: - c.update( - Subnet( - spec=SubnetSpec( - forProvider=SubnetForProvider( - region="us-east-1", - vpcId=c.ref(vpc.status.atProvider.id), - ) - ) - ) - ) - - self.assertEqual(body(marked, "subnet"), body(scoped, "subnet")) - self.assertEqual(edges(marked), edges(scoped)) - - if __name__ == "__main__": unittest.main() From 38b5b910cec45cbaa41e9cfdb8fe47af799bf9d2 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 14:06:39 +0200 Subject: [PATCH 7/9] Rename the reference module to dependency 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 --- .../function/{reference.py => dependency.py} | 22 ++-- .../{test_reference.py => test_dependency.py} | 110 +++++++++--------- 2 files changed, 68 insertions(+), 64 deletions(-) rename crossplane/function/{reference.py => dependency.py} (97%) rename tests/{test_reference.py => test_dependency.py} (85%) diff --git a/crossplane/function/reference.py b/crossplane/function/dependency.py similarity index 97% rename from crossplane/function/reference.py rename to crossplane/function/dependency.py index ef6abaa..2cee489 100644 --- a/crossplane/function/reference.py +++ b/crossplane/function/dependency.py @@ -25,11 +25,11 @@ dependency, and Crossplane waits rather than letting the provider discover the problem: - from crossplane.function import reference + from crossplane.function import dependency - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: c.update( Subnet( spec={ @@ -131,8 +131,8 @@ def __setattr__(self, attr: str, value: typing.Any) -> None: def __bool__(self) -> bool: msg = ( - f"{self._describe()} has no value yet. Wrap it in reference.ref() to " - "assign it; read the observed resource instead to branch on it." + f"{self._describe()} has no value yet. Read it with c.ref() inside " + "dependency.composing(), and branch on what that returns." ) raise TypeError(msg) @@ -141,7 +141,7 @@ def __iter__(self) -> typing.NoReturn: raise TypeError(msg) def __str__(self) -> str: - msg = f"{self._describe()} has no value yet. Wrap it in reference.ref()." + msg = f"{self._describe()} has no value yet. Read it with c.ref()." raise TypeError(msg) def __repr__(self) -> str: @@ -220,8 +220,10 @@ def named(name: str, model: type[M] | None = None) -> typing.Any: Returns: A stand-in that reads like the resource itself: - vpc = reference.named("vpc", VPC) - "vpcId": reference.ref(vpc.status.atProvider.id), + vpc = dependency.named("vpc", VPC) + + with dependency.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(vpc.status.atProvider.id) """ return _Field(_Source(_COMPOSED, name), (), model) @@ -559,9 +561,9 @@ def composing( A Scope whose ref and external_name return real values, and record that this resource depends on the resources they came from: - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: c.update(Subnet(spec={"forProvider": { "region": "us-east-1", "vpcId": c.external_name(vpc), diff --git a/tests/test_reference.py b/tests/test_dependency.py similarity index 85% rename from tests/test_reference.py rename to tests/test_dependency.py index aed54e1..d58e6af 100644 --- a/tests/test_reference.py +++ b/tests/test_dependency.py @@ -18,7 +18,7 @@ import pydantic from google.protobuf import json_format -from crossplane.function import logging, reference, resource, response +from crossplane.function import dependency, logging, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 # Models shaped the way datamodel-codegen generates them from CRDs: JSON names @@ -119,14 +119,16 @@ def setUp(self) -> None: logging.configure(level=logging.Level.DISABLED) def read( - self, observed_state: fnv1.State, fn: typing.Callable[[reference.Scope], object] + self, + observed_state: fnv1.State, + fn: typing.Callable[[dependency.Scope], object], ) -> object: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed_state) - with reference.composing(req, response.to(req), "subnet") as c: + with dependency.composing(req, response.to(req), "subnet") as c: return fn(c) def test_follows_model_fields(self) -> None: - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) self.assertEqual( self.read( observed(vpc=VPC_OBSERVED), lambda c: c.ref(vpc.status.atProvider.arn) @@ -135,14 +137,14 @@ def test_follows_model_fields(self) -> None: ) def test_typo_fails_where_it_is_written(self) -> None: - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) with self.assertRaisesRegex( AttributeError, "vpc.status has no field 'atprovider'" ): _ = vpc.status.atprovider def test_keyword_field_is_read_by_its_json_name(self) -> None: - pool = reference.named("pool", Pool) + pool = dependency.named("pool", Pool) for field in ( pool.ranges[0].from_, pool.ranges[0]["from"], @@ -154,7 +156,7 @@ def test_keyword_field_is_read_by_its_json_name(self) -> None: ) def test_untyped_reads_what_it_is_given(self) -> None: - vpc = reference.named("vpc") + vpc = dependency.named("vpc") got = self.read( observed(vpc=VPC_OBSERVED), lambda c: c.ref(vpc.metadata.annotations["crossplane.io/external-name"]), @@ -162,7 +164,7 @@ def test_untyped_reads_what_it_is_given(self) -> None: self.assertEqual(got, "vpc-0123") def test_has_no_value(self) -> None: - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) field = vpc.status.atProvider.id with self.assertRaisesRegex(TypeError, "has no value yet"): bool(field) @@ -179,15 +181,15 @@ def test_ref_rejects_values(self) -> None: def test_ref_rejects_whole_resource(self) -> None: with self.assertRaisesRegex(ValueError, "external_name"): - self.read(fnv1.State(), lambda c: c.ref(reference.named("vpc", VPC))) + self.read(fnv1.State(), lambda c: c.ref(dependency.named("vpc", VPC))) def test_external_name_rejects_fields(self) -> None: - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) with self.assertRaises(TypeError): self.read(fnv1.State(), lambda c: c.external_name(vpc.status)) def test_forgetting_ref_fails_validation(self) -> None: - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) with self.assertRaises(pydantic.ValidationError): SubnetForProvider(vpcId=vpc.status.atProvider.id) @@ -209,9 +211,9 @@ def setUp(self) -> None: def test_returns_values_and_records_edges(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: vpc_id = c.ref(vpc.status.atProvider.id) name = c.external_name(vpc) desired_subnet(rsp, vpc_id) @@ -228,9 +230,9 @@ def test_typed_values_come_back_as_models(self) -> None: meta=ORDERED, observed=observed(pool=POOL_OBSERVED) ) rsp = response.to(req) - pool = reference.named("pool", Pool) + pool = dependency.named("pool", Pool) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: ranges = c.ref(pool.ranges) first = c.ref(pool.ranges[0]) start = c.ref(pool.ranges[0].from_) @@ -246,9 +248,9 @@ def test_untyped_values_come_back_as_json(self) -> None: meta=ORDERED, observed=observed(pool=POOL_OBSERVED) ) rsp = response.to(req) - pool = reference.named("pool") + pool = dependency.named("pool") - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: ranges = c.ref(pool.ranges) self.assertEqual(ranges, [{"from": "10.0.0.0"}, {"from": "10.1.0.0"}]) @@ -258,11 +260,11 @@ def test_values_that_dont_fit_the_model_fail(self) -> None: meta=ORDERED, observed=observed(pool={"ranges": [{"from": 7}]}) ) rsp = response.to(req) - pool = reference.named("pool", Pool) + pool = dependency.named("pool", Pool) with ( self.assertRaisesRegex(ValueError, "pool.ranges doesn't match its model"), - reference.composing(req, rsp, "subnet") as c, + dependency.composing(req, rsp, "subnet") as c, ): c.ref(pool.ranges) @@ -270,9 +272,9 @@ def test_works_in_fields_that_are_not_strings(self) -> None: vpc_body = {"spec": {"forProvider": {"enableDnsSupport": True}}} req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=vpc_body)) rsp = response.to(req) - vpc = reference.named("vpc") + vpc = dependency.named("vpc") - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: c.update( Dns( spec=DnsForProvider( @@ -286,9 +288,9 @@ def test_works_in_fields_that_are_not_strings(self) -> None: def test_missing_source_returns_none_and_keeps_edge(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: vpc_id = c.ref(vpc.status.atProvider.id) c.update( Subnet( @@ -311,9 +313,9 @@ def test_existing_resource_keeps_its_spec(self) -> None: subnet = {"spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}}} req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(subnet=subnet)) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: c.update( Subnet( spec=SubnetSpec( @@ -336,9 +338,9 @@ def test_existing_resource_keeps_its_spec(self) -> None: def test_composing_nothing_declares_nothing(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: vpc_id = c.ref(vpc.status.atProvider.id) if vpc_id: desired_subnet(rsp, vpc_id) @@ -356,9 +358,9 @@ def test_existing_resource_not_composed_is_kept(self) -> None: } req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(subnet=subnet)) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: vpc_id = c.ref(vpc.status.atProvider.id) if vpc_id: desired_subnet(rsp, vpc_id) @@ -382,9 +384,9 @@ def test_existing_resource_not_composed_is_kept(self) -> None: def test_without_capability_holds_back_by_omission(self) -> None: req = fnv1.RunFunctionRequest(meta=UNORDERED) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: desired_subnet(rsp, c.ref(vpc.status.atProvider.id)) self.assertNotIn("subnet", rsp.desired.resources) @@ -392,11 +394,11 @@ def test_without_capability_holds_back_by_omission(self) -> None: def test_exception_records_nothing(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) with ( self.assertRaises(RuntimeError), - reference.composing(req, rsp, "subnet") as c, + dependency.composing(req, rsp, "subnet") as c, ): c.ref(vpc.status.atProvider.id) raise RuntimeError @@ -422,9 +424,9 @@ def test_required(self) -> None: }, ) rsp = response.to(req) - db = reference.named_required("dbs") + db = dependency.named_required("dbs") - with reference.composing(req, rsp, "app-config") as c: + with dependency.composing(req, rsp, "app-config") as c: host = c.ref(db.status.address) c.update( {"apiVersion": "v1", "kind": "ConfigMap", "data": {"DB_HOST": host}} @@ -443,23 +445,23 @@ def test_required(self) -> None: def test_external_name(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) - with reference.composing(req, response.to(req), "subnet") as c: - self.assertEqual(c.external_name(reference.named("vpc")), "vpc-0123") + with dependency.composing(req, response.to(req), "subnet") as c: + self.assertEqual(c.external_name(dependency.named("vpc")), "vpc-0123") def test_external_name_falls_back_to_name(self) -> None: req = fnv1.RunFunctionRequest( meta=ORDERED, observed=observed(vpc={"metadata": {"name": "xr-vpc-abc12"}}) ) - with reference.composing(req, response.to(req), "subnet") as c: - self.assertEqual(c.external_name(reference.named("vpc")), "xr-vpc-abc12") + with dependency.composing(req, response.to(req), "subnet") as c: + self.assertEqual(c.external_name(dependency.named("vpc")), "xr-vpc-abc12") def test_missing_field_on_existing_source_is_none(self) -> None: req = fnv1.RunFunctionRequest( meta=ORDERED, observed=observed(vpc={"metadata": {"name": "xr-vpc-abc12"}}) ) rsp = response.to(req) - with reference.composing(req, rsp, "subnet") as c: - vpc_id = c.ref(reference.named("vpc", VPC).status.atProvider.id) + with dependency.composing(req, rsp, "subnet") as c: + vpc_id = c.ref(dependency.named("vpc", VPC).status.atProvider.id) desired_subnet(rsp, vpc_id) self.assertIsNone(vpc_id) @@ -468,12 +470,12 @@ def test_missing_field_on_existing_source_is_none(self) -> None: def test_none_is_stripped_however_the_resource_is_written(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) rsp = response.to(req) - vpc = reference.named("vpc", VPC) - gone = reference.named("gone", VPC) + vpc = dependency.named("vpc", VPC) + gone = dependency.named("gone", VPC) # resource.update rather than c.update, as a helper would call it. A # model field set to None is emitted as a null, because it was set. - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: resource.update( rsp.desired.resources["subnet"], Subnet( @@ -486,7 +488,7 @@ def test_none_is_stripped_however_the_resource_is_written(self) -> None: ) # A dict can carry None nested, and in a list. - with reference.composing(req, rsp, "tags") as c: + with dependency.composing(req, rsp, "tags") as c: resource.update( rsp.desired.resources["tags"], { @@ -512,9 +514,9 @@ def test_does_not_duplicate_declared_dependencies(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) rsp = response.to(req) response.add_dependency(rsp, "subnet", "vpc") - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "subnet") as c: + with dependency.composing(req, rsp, "subnet") as c: c.update( { "a": c.ref(vpc.status.atProvider.id), @@ -529,9 +531,9 @@ def test_does_not_duplicate_declared_dependencies(self) -> None: def test_self_reference_declares_nothing(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED, observed=observed(vpc=VPC_OBSERVED)) rsp = response.to(req) - vpc = reference.named("vpc", VPC) + vpc = dependency.named("vpc", VPC) - with reference.composing(req, rsp, "vpc") as c: + with dependency.composing(req, rsp, "vpc") as c: c.update({"tag": c.ref(vpc.status.atProvider.id)}) self.assertEqual(edges(rsp), []) @@ -561,9 +563,9 @@ def test_required_named_match(self) -> None: }, ) rsp = response.to(req) - db = reference.named_required("dbs", name="a", namespace="y") + db = dependency.named_required("dbs", name="a", namespace="y") - with reference.composing(req, rsp, "app-config") as c: + with dependency.composing(req, rsp, "app-config") as c: host = c.ref(db.status.address) c.update( {"apiVersion": "v1", "kind": "ConfigMap", "data": {"DB_HOST": host}} @@ -580,15 +582,15 @@ def test_required_declines_to_guess_between_matches(self) -> None: {"metadata": {"name": "a"}, "status": {"address": "db.a"}}, {"metadata": {"name": "b"}, "status": {"address": "db.b"}}, ) - with reference.composing(req, response.to(req), "app-config") as c: - self.assertIsNone(c.ref(reference.named_required("dbs").status.address)) + with dependency.composing(req, response.to(req), "app-config") as c: + self.assertIsNone(c.ref(dependency.named_required("dbs").status.address)) def test_rejects_values(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED) rsp = response.to(req) with ( self.assertRaises(TypeError), - reference.composing(req, rsp, "subnet") as c, + dependency.composing(req, rsp, "subnet") as c, ): c.ref("vpc-0123") From 20115c77d5da71f482d99d0f9ebe1115b14780d3 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 29 Sep 2026 14:08:49 +0200 Subject: [PATCH 8/9] Say a resource was kept in a condition, not a warning every reconcile 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 --- crossplane/function/dependency.py | 62 +++++++++++++++++++----- tests/test_dependency.py | 78 +++++++++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/crossplane/function/dependency.py b/crossplane/function/dependency.py index 2cee489..b575fe8 100644 --- a/crossplane/function/dependency.py +++ b/crossplane/function/dependency.py @@ -66,6 +66,13 @@ _EXTERNAL_NAME = "@externalName" _EXTERNAL_NAME_ANNOTATION = "crossplane.io/external-name" +CONDITION_TYPE = "DependencyValuesAvailable" +"""The XR condition composing reports whether any resource was kept. + +False while a resource was kept at its current spec because a value it's +built from isn't available, naming each one. True otherwise. +""" + _COMPOSED = "composed" _REQUIRED = "required" @@ -373,6 +380,7 @@ def __init__( self.name = name self._sources: set[_Source] = set() self._unresolved: list[str] = [] + self._kept = False def ref(self, value: V) -> V: """Read a field of a named resource, and depend on that resource. @@ -488,6 +496,8 @@ def _close(self) -> None: if self.name in self.rsp.desired.resources or exists: _record(self.rsp, self.name, self._sources) + _report(self.rsp, self.name, self._unresolved if self._kept else []) + def _keep_current_spec(self) -> None: """Keep an existing resource's spec while a reference it needs is gone. @@ -495,7 +505,8 @@ def _keep_current_spec(self) -> None: out, and applying that would unset them. A function that didn't compose the resource at all, because the value it needed wasn't there, would have it deleted. Either way, keep what the resource - already has for anything this function doesn't set, and say so. + already has for anything this function doesn't set, and say so in + the CONDITION_TYPE condition. """ observed = resource.struct_to_dict( self.req.observed.resources[self.name].resource @@ -517,12 +528,39 @@ def _keep_current_spec(self) -> None: body["spec"] = observed["spec"] resource.update(self.rsp.desired.resources[self.name], body) - response.warning( - self.rsp, - f"{self.name}: kept its current spec, because " - f"{', '.join(self._unresolved)} isn't available", + self._kept = True + + +def _report(rsp: fnv1.RunFunctionResponse, name: str, missing: list[str]) -> None: + """Report on CONDITION_TYPE for one scope. + + Every scope reports, so the condition is always returned: Crossplane + keeps a condition a function set earlier until the function sets it + again, so one returned only while something was kept would stay False + after it recovered. Scopes in one function share the condition, which + turns False on the first that kept its resource and names each. + + The message is built from resource names and field paths only, so it + holds still while the situation does. Crossplane then leaves the + condition alone, where a result would be an event every reconcile. + """ + c = next((c for c in rsp.conditions if c.type == CONDITION_TYPE), None) + if c is None: + c = rsp.conditions.add( + type=CONDITION_TYPE, status=fnv1.STATUS_CONDITION_TRUE, reason="Available" ) + if not missing: + return + + line = f"{name} kept its current spec: {', '.join(missing)} isn't available" + if c.status == fnv1.STATUS_CONDITION_TRUE: + c.status = fnv1.STATUS_CONDITION_FALSE + c.reason = "KeptCurrentSpec" + c.message = line + else: + c.message = f"{c.message}; {line}" + def _without_none(v: typing.Any) -> typing.Any: """Return v with every None value left out, recursing into dicts and lists.""" @@ -573,15 +611,15 @@ def composing( The dependencies are recorded when the block exits, so an exception inside it records nothing. - A value that isn't available yet comes back as None, and the field is - left out: when the block exits, None fields are removed from the resource + A value that isn't available yet comes back as None, and the field is left + out: when the block exits, None fields are removed from the resource however it was written, because sending one would clear the field. If the resource doesn't exist yet that's what ordering is for: Crossplane waits - for the dependency before creating it. If it does exist, - it keeps its current spec for the fields that were left out, and the - response carries a warning saying why. That holds even if the block - doesn't compose the resource at all because the value it needed is - missing: an existing resource is kept rather than deleted. + for the dependency before creating it. If it does exist, it keeps its + current spec for the fields that were left out, and the XR's + DependencyValuesAvailable condition turns False, saying why. That holds + even if the block doesn't compose the resource at all because the value it + needed is missing: an existing resource is kept rather than deleted. Dependencies are declared only for a resource that's composed or already exists. A block that composes nothing declares nothing. diff --git a/tests/test_dependency.py b/tests/test_dependency.py index d58e6af..409a1f2 100644 --- a/tests/test_dependency.py +++ b/tests/test_dependency.py @@ -110,6 +110,18 @@ def edges(rsp: fnv1.RunFunctionResponse) -> list[dict]: ] +def conditions(rsp: fnv1.RunFunctionResponse) -> list[dict]: + return [ + json_format.MessageToDict(c, preserving_proto_field_name=True) + for c in rsp.conditions + ] + + +def kept(name: str) -> str: + """The condition message for a resource kept while the VPC's id is missing.""" + return f"{name} kept its current spec: vpc.status.atProvider.id isn't available" + + def body(rsp: fnv1.RunFunctionResponse, name: str) -> dict: return resource.struct_to_dict(rsp.desired.resources[name].resource) @@ -331,9 +343,67 @@ def test_existing_resource_keeps_its_spec(self) -> None: body(rsp, "subnet")["spec"]["forProvider"], {"region": "us-west-2", "vpcId": "vpc-0123"}, ) - self.assertEqual(len(rsp.results), 1) - self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_WARNING) - self.assertIn("vpc.status.atProvider.id", rsp.results[0].message) + self.assertEqual(rsp.results, []) + self.assertEqual( + conditions(rsp), + [ + { + "type": "DependencyValuesAvailable", + "status": "STATUS_CONDITION_FALSE", + "reason": "KeptCurrentSpec", + "message": kept("subnet"), + } + ], + ) + + def test_condition_is_true_when_nothing_is_kept(self) -> None: + # Returned even so: Crossplane keeps a condition a function set + # earlier, so one only returned while False would never clear. + req = fnv1.RunFunctionRequest(meta=ORDERED) + rsp = response.to(req) + with dependency.composing(req, rsp, "subnet") as c: + c.ref(dependency.named("vpc", VPC).status.atProvider.id) + + self.assertEqual( + conditions(rsp), + [ + { + "type": "DependencyValuesAvailable", + "status": "STATUS_CONDITION_TRUE", + "reason": "Available", + } + ], + ) + + def test_condition_names_every_kept_resource(self) -> None: + existing = { + "spec": {"forProvider": {"region": "us-east-1", "vpcId": "vpc-0123"}} + } + req = fnv1.RunFunctionRequest( + meta=ORDERED, observed=observed(a=existing, b=existing, c=VPC_OBSERVED) + ) + rsp = response.to(req) + vpc = dependency.named("vpc", VPC) + + # A kept resource, one that's fine, then another kept one: the one + # that's fine mustn't turn the condition back to True. + for name in ("a", "c", "b"): + with dependency.composing(req, rsp, name) as c: + if name == "c": + continue + c.ref(vpc.status.atProvider.id) + + self.assertEqual( + conditions(rsp), + [ + { + "type": "DependencyValuesAvailable", + "status": "STATUS_CONDITION_FALSE", + "reason": "KeptCurrentSpec", + "message": f"{kept('a')}; {kept('b')}", + } + ], + ) def test_composing_nothing_declares_nothing(self) -> None: req = fnv1.RunFunctionRequest(meta=ORDERED) @@ -379,7 +449,7 @@ def test_existing_resource_not_composed_is_kept(self) -> None: self.assertEqual( edges(rsp), [{"resource": "subnet", "composed_resource": "vpc"}] ) - self.assertEqual(rsp.results[0].severity, fnv1.SEVERITY_WARNING) + self.assertEqual(conditions(rsp)[0]["reason"], "KeptCurrentSpec") def test_without_capability_holds_back_by_omission(self) -> None: req = fnv1.RunFunctionRequest(meta=UNORDERED) From 78346f4344af862be7133751cf51c355b7e76852 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 17 Oct 2026 20:38:36 +0200 Subject: [PATCH 9/9] Let a function require the capabilities it can't work without has_capability answers one capability at a time, so a function that can't work correctly without some - because it relies on Crossplane honoring a response field an older Crossplane would ignore - had to write its own check and its own fatal result, one capability at a time. missing_capabilities returns which of several capabilities Crossplane doesn't advertise, and require_capabilities adds one fatal result naming all of them, so an author fixes them in one go rather than a run per capability. A Crossplane that advertises no capabilities predates capability advertisement, so every capability is missing and the result says so - the fix there is to upgrade, not to enable a feature. if not request.require_capabilities(req, rsp, fnv1.CAPABILITY_DEPENDENCIES): return rsp Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven Borrelli --- crossplane/function/request.py | 75 +++++++++++++++++- tests/test_request.py | 141 +++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/crossplane/function/request.py b/crossplane/function/request.py index 839a340..871ec30 100644 --- a/crossplane/function/request.py +++ b/crossplane/function/request.py @@ -17,7 +17,7 @@ import dataclasses import crossplane.function.proto.v1.run_function_pb2 as fnv1 -from crossplane.function import resource +from crossplane.function import resource, response @dataclasses.dataclass @@ -183,6 +183,79 @@ def has_capability( return cap in req.meta.capabilities +def missing_capabilities( + req: fnv1.RunFunctionRequest, + *caps: fnv1.Capability.ValueType, +) -> list[fnv1.Capability.ValueType]: + """Find the capabilities Crossplane doesn't advertise. + + Args: + req: The RunFunctionRequest to check. + *caps: The capabilities to check for, e.g. fnv1.CAPABILITY_DEPENDENCIES. + + Returns: + The capabilities in caps that aren't present in the request metadata, + in the order given and without duplicates. Empty if all are present. + + If Crossplane doesn't advertise its capabilities at all, every capability + is missing. See advertises_capabilities. + + missing = request.missing_capabilities(req, fnv1.CAPABILITY_DEPENDENCIES) + """ + missing: list[fnv1.Capability.ValueType] = [] + for cap in caps: + if not has_capability(req, cap) and cap not in missing: + missing.append(cap) + return missing + + +def require_capabilities( + req: fnv1.RunFunctionRequest, + rsp: fnv1.RunFunctionResponse, + *caps: fnv1.Capability.ValueType, +) -> bool: + """Require that Crossplane advertises the supplied capabilities. + + Args: + req: The RunFunctionRequest to check. + rsp: The RunFunctionResponse to add a fatal result to. + *caps: The capabilities the function requires. + + Returns: + True if Crossplane advertises every capability in caps. Otherwise + False, with one fatal result added to rsp naming every missing + capability. + + Use this when a function can't work correctly without a capability - + for example because it relies on Crossplane honoring a response field + that an older Crossplane would ignore. A fatal result stops the pipeline, + which is easier to diagnose than a composition that silently behaves + differently. + + A Crossplane that predates capability advertisement (v2.2) has none of + them as far as the function can tell, so every capability is missing and + the fatal result says so. + + if not request.require_capabilities(req, rsp, fnv1.CAPABILITY_DEPENDENCIES): + return rsp + """ + missing = missing_capabilities(req, *caps) + if not missing: + return True + + msg = ( + "This function requires Crossplane capabilities that Crossplane doesn't " + f"advertise: {', '.join(fnv1.Capability.Name(cap) for cap in missing)}." + ) + if not advertises_capabilities(req): + msg += ( + " Crossplane doesn't advertise any capabilities, so it predates" + " capability advertisement (v2.2)." + ) + response.fatal(rsp, msg) + return False + + def get_required_schema(req: fnv1.RunFunctionRequest, name: str) -> dict | None: """Get a required OpenAPI schema by name from the request. diff --git a/tests/test_request.py b/tests/test_request.py index ebd1e2c..57a8321 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -342,6 +342,147 @@ class TestCase: got = request.has_capability(case.req, case.cap) self.assertEqual(case.want, got, case.reason) + def test_missing_capabilities(self) -> None: + @dataclasses.dataclass + class TestCase: + reason: str + req: fnv1.RunFunctionRequest + caps: list[fnv1.Capability.ValueType] + want: list[fnv1.Capability.ValueType] + + cases = [ + TestCase( + reason="Should return nothing when no capabilities are required.", + req=fnv1.RunFunctionRequest(), + caps=[], + want=[], + ), + TestCase( + reason="Should return nothing when every capability is advertised.", + req=fnv1.RunFunctionRequest( + meta=fnv1.RequestMeta( + capabilities=[ + fnv1.CAPABILITY_CAPABILITIES, + fnv1.CAPABILITY_REQUIRED_SCHEMAS, + fnv1.CAPABILITY_DEPENDENCIES, + ], + ), + ), + caps=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_REQUIRED_SCHEMAS], + want=[], + ), + TestCase( + reason="Should return missing capabilities in order, once each.", + req=fnv1.RunFunctionRequest( + meta=fnv1.RequestMeta( + capabilities=[ + fnv1.CAPABILITY_CAPABILITIES, + fnv1.CAPABILITY_REQUIRED_SCHEMAS, + ], + ), + ), + caps=[ + fnv1.CAPABILITY_DEPENDENCIES, + fnv1.CAPABILITY_REQUIRED_SCHEMAS, + fnv1.CAPABILITY_CREDENTIALS, + fnv1.CAPABILITY_DEPENDENCIES, + ], + want=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_CREDENTIALS], + ), + TestCase( + reason="Should return every capability when none are advertised.", + req=fnv1.RunFunctionRequest(), + caps=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_REQUIRED_SCHEMAS], + want=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_REQUIRED_SCHEMAS], + ), + ] + + for case in cases: + got = request.missing_capabilities(case.req, *case.caps) + self.assertEqual(case.want, got, case.reason) + + def test_require_capabilities(self) -> None: + @dataclasses.dataclass + class TestCase: + reason: str + req: fnv1.RunFunctionRequest + caps: list[fnv1.Capability.ValueType] + want: bool + want_results: list[fnv1.Result] + + cases = [ + TestCase( + reason="Should pass when no capabilities are required, whatever " + "Crossplane advertises.", + req=fnv1.RunFunctionRequest(), + caps=[], + want=True, + want_results=[], + ), + TestCase( + reason="Should pass, adding no result, when all are advertised.", + req=fnv1.RunFunctionRequest( + meta=fnv1.RequestMeta( + capabilities=[ + fnv1.CAPABILITY_CAPABILITIES, + fnv1.CAPABILITY_DEPENDENCIES, + ], + ), + ), + caps=[fnv1.CAPABILITY_DEPENDENCIES], + want=True, + want_results=[], + ), + TestCase( + reason="Should fail with one fatal result naming every missing " + "capability.", + req=fnv1.RunFunctionRequest( + meta=fnv1.RequestMeta( + capabilities=[ + fnv1.CAPABILITY_CAPABILITIES, + fnv1.CAPABILITY_REQUIRED_SCHEMAS, + ], + ), + ), + caps=[ + fnv1.CAPABILITY_DEPENDENCIES, + fnv1.CAPABILITY_REQUIRED_SCHEMAS, + fnv1.CAPABILITY_CREDENTIALS, + ], + want=False, + want_results=[ + fnv1.Result( + severity=fnv1.SEVERITY_FATAL, + message="This function requires Crossplane capabilities that " + "Crossplane doesn't advertise: CAPABILITY_DEPENDENCIES, " + "CAPABILITY_CREDENTIALS.", + ), + ], + ), + TestCase( + reason="Should say Crossplane predates capability advertisement when " + "it advertises none.", + req=fnv1.RunFunctionRequest(), + caps=[fnv1.CAPABILITY_DEPENDENCIES], + want=False, + want_results=[ + fnv1.Result( + severity=fnv1.SEVERITY_FATAL, + message="This function requires Crossplane capabilities that " + "Crossplane doesn't advertise: CAPABILITY_DEPENDENCIES. " + "Crossplane doesn't advertise any capabilities, so it predates " + "capability advertisement (v2.2).", + ), + ], + ), + ] + + for case in cases: + rsp = fnv1.RunFunctionResponse() + got = request.require_capabilities(case.req, rsp, *case.caps) + self.assertEqual(case.want, got, case.reason) + self.assertEqual(case.want_results, list(rsp.results), case.reason) + def test_get_required_schema(self) -> None: @dataclasses.dataclass class TestCase: