Skip to content

Commit 12be880

Browse files
committed
Add support for operation functions
This PR adds helpers to the Python SDK to make writing operation functions easier. Operation functions use the same FunctionRunnerService RPC as composition functions, but they have different patterns for accessing required resources and returning output data. The new request.py module provides functions to get required resources (previously called "extra resources" in composition functions) and includes a helper specifically for accessing watched resources in WatchOperations. The response.py module gets new functions to set operation output and build resource requirements Signed-off-by: Nic Cope <[email protected]>
1 parent 0551df7 commit 12be880

File tree

5 files changed

+512
-0
lines changed

5 files changed

+512
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,6 @@ cython_debug/
161161

162162
# We don't commit our docs - instead we generate them and upload to GitHub pages.
163163
docs
164+
165+
# AIs
166+
.claude/

crossplane/function/request.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2025 The Crossplane Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Utilities for working with RunFunctionRequests."""
16+
17+
import crossplane.function.proto.v1.run_function_pb2 as fnv1
18+
from crossplane.function import resource
19+
20+
21+
def get_required_resources(req: fnv1.RunFunctionRequest, name: str) -> list[dict]:
22+
"""Get required resources by name from the request.
23+
24+
Args:
25+
req: The RunFunctionRequest containing required resources.
26+
name: The name of the required resource set to get.
27+
28+
Returns:
29+
A list of resources as dictionaries. Empty list if not found.
30+
31+
Required resources are previously called "extra resources" in composition
32+
functions. For operation functions, there are no observed resources, so
33+
all resources are "required" resources that the function requested.
34+
"""
35+
if name not in req.required_resources:
36+
return []
37+
38+
return [
39+
resource.struct_to_dict(item.resource)
40+
for item in req.required_resources[name].items
41+
]
42+
43+
44+
def get_watched_resource(req: fnv1.RunFunctionRequest) -> dict | None:
45+
"""Get the watched resource that triggered this operation.
46+
47+
Args:
48+
req: The RunFunctionRequest to check for a watched resource.
49+
50+
Returns:
51+
The watched resource as a dictionary, or None if not found.
52+
53+
When a WatchOperation creates an Operation, it injects the resource that
54+
changed using the special requirement name 'ops.crossplane.io/watched-resource'.
55+
This helper makes it easy to access that resource.
56+
"""
57+
watched = get_required_resources(req, "ops.crossplane.io/watched-resource")
58+
return watched[0] if watched else None
59+
60+
61+
def get_required_resource(req: fnv1.RunFunctionRequest, name: str) -> dict | None:
62+
"""Get a single required resource by name from the request.
63+
64+
Args:
65+
req: The RunFunctionRequest containing required resources.
66+
name: The name of the required resource to get.
67+
68+
Returns:
69+
The first resource as a dictionary, or None if not found.
70+
71+
This is a convenience function for when you know there should be exactly
72+
one resource with the given requirement name.
73+
"""
74+
resources = get_required_resources(req, name)
75+
return resources[0] if resources else None

crossplane/function/response.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
import datetime
1818

1919
from google.protobuf import duration_pb2 as durationpb
20+
from google.protobuf import struct_pb2 as structpb
2021

2122
import crossplane.function.proto.v1.run_function_pb2 as fnv1
23+
from crossplane.function import resource
2224

2325
"""The default TTL for which a RunFunctionResponse may be cached."""
2426
DEFAULT_TTL = datetime.timedelta(minutes=1)
@@ -77,3 +79,73 @@ def fatal(rsp: fnv1.RunFunctionResponse, message: str) -> None:
7779
message=message,
7880
)
7981
)
82+
83+
84+
def set_output(rsp: fnv1.RunFunctionResponse, output: dict | structpb.Struct) -> None:
85+
"""Set the output field in a RunFunctionResponse for operation functions.
86+
87+
Args:
88+
rsp: The RunFunctionResponse to update.
89+
output: The output data as a dictionary or protobuf Struct.
90+
91+
Operation functions can return arbitrary output data that will be written
92+
to the Operation's status.pipeline field. This function sets that output
93+
on the response.
94+
"""
95+
match output:
96+
case dict():
97+
rsp.output.CopyFrom(resource.dict_to_struct(output))
98+
case structpb.Struct():
99+
rsp.output.CopyFrom(output)
100+
case _:
101+
t = type(output)
102+
msg = f"Unsupported output type: {t}"
103+
raise TypeError(msg)
104+
105+
106+
def require_resources( # noqa: PLR0913
107+
rsp: fnv1.RunFunctionResponse,
108+
name: str,
109+
api_version: str,
110+
kind: str,
111+
*,
112+
match_name: str | None = None,
113+
match_labels: dict[str, str] | None = None,
114+
namespace: str | None = None,
115+
) -> None:
116+
"""Add a resource requirement to the response.
117+
118+
Args:
119+
rsp: The RunFunctionResponse to update.
120+
name: The name to use for this requirement.
121+
api_version: The API version of resources to require.
122+
kind: The kind of resources to require.
123+
match_name: Match a resource by name (mutually exclusive with match_labels).
124+
match_labels: Match resources by labels (mutually exclusive with match_name).
125+
namespace: The namespace to search in (optional).
126+
127+
Raises:
128+
ValueError: If both match_name and match_labels are provided, or neither.
129+
130+
This tells Crossplane to fetch the specified resources and include them
131+
in the next call to the function in req.required_resources[name].
132+
"""
133+
if (match_name is None) == (match_labels is None):
134+
msg = "Exactly one of match_name or match_labels must be provided"
135+
raise ValueError(msg)
136+
137+
selector = fnv1.ResourceSelector(
138+
api_version=api_version,
139+
kind=kind,
140+
)
141+
142+
if match_name is not None:
143+
selector.match_name = match_name
144+
145+
if match_labels is not None:
146+
selector.match_labels.labels.update(match_labels)
147+
148+
if namespace is not None:
149+
selector.namespace = namespace
150+
151+
rsp.requirements.resources[name].CopyFrom(selector)

tests/test_request.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Copyright 2025 The Crossplane Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import dataclasses
16+
import unittest
17+
18+
import crossplane.function.proto.v1.run_function_pb2 as fnv1
19+
from crossplane.function import logging, request, resource
20+
21+
22+
class TestRequest(unittest.TestCase):
23+
def setUp(self) -> None:
24+
logging.configure(level=logging.Level.DISABLED)
25+
26+
def test_get_required_resources(self) -> None:
27+
@dataclasses.dataclass
28+
class TestCase:
29+
reason: str
30+
req: fnv1.RunFunctionRequest
31+
name: str
32+
want: list[dict]
33+
34+
cases = [
35+
TestCase(
36+
reason="Should return empty list when requirement name not found.",
37+
req=fnv1.RunFunctionRequest(),
38+
name="non-existent",
39+
want=[],
40+
),
41+
TestCase(
42+
reason="Should return resources when requirement name exists.",
43+
req=fnv1.RunFunctionRequest(
44+
required_resources={
45+
"test-resources": fnv1.Resources(
46+
items=[
47+
fnv1.Resource(
48+
resource=resource.dict_to_struct(
49+
{
50+
"apiVersion": "v1",
51+
"kind": "Pod",
52+
"metadata": {"name": "test-pod"},
53+
}
54+
)
55+
),
56+
fnv1.Resource(
57+
resource=resource.dict_to_struct(
58+
{
59+
"apiVersion": "v1",
60+
"kind": "Service",
61+
"metadata": {"name": "test-svc"},
62+
}
63+
)
64+
),
65+
]
66+
)
67+
}
68+
),
69+
name="test-resources",
70+
want=[
71+
{
72+
"apiVersion": "v1",
73+
"kind": "Pod",
74+
"metadata": {"name": "test-pod"},
75+
},
76+
{
77+
"apiVersion": "v1",
78+
"kind": "Service",
79+
"metadata": {"name": "test-svc"},
80+
},
81+
],
82+
),
83+
]
84+
85+
for case in cases:
86+
got = request.get_required_resources(case.req, case.name)
87+
self.assertEqual(case.want, got, case.reason)
88+
89+
def test_get_watched_resource(self) -> None:
90+
@dataclasses.dataclass
91+
class TestCase:
92+
reason: str
93+
req: fnv1.RunFunctionRequest
94+
want: dict | None
95+
96+
cases = [
97+
TestCase(
98+
reason="Should return None when no watched resource exists.",
99+
req=fnv1.RunFunctionRequest(),
100+
want=None,
101+
),
102+
TestCase(
103+
reason="Should return watched resource when it exists.",
104+
req=fnv1.RunFunctionRequest(
105+
required_resources={
106+
"ops.crossplane.io/watched-resource": fnv1.Resources(
107+
items=[
108+
fnv1.Resource(
109+
resource=resource.dict_to_struct(
110+
{
111+
"apiVersion": "example.org/v1",
112+
"kind": "App",
113+
"metadata": {"name": "watched-app"},
114+
}
115+
)
116+
)
117+
]
118+
)
119+
}
120+
),
121+
want={
122+
"apiVersion": "example.org/v1",
123+
"kind": "App",
124+
"metadata": {"name": "watched-app"},
125+
},
126+
),
127+
]
128+
129+
for case in cases:
130+
got = request.get_watched_resource(case.req)
131+
self.assertEqual(case.want, got, case.reason)
132+
133+
def test_get_required_resource(self) -> None:
134+
@dataclasses.dataclass
135+
class TestCase:
136+
reason: str
137+
req: fnv1.RunFunctionRequest
138+
name: str
139+
want: dict | None
140+
141+
cases = [
142+
TestCase(
143+
reason="Should return None when requirement name not found.",
144+
req=fnv1.RunFunctionRequest(),
145+
name="non-existent",
146+
want=None,
147+
),
148+
TestCase(
149+
reason="Should return first resource when requirement name exists.",
150+
req=fnv1.RunFunctionRequest(
151+
required_resources={
152+
"single-resource": fnv1.Resources(
153+
items=[
154+
fnv1.Resource(
155+
resource=resource.dict_to_struct(
156+
{
157+
"apiVersion": "v1",
158+
"kind": "ConfigMap",
159+
"metadata": {"name": "test-cm"},
160+
}
161+
)
162+
)
163+
]
164+
)
165+
}
166+
),
167+
name="single-resource",
168+
want={
169+
"apiVersion": "v1",
170+
"kind": "ConfigMap",
171+
"metadata": {"name": "test-cm"},
172+
},
173+
),
174+
TestCase(
175+
reason="Should return first resource when multiple resources exist.",
176+
req=fnv1.RunFunctionRequest(
177+
required_resources={
178+
"multi-resource": fnv1.Resources(
179+
items=[
180+
fnv1.Resource(
181+
resource=resource.dict_to_struct(
182+
{
183+
"apiVersion": "v1",
184+
"kind": "Secret",
185+
"metadata": {"name": "first-secret"},
186+
}
187+
)
188+
),
189+
fnv1.Resource(
190+
resource=resource.dict_to_struct(
191+
{
192+
"apiVersion": "v1",
193+
"kind": "Secret",
194+
"metadata": {"name": "second-secret"},
195+
}
196+
)
197+
),
198+
]
199+
)
200+
}
201+
),
202+
name="multi-resource",
203+
want={
204+
"apiVersion": "v1",
205+
"kind": "Secret",
206+
"metadata": {"name": "first-secret"},
207+
},
208+
),
209+
]
210+
211+
for case in cases:
212+
got = request.get_required_resource(case.req, case.name)
213+
self.assertEqual(case.want, got, case.reason)
214+
215+
216+
if __name__ == "__main__":
217+
unittest.main()

0 commit comments

Comments
 (0)