Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,5 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}

- name: Push Multi-Platform Package to GHCR
if: env.XPKG_ACCESS_ID != ''
run: "./crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.CROSSPLANE_REGORG }}:${{ env.XPKG_VERSION }}"
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

[![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)

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

Provide a Python script that defines a `compose` function with this signature:
## Composition Functions

To compose resources, provide a Python script that defines a `compose` function with this signature:

```python
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
Expand Down Expand Up @@ -60,6 +62,56 @@ spec:
rsp.desired.resources["bucket"].ready = True
```

## Operations Functions (Alpha)

`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:

```python
from crossplane.function.proto.v1 import run_function_pb2 as fnv1

def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
# Your operational logic here

# Set output for operation monitoring
rsp.output["result"] = "success"
rsp.output["message"] = "Operation completed successfully"
```

### Operation Example

```yaml
apiVersion: ops.crossplane.io/v1alpha1
kind: Operation
metadata:
name: check-cert-expiry
spec:
template:
spec:
pipeline:
- step: check-certificate
functionRef:
name: function-python
input:
apiVersion: python.fn.crossplane.io/v1beta1
kind: Script
script: |
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
import datetime

def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
# Example: Check certificate expiration
# In real use, you'd retrieve and validate actual certificates

# Set operation output
rsp.output["check_time"] = datetime.datetime.now().isoformat()
rsp.output["status"] = "healthy"
rsp.output["message"] = "Certificate expiry check completed"
```

For more complex operations, see the [operation examples](example/operation/).

## Usage Notes

`function-python` is best for very simple cases. If writing Python inline of
YAML becomes unwieldy, consider building a Python function using
[function-template-python].
Expand Down
2 changes: 1 addition & 1 deletion example/README.md → example/composition/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ $ hatch run development

```shell
# Then, in another terminal, call it with these example manifests
$ crossplane beta render xr.yaml composition.yaml functions.yaml -r
$ crossplane render xr.yaml composition.yaml functions.yaml -r
```
File renamed without changes.
File renamed without changes.
File renamed without changes.
63 changes: 63 additions & 0 deletions example/operation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Operations Example

This example demonstrates using function-python with Crossplane Operations to
check SSL certificate expiry for websites referenced in Kubernetes Ingress
resources.

## Files

- `operation.yaml` - The Operation that checks certificate expiry
- `functions.yaml` - Function definition for local development
- `ingress.yaml` - Sample Ingress resource to check
- `rbac.yaml` - RBAC permissions for Operations to access Ingress resources
- `README.md` - This file

## Testing

Since Operations are runtime-only (they can't be statically rendered), you can
test this example locally using the new `crossplane alpha render op` command.

### Prerequisites

1. Run the function in development mode:
```bash
hatch run development
```

2. In another terminal, render the operation:
```bash
crossplane alpha render op operation.yaml functions.yaml --required-resources . -r
```

The `-r` flag includes function results in the output, and
`--required-resources .` tells the command to use the ingress.yaml file in this
directory as the required resource.

## What it does

The Operation:

1. **Reads the Ingress** resource specified in `requirements.requiredResources`
2. **Extracts the hostname** from the Ingress rules (`google.com` in this
example)
3. **Fetches the SSL certificate** for that hostname
4. **Calculates expiry information** (days until expiration)
5. **Annotates the Ingress** with certificate monitoring annotations
6. **Returns status information** in the Operation's output field

This pattern is useful for:
- Certificate monitoring and alerting
- Compliance checking
- Automated certificate renewal workflows
- Integration with monitoring tools that read annotations

## Function Details

The operation function (`operate()`) demonstrates key Operations patterns:

- **Required Resources**: Accessing pre-populated resources via
`request.get_required_resources(req, "ingress")`
- **Resource Updates**: Using `rsp.desired.resources` to update existing
resources
- **Operation Output**: Using `rsp.output.update()` for monitoring data
- **Server-side Apply**: Crossplane applies the changes with force ownership
11 changes: 11 additions & 0 deletions example/operation/functions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-python
annotations:
# This tells crossplane beta render to connect to the function locally.
render.crossplane.io/runtime: Development
spec:
# This is ignored when using the Development runtime.
package: function-python
17 changes: 17 additions & 0 deletions example/operation/ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-ingress
namespace: default
spec:
rules:
- host: google.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: example-service
port:
number: 80
74 changes: 74 additions & 0 deletions example/operation/operation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
apiVersion: ops.crossplane.io/v1alpha1
kind: Operation
metadata:
name: check-cert-expiry
spec:
mode: Pipeline
pipeline:
- step: check-certificate
functionRef:
name: function-python
requirements:
requiredResources:
- requirementName: ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
name: example-ingress
namespace: default
input:
apiVersion: python.fn.crossplane.io/v1beta1
kind: Script
script: |
import ssl
import socket
from datetime import datetime

from crossplane.function import request, response

def operate(req, rsp):
# Get the Ingress resource
ingress = request.get_required_resource(req, "ingress")
if not ingress:
response.set_output(rsp, {"error": "No ingress resource found"})
return

# Extract hostname from Ingress rules
hostname = ingress["spec"]["rules"][0]["host"]
port = 443

# Get SSL certificate info
context = ssl.create_default_context()
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()

# Parse expiration date
expiry_date = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
days_until_expiry = (expiry_date - datetime.now()).days

# Add warning if certificate expires soon
if days_until_expiry < 30:
response.warning(rsp, f"Certificate for {hostname} expires in {days_until_expiry} days")

# Annotate the Ingress with certificate expiry info
rsp.desired.resources["ingress"].resource.update({
"apiVersion": "networking.k8s.io/v1",
"kind": "Ingress",
"metadata": {
"name": ingress["metadata"]["name"],
"namespace": ingress["metadata"]["namespace"],
"annotations": {
"cert-monitor.crossplane.io/expires": cert['notAfter'],
"cert-monitor.crossplane.io/days-until-expiry": str(days_until_expiry),
"cert-monitor.crossplane.io/status": "warning" if days_until_expiry < 30 else "ok"
}
}
})

# Return results in operation output for monitoring
response.set_output(rsp, {
"hostname": hostname,
"certificateExpires": cert['notAfter'],
"daysUntilExpiry": days_until_expiry,
"status": "warning" if days_until_expiry < 30 else "ok"
})
11 changes: 11 additions & 0 deletions example/operation/rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: operation-ingress-access
labels:
rbac.crossplane.io/aggregate-to-crossplane: "true"
rules:
# Allow Operations to read and update Ingress resources
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "patch", "update"]
24 changes: 22 additions & 2 deletions function/fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def __init__(self):
self.log = logging.get_logger()

async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
self,
req: fnv1.RunFunctionRequest,
_: grpc.aio.ServicerContext,
) -> fnv1.RunFunctionResponse:
"""Run the function."""
log = self.log.bind(tag=req.meta.tag)
Expand All @@ -31,7 +33,25 @@ async def RunFunction(

log.debug("Running script", script=req.input["script"])
script = load_module("script", req.input["script"])
script.compose(req, rsp)

has_compose = hasattr(script, "compose")
has_operate = hasattr(script, "operate")

match (has_compose, has_operate):
case (True, True):
msg = "script must define only one function: compose or operate"
log.debug(msg)
response.fatal(rsp, msg)
case (True, False):
log.debug("running composition function")
script.compose(req, rsp)
case (False, True):
log.debug("running operation function")
script.operate(req, rsp)
case (False, False):
msg = "script must define a compose or operate function"
log.debug(msg)
response.fatal(rsp, msg)

return rsp

Expand Down
6 changes: 4 additions & 2 deletions function/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
@click.option(
"--insecure",
is_flag=True,
help="Run without mTLS credentials. "
"If you supply this flag --tls-certs-dir will be ignored.",
help=(
"Run without mTLS credentials. "
"If you supply this flag --tls-certs-dir will be ignored."
),
)
def cli(debug: bool, address: str, tls_certs_dir: str, insecure: bool) -> None: # noqa:FBT001 # We only expect callers via the CLI.
"""A Crossplane composition function."""
Expand Down
5 changes: 4 additions & 1 deletion package/crossplane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ apiVersion: meta.pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-python
spec: {}
spec:
capabilities:
- composition
- operation
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ dependencies = ["ipython==9.4.0"]
[tool.hatch.envs.default.scripts]
development = "python function/main.py --insecure --debug"

# This special environment is used by hatch fmt.
[tool.hatch.envs.hatch-static-analysis]
dependencies = ["ruff==0.12.7"]
config-path = "none" # Disable Hatch's default Ruff config.

[tool.hatch.envs.lint]
type = "virtual"
detached = true
Expand All @@ -67,9 +72,7 @@ unit = "python -m unittest tests/*.py"
[tool.ruff]
target-version = "py311"
exclude = ["function/proto/*"]

[tool.ruff.lint]
select = [
lint.select = [
"A",
"ARG",
"ASYNC",
Expand Down Expand Up @@ -99,7 +102,7 @@ select = [
"W",
"YTT",
]
ignore = ["ISC001"] # Ruff warns this is incompatible with ruff format.
lint.ignore = ["ISC001"] # Ruff warns this is incompatible with ruff format.

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