Skip to content

Commit 744e7d0

Browse files
committed
Add support for operations
Signed-off-by: Nic Cope <[email protected]>
1 parent 74ec271 commit 744e7d0

File tree

6 files changed

+188
-14
lines changed

6 files changed

+188
-14
lines changed

README.md

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
[![CI](https://github.com/crossplane-contrib/function-python/actions/workflows/ci.yml/badge.svg)](https://github.com/crossplane-contrib/function-python/actions/workflows/ci.yml)
44

5-
A Crossplane composition function that lets you compose resources using Python.
5+
A Crossplane composition function that lets you compose resources and run operational tasks using Python.
66

7-
Provide a Python script that defines a `compose` function with this signature:
7+
## Composition Functions
8+
9+
For traditional resource composition, provide a Python script that defines a `compose` function with this signature:
810

911
```python
1012
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
@@ -60,6 +62,61 @@ spec:
6062
rsp.desired.resources["bucket"].ready = True
6163
```
6264
65+
## Operations Functions (Alpha)
66+
67+
`function-python` also supports Crossplane Operations, which are one-time operational tasks that run to completion (like Kubernetes Jobs). For operations, provide a Python script that defines an `operate` function with this signature:
68+
69+
```python
70+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
71+
72+
def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
73+
# Your operational logic here
74+
75+
# Set output for operation monitoring
76+
rsp.output["result"] = "success"
77+
rsp.output["message"] = "Operation completed successfully"
78+
```
79+
80+
### Operation Example
81+
82+
```yaml
83+
apiVersion: ops.crossplane.io/v1alpha1
84+
kind: Operation
85+
metadata:
86+
name: check-cert-expiry
87+
spec:
88+
template:
89+
spec:
90+
pipeline:
91+
- step: check-certificate
92+
functionRef:
93+
name: function-python
94+
input:
95+
apiVersion: python.fn.crossplane.io/v1beta1
96+
kind: Script
97+
script: |
98+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
99+
import datetime
100+
101+
def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
102+
# Example: Check certificate expiration
103+
# In real use, you'd retrieve and validate actual certificates
104+
105+
# Set operation output
106+
rsp.output["check_time"] = datetime.datetime.now().isoformat()
107+
rsp.output["status"] = "healthy"
108+
rsp.output["message"] = "Certificate expiry check completed"
109+
```
110+
111+
Operations support several types:
112+
- **Operation**: One-time tasks
113+
- **CronOperation**: Scheduled recurring tasks
114+
- **WatchOperation**: Reactive tasks triggered by resource changes
115+
116+
For more complex operations, see the [operation examples](example/operation/).
117+
118+
## Usage Notes
119+
63120
`function-python` is best for very simple cases. If writing Python inline of
64121
YAML becomes unwieldy, consider building a Python function using
65122
[function-template-python].

function/fn.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ def __init__(self):
1717
self.log = logging.get_logger()
1818

1919
async def RunFunction(
20-
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
20+
self,
21+
req: fnv1.RunFunctionRequest,
22+
_: grpc.aio.ServicerContext,
2123
) -> fnv1.RunFunctionResponse:
2224
"""Run the function."""
2325
log = self.log.bind(tag=req.meta.tag)
@@ -31,7 +33,25 @@ async def RunFunction(
3133

3234
log.debug("Running script", script=req.input["script"])
3335
script = load_module("script", req.input["script"])
34-
script.compose(req, rsp)
36+
37+
has_compose = hasattr(script, "compose")
38+
has_operate = hasattr(script, "operate")
39+
40+
match (has_compose, has_operate):
41+
case (True, True):
42+
msg = "script must define only one function: compose or operate"
43+
log.debug(msg)
44+
response.fatal(rsp, msg)
45+
case (True, False):
46+
log.debug("running composition function")
47+
script.compose(req, rsp)
48+
case (False, True):
49+
log.debug("running operation function")
50+
script.operate(req, rsp)
51+
case (False, False):
52+
msg = "script must define a compose or operate function"
53+
log.debug(msg)
54+
response.fatal(rsp, msg)
3555

3656
return rsp
3757

function/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
@click.option(
2828
"--insecure",
2929
is_flag=True,
30-
help="Run without mTLS credentials. "
31-
"If you supply this flag --tls-certs-dir will be ignored.",
30+
help=(
31+
"Run without mTLS credentials. "
32+
"If you supply this flag --tls-certs-dir will be ignored."
33+
),
3234
)
3335
def cli(debug: bool, address: str, tls_certs_dir: str, insecure: bool) -> None: # noqa:FBT001 # We only expect callers via the CLI.
3436
"""A Crossplane composition function."""

package/crossplane.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@ apiVersion: meta.pkg.crossplane.io/v1beta1
33
kind: Function
44
metadata:
55
name: function-python
6-
spec: {}
6+
spec:
7+
capabilities:
8+
- composition
9+
- operation

pyproject.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ dependencies = ["ipython==9.4.0"]
4848
[tool.hatch.envs.default.scripts]
4949
development = "python function/main.py --insecure --debug"
5050

51+
# This special environment is used by hatch fmt.
52+
[tool.hatch.envs.hatch-static-analysis]
53+
dependencies = ["ruff==0.12.7"]
54+
config-path = "none" # Disable Hatch's default Ruff config.
55+
5156
[tool.hatch.envs.lint]
5257
type = "virtual"
5358
detached = true
@@ -67,9 +72,7 @@ unit = "python -m unittest tests/*.py"
6772
[tool.ruff]
6873
target-version = "py311"
6974
exclude = ["function/proto/*"]
70-
71-
[tool.ruff.lint]
72-
select = [
75+
lint.select = [
7376
"A",
7477
"ARG",
7578
"ASYNC",
@@ -99,7 +102,7 @@ select = [
99102
"W",
100103
"YTT",
101104
]
102-
ignore = ["ISC001"] # Ruff warns this is incompatible with ruff format.
105+
lint.ignore = ["ISC001"] # Ruff warns this is incompatible with ruff format.
103106

104107
[tool.ruff.lint.per-file-ignores]
105108
"tests/*" = ["D"] # Don't require docstrings for tests.

tests/test_fn.py

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from function import fn
1111

12-
script = """
12+
composition_script = """
1313
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
1414
1515
def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
@@ -24,6 +24,32 @@ def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
2424
})
2525
"""
2626

27+
operation_script = """
28+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
29+
30+
def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
31+
# Set output for operation monitoring
32+
rsp.output["result"] = "success"
33+
rsp.output["message"] = "Operation completed successfully"
34+
"""
35+
36+
both_functions_script = """
37+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
38+
39+
def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
40+
pass
41+
42+
def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
43+
pass
44+
"""
45+
46+
no_function_script = """
47+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
48+
49+
def some_other_function():
50+
pass
51+
"""
52+
2753

2854
class TestFunctionRunner(unittest.IsolatedAsyncioTestCase):
2955
def setUp(self) -> None:
@@ -41,9 +67,9 @@ class TestCase:
4167

4268
cases = [
4369
TestCase(
44-
reason="The function should return the input as a result.",
70+
reason="Function should run composition scripts with compose().",
4571
req=fnv1.RunFunctionRequest(
46-
input=resource.dict_to_struct({"script": script})
72+
input=resource.dict_to_struct({"script": composition_script}),
4773
),
4874
want=fnv1.RunFunctionResponse(
4975
meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)),
@@ -77,6 +103,69 @@ class TestCase:
77103
"-want, +got",
78104
)
79105

106+
async def test_run_operation(self) -> None:
107+
@dataclasses.dataclass
108+
class TestCase:
109+
reason: str
110+
req: fnv1.RunFunctionRequest
111+
want: fnv1.RunFunctionResponse
112+
113+
cases = [
114+
TestCase(
115+
reason="Function should run operation scripts with operate().",
116+
req=fnv1.RunFunctionRequest(
117+
input=resource.dict_to_struct({"script": operation_script}),
118+
),
119+
want=fnv1.RunFunctionResponse(
120+
meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)),
121+
desired=fnv1.State(),
122+
context=structpb.Struct(),
123+
output=resource.dict_to_struct(
124+
{
125+
"result": "success",
126+
"message": "Operation completed successfully",
127+
}
128+
),
129+
),
130+
),
131+
]
132+
133+
runner = fn.FunctionRunner()
134+
135+
for case in cases:
136+
got = await runner.RunFunction(case.req, None)
137+
self.assertEqual(
138+
json_format.MessageToDict(case.want),
139+
json_format.MessageToDict(got),
140+
"-want, +got",
141+
)
142+
143+
async def test_error_both_functions(self) -> None:
144+
"""Test that having both compose and operate functions returns an error."""
145+
runner = fn.FunctionRunner()
146+
script = both_functions_script
147+
req = fnv1.RunFunctionRequest(input=resource.dict_to_struct({"script": script}))
148+
149+
got = await runner.RunFunction(req, None)
150+
151+
# Should have a fatal error
152+
self.assertEqual(len(got.results), 1)
153+
self.assertEqual(got.results[0].severity, fnv1.Severity.SEVERITY_FATAL)
154+
self.assertIn("only one function: compose or operate", got.results[0].message)
155+
156+
async def test_error_no_functions(self) -> None:
157+
"""Test that having neither compose nor operate functions returns an error."""
158+
runner = fn.FunctionRunner()
159+
script = no_function_script
160+
req = fnv1.RunFunctionRequest(input=resource.dict_to_struct({"script": script}))
161+
162+
got = await runner.RunFunction(req, None)
163+
164+
# Should have a fatal error
165+
self.assertEqual(len(got.results), 1)
166+
self.assertEqual(got.results[0].severity, fnv1.Severity.SEVERITY_FATAL)
167+
self.assertIn("compose or operate function", got.results[0].message)
168+
80169

81170
if __name__ == "__main__":
82171
unittest.main()

0 commit comments

Comments
 (0)