Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v6
Expand All @@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v6
Expand Down Expand Up @@ -94,7 +94,7 @@ jobs:
install: true

- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

# We ask Docker to use GitHub Action's native caching support to speed up
# the build, per https://docs.docker.com/build/cache/backends/gha/.
Expand Down Expand Up @@ -134,7 +134,7 @@ jobs:
- build
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Download Single-Platform Packages
uses: actions/download-artifact@v8
Expand Down
1 change: 1 addition & 0 deletions OWNERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

* Nic Cope <negz@upbound.io> ([negz](https://github.com/negz))
* Bob Haddleton <bob.haddleton@nokia.com> ([bobh66](https://github.com/bobh66))
* Yury Tsarev <yury@upbound.io> ([ytsarev](https://github.com/ytsarev))
45 changes: 32 additions & 13 deletions function/fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import importlib.util
import inspect
import traceback
import types

import grpc
Expand All @@ -24,12 +25,12 @@ async def RunFunction(
) -> fnv1.RunFunctionResponse:
"""Run the function."""
log = self.log.bind(tag=req.meta.tag)
log.info("Running function")
log.debug("Running function")

rsp = response.to(req)

if req.input["script"] is None:
response.fatal(rsp, "missing script")
if "script" not in req.input or not req.input["script"]:
response.fatal(rsp, "missing script in function input")
return rsp

log.debug("Running script", script=req.input["script"])
Expand All @@ -44,22 +45,40 @@ async def RunFunction(
log.debug(msg)
response.fatal(rsp, msg)
case (True, False):
log.debug("running composition function")
if inspect.iscoroutinefunction(script.compose):
await script.compose(req, rsp)
else:
script.compose(req, rsp)
try:
log.debug("running composition function")
if inspect.iscoroutinefunction(script.compose):
await script.compose(req, rsp)
else:
script.compose(req, rsp)
except Exception as e:
msg = (
f"Exception: {type(e)}, "
f"traceback: {traceback.format_tb(e.__traceback__.tb_next)}"
)
log.debug(msg)
response.fatal(rsp, msg)

case (False, True):
log.debug("running operation function")
if inspect.iscoroutinefunction(script.operate):
await script.operate(req, rsp)
else:
script.operate(req, rsp)
try:
if inspect.iscoroutinefunction(script.operate):
await script.operate(req, rsp)
else:
script.operate(req, rsp)
except Exception as e:
msg = (
f"Exception: {e}, "
f"traceback: {traceback.format_tb(e.__traceback__.tb_next)}"
)
log.debug(msg)
response.fatal(rsp, msg)

case (False, False):
msg = "script must define a compose or operate function"
log.debug(msg)
response.fatal(rsp, msg)

log.debug(f"Response: {rsp}")
return rsp


Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ classifiers = [
]

dependencies = [
"crossplane-function-sdk-python==0.11.0",
"click==8.3.1",
"grpcio==1.76.0",
"crossplane-function-sdk-python==0.14.0",
"click==8.4.2",
"grpcio>=1.76.0",
"aioboto3==15.5.0",
]

Expand Down
138 changes: 138 additions & 0 deletions tests/test_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
})
"""

composition_script_with_exception = """
from crossplane.function.proto.v1 import run_function_pb2 as fnv1

def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
rsp.desired.resources["bucket"].resource.update({
"apiVersion": "s3.aws.upbound.io/v1beta2",
"kind": "Bucket",
"spec": {
"forProvider": {
"region": "us-east-1"
}
},
})
raise AttributeError
"""

async_composition_script = """
from crossplane.function.proto.v1 import run_function_pb2 as fnv1

Expand All @@ -39,6 +55,22 @@ async def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
})
"""

async_composition_script_with_exception = """
from crossplane.function.proto.v1 import run_function_pb2 as fnv1

async def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse):
rsp.desired.resources["bucket"].resource.update({
"apiVersion": "s3.aws.upbound.io/v1beta2",
"kind": "Bucket",
"spec": {
"forProvider": {
"region": "us-east-1"
}
},
})
raise AttributeError
"""

operation_script = """
from crossplane.function.proto.v1 import run_function_pb2 as fnv1

Expand Down Expand Up @@ -140,6 +172,112 @@ class TestCase:
context=structpb.Struct(),
),
),
TestCase(
reason="Function should fail gracefully when script is missing.",
req=fnv1.RunFunctionRequest(
input=resource.dict_to_struct({}),
),
want=fnv1.RunFunctionResponse(
meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)),
results=[
{
"message": "missing script in function input",
"severity": "SEVERITY_FATAL",
}
],
desired=fnv1.State(),
context=structpb.Struct(),
),
),
TestCase(
reason="Function should fail gracefully when script is empty.",
req=fnv1.RunFunctionRequest(
input=resource.dict_to_struct({"script": ""}),
),
want=fnv1.RunFunctionResponse(
meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)),
results=[
{
"message": "missing script in function input",
"severity": "SEVERITY_FATAL",
}
],
desired=fnv1.State(),
context=structpb.Struct(),
),
),
TestCase(
reason="Function should fail gracefully when compose script raises"
" an exception.",
req=fnv1.RunFunctionRequest(
input=resource.dict_to_struct(
{"script": composition_script_with_exception}
),
),
want=fnv1.RunFunctionResponse(
meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)),
results=[
{
"message": "Exception: <class 'AttributeError'>, traceback:"
" [' File \"<string>\", line 14, in compose\\n'"
"]",
"severity": "SEVERITY_FATAL",
}
],
desired=fnv1.State(
resources={
"bucket": fnv1.Resource(
resource=resource.dict_to_struct(
{
"apiVersion": "s3.aws.upbound.io/v1beta2",
"kind": "Bucket",
"spec": {
"forProvider": {"region": "us-east-1"}
},
}
)
)
}
),
context=structpb.Struct(),
),
),
TestCase(
reason="Function should fail gracefully when async compose script"
" raises an exception.",
req=fnv1.RunFunctionRequest(
input=resource.dict_to_struct(
{"script": async_composition_script_with_exception}
),
),
want=fnv1.RunFunctionResponse(
meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)),
results=[
{
"message": "Exception: <class 'AttributeError'>, traceback:"
" [' File \"<string>\", line 14, in compose\\n'"
"]",
"severity": "SEVERITY_FATAL",
}
],
desired=fnv1.State(
resources={
"bucket": fnv1.Resource(
resource=resource.dict_to_struct(
{
"apiVersion": "s3.aws.upbound.io/v1beta2",
"kind": "Bucket",
"spec": {
"forProvider": {"region": "us-east-1"}
},
}
)
)
}
),
context=structpb.Struct(),
),
),
]

runner = fn.FunctionRunner()
Expand Down
Loading