Skip to content

Commit 313ddd4

Browse files
authored
Merge pull request #23 from negz/operational
Add support for operations
2 parents 66830b6 + 38fe415 commit 313ddd4

File tree

16 files changed

+361
-15
lines changed

16 files changed

+361
-15
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,5 @@ jobs:
176176
password: ${{ secrets.GITHUB_TOKEN }}
177177

178178
- name: Push Multi-Platform Package to GHCR
179+
if: env.XPKG_ACCESS_ID != ''
179180
run: "./crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.CROSSPLANE_REGORG }}:${{ env.XPKG_VERSION }}"

README.md

Lines changed: 54 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 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+
To compose resources, 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,56 @@ 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+
For more complex operations, see the [operation examples](example/operation/).
112+
113+
## Usage Notes
114+
63115
`function-python` is best for very simple cases. If writing Python inline of
64116
YAML becomes unwieldy, consider building a Python function using
65117
[function-template-python].

example/README.md renamed to example/composition/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ $ hatch run development
1010

1111
```shell
1212
# Then, in another terminal, call it with these example manifests
13-
$ crossplane beta render xr.yaml composition.yaml functions.yaml -r
13+
$ crossplane render xr.yaml composition.yaml functions.yaml -r
1414
```
File renamed without changes.
File renamed without changes.
File renamed without changes.

example/operation/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Operations Example
2+
3+
This example demonstrates using function-python with Crossplane Operations to
4+
check SSL certificate expiry for websites referenced in Kubernetes Ingress
5+
resources.
6+
7+
## Files
8+
9+
- `operation.yaml` - The Operation that checks certificate expiry
10+
- `functions.yaml` - Function definition for local development
11+
- `ingress.yaml` - Sample Ingress resource to check
12+
- `rbac.yaml` - RBAC permissions for Operations to access Ingress resources
13+
- `README.md` - This file
14+
15+
## Testing
16+
17+
Since Operations are runtime-only (they can't be statically rendered), you can
18+
test this example locally using the new `crossplane alpha render op` command.
19+
20+
### Prerequisites
21+
22+
1. Run the function in development mode:
23+
```bash
24+
hatch run development
25+
```
26+
27+
2. In another terminal, render the operation:
28+
```bash
29+
crossplane alpha render op operation.yaml functions.yaml --required-resources . -r
30+
```
31+
32+
The `-r` flag includes function results in the output, and
33+
`--required-resources .` tells the command to use the ingress.yaml file in this
34+
directory as the required resource.
35+
36+
## What it does
37+
38+
The Operation:
39+
40+
1. **Reads the Ingress** resource specified in `requirements.requiredResources`
41+
2. **Extracts the hostname** from the Ingress rules (`google.com` in this
42+
example)
43+
3. **Fetches the SSL certificate** for that hostname
44+
4. **Calculates expiry information** (days until expiration)
45+
5. **Annotates the Ingress** with certificate monitoring annotations
46+
6. **Returns status information** in the Operation's output field
47+
48+
This pattern is useful for:
49+
- Certificate monitoring and alerting
50+
- Compliance checking
51+
- Automated certificate renewal workflows
52+
- Integration with monitoring tools that read annotations
53+
54+
## Function Details
55+
56+
The operation function (`operate()`) demonstrates key Operations patterns:
57+
58+
- **Required Resources**: Accessing pre-populated resources via
59+
`request.get_required_resources(req, "ingress")`
60+
- **Resource Updates**: Using `rsp.desired.resources` to update existing
61+
resources
62+
- **Operation Output**: Using `rsp.output.update()` for monitoring data
63+
- **Server-side Apply**: Crossplane applies the changes with force ownership

example/operation/functions.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
apiVersion: pkg.crossplane.io/v1beta1
3+
kind: Function
4+
metadata:
5+
name: function-python
6+
annotations:
7+
# This tells crossplane beta render to connect to the function locally.
8+
render.crossplane.io/runtime: Development
9+
spec:
10+
# This is ignored when using the Development runtime.
11+
package: function-python

example/operation/ingress.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
apiVersion: networking.k8s.io/v1
2+
kind: Ingress
3+
metadata:
4+
name: example-ingress
5+
namespace: default
6+
spec:
7+
rules:
8+
- host: google.com
9+
http:
10+
paths:
11+
- path: /
12+
pathType: Prefix
13+
backend:
14+
service:
15+
name: example-service
16+
port:
17+
number: 80

example/operation/operation.yaml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
apiVersion: ops.crossplane.io/v1alpha1
2+
kind: Operation
3+
metadata:
4+
name: check-cert-expiry
5+
spec:
6+
mode: Pipeline
7+
pipeline:
8+
- step: check-certificate
9+
functionRef:
10+
name: function-python
11+
requirements:
12+
requiredResources:
13+
- requirementName: ingress
14+
apiVersion: networking.k8s.io/v1
15+
kind: Ingress
16+
name: example-ingress
17+
namespace: default
18+
input:
19+
apiVersion: python.fn.crossplane.io/v1beta1
20+
kind: Script
21+
script: |
22+
import ssl
23+
import socket
24+
from datetime import datetime
25+
26+
from crossplane.function import request, response
27+
28+
def operate(req, rsp):
29+
# Get the Ingress resource
30+
ingress = request.get_required_resource(req, "ingress")
31+
if not ingress:
32+
response.set_output(rsp, {"error": "No ingress resource found"})
33+
return
34+
35+
# Extract hostname from Ingress rules
36+
hostname = ingress["spec"]["rules"][0]["host"]
37+
port = 443
38+
39+
# Get SSL certificate info
40+
context = ssl.create_default_context()
41+
with socket.create_connection((hostname, port)) as sock:
42+
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
43+
cert = ssock.getpeercert()
44+
45+
# Parse expiration date
46+
expiry_date = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
47+
days_until_expiry = (expiry_date - datetime.now()).days
48+
49+
# Add warning if certificate expires soon
50+
if days_until_expiry < 30:
51+
response.warning(rsp, f"Certificate for {hostname} expires in {days_until_expiry} days")
52+
53+
# Annotate the Ingress with certificate expiry info
54+
rsp.desired.resources["ingress"].resource.update({
55+
"apiVersion": "networking.k8s.io/v1",
56+
"kind": "Ingress",
57+
"metadata": {
58+
"name": ingress["metadata"]["name"],
59+
"namespace": ingress["metadata"]["namespace"],
60+
"annotations": {
61+
"cert-monitor.crossplane.io/expires": cert['notAfter'],
62+
"cert-monitor.crossplane.io/days-until-expiry": str(days_until_expiry),
63+
"cert-monitor.crossplane.io/status": "warning" if days_until_expiry < 30 else "ok"
64+
}
65+
}
66+
})
67+
68+
# Return results in operation output for monitoring
69+
response.set_output(rsp, {
70+
"hostname": hostname,
71+
"certificateExpires": cert['notAfter'],
72+
"daysUntilExpiry": days_until_expiry,
73+
"status": "warning" if days_until_expiry < 30 else "ok"
74+
})

0 commit comments

Comments
 (0)