diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c85c053..38b041b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }}" diff --git a/README.md b/README.md index 9b098bb..ec6de9c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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]. diff --git a/example/README.md b/example/composition/README.md similarity index 79% rename from example/README.md rename to example/composition/README.md index d6dfbd1..2881303 100644 --- a/example/README.md +++ b/example/composition/README.md @@ -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 ``` \ No newline at end of file diff --git a/example/composition.yaml b/example/composition/composition.yaml similarity index 100% rename from example/composition.yaml rename to example/composition/composition.yaml diff --git a/example/functions.yaml b/example/composition/functions.yaml similarity index 100% rename from example/functions.yaml rename to example/composition/functions.yaml diff --git a/example/xr.yaml b/example/composition/xr.yaml similarity index 100% rename from example/xr.yaml rename to example/composition/xr.yaml diff --git a/example/operation/README.md b/example/operation/README.md new file mode 100644 index 0000000..ac37d54 --- /dev/null +++ b/example/operation/README.md @@ -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 \ No newline at end of file diff --git a/example/operation/functions.yaml b/example/operation/functions.yaml new file mode 100644 index 0000000..42f960b --- /dev/null +++ b/example/operation/functions.yaml @@ -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 \ No newline at end of file diff --git a/example/operation/ingress.yaml b/example/operation/ingress.yaml new file mode 100644 index 0000000..d402969 --- /dev/null +++ b/example/operation/ingress.yaml @@ -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 \ No newline at end of file diff --git a/example/operation/operation.yaml b/example/operation/operation.yaml new file mode 100644 index 0000000..0c99f54 --- /dev/null +++ b/example/operation/operation.yaml @@ -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" + }) \ No newline at end of file diff --git a/example/operation/rbac.yaml b/example/operation/rbac.yaml new file mode 100644 index 0000000..f741e3c --- /dev/null +++ b/example/operation/rbac.yaml @@ -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"] \ No newline at end of file diff --git a/function/fn.py b/function/fn.py index 212acd0..9aebedc 100644 --- a/function/fn.py +++ b/function/fn.py @@ -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) @@ -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 diff --git a/function/main.py b/function/main.py index 7ea1197..6ed41d7 100644 --- a/function/main.py +++ b/function/main.py @@ -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.""" diff --git a/package/crossplane.yaml b/package/crossplane.yaml index 9f8e786..442815a 100644 --- a/package/crossplane.yaml +++ b/package/crossplane.yaml @@ -3,4 +3,7 @@ apiVersion: meta.pkg.crossplane.io/v1beta1 kind: Function metadata: name: function-python -spec: {} +spec: + capabilities: + - composition + - operation diff --git a/pyproject.toml b/pyproject.toml index b1cdb0b..6b43b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 @@ -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", @@ -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. diff --git a/tests/test_fn.py b/tests/test_fn.py index 93c25dd..63cfd85 100644 --- a/tests/test_fn.py +++ b/tests/test_fn.py @@ -9,7 +9,7 @@ from function import fn -script = """ +composition_script = """ from crossplane.function.proto.v1 import run_function_pb2 as fnv1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): @@ -24,6 +24,32 @@ def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): }) """ +operation_script = """ +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 + +def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): + # Set output for operation monitoring + rsp.output["result"] = "success" + rsp.output["message"] = "Operation completed successfully" +""" + +both_functions_script = """ +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 + +def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): + pass + +def operate(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): + pass +""" + +no_function_script = """ +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 + +def some_other_function(): + pass +""" + class TestFunctionRunner(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: @@ -41,9 +67,9 @@ class TestCase: cases = [ TestCase( - reason="The function should return the input as a result.", + reason="Function should run composition scripts with compose().", req=fnv1.RunFunctionRequest( - input=resource.dict_to_struct({"script": script}) + input=resource.dict_to_struct({"script": composition_script}), ), want=fnv1.RunFunctionResponse( meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), @@ -77,6 +103,69 @@ class TestCase: "-want, +got", ) + async def test_run_operation(self) -> None: + @dataclasses.dataclass + class TestCase: + reason: str + req: fnv1.RunFunctionRequest + want: fnv1.RunFunctionResponse + + cases = [ + TestCase( + reason="Function should run operation scripts with operate().", + req=fnv1.RunFunctionRequest( + input=resource.dict_to_struct({"script": operation_script}), + ), + want=fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State(), + context=structpb.Struct(), + output=resource.dict_to_struct( + { + "result": "success", + "message": "Operation completed successfully", + } + ), + ), + ), + ] + + runner = fn.FunctionRunner() + + for case in cases: + got = await runner.RunFunction(case.req, None) + self.assertEqual( + json_format.MessageToDict(case.want), + json_format.MessageToDict(got), + "-want, +got", + ) + + async def test_error_both_functions(self) -> None: + """Test that having both compose and operate functions returns an error.""" + runner = fn.FunctionRunner() + script = both_functions_script + req = fnv1.RunFunctionRequest(input=resource.dict_to_struct({"script": script})) + + got = await runner.RunFunction(req, None) + + # Should have a fatal error + self.assertEqual(len(got.results), 1) + self.assertEqual(got.results[0].severity, fnv1.Severity.SEVERITY_FATAL) + self.assertIn("only one function: compose or operate", got.results[0].message) + + async def test_error_no_functions(self) -> None: + """Test that having neither compose nor operate functions returns an error.""" + runner = fn.FunctionRunner() + script = no_function_script + req = fnv1.RunFunctionRequest(input=resource.dict_to_struct({"script": script})) + + got = await runner.RunFunction(req, None) + + # Should have a fatal error + self.assertEqual(len(got.results), 1) + self.assertEqual(got.results[0].severity, fnv1.Severity.SEVERITY_FATAL) + self.assertIn("compose or operate function", got.results[0].message) + if __name__ == "__main__": unittest.main()