Break the SDK out into its own repo

I prototyped this in https://github.com/negz/function-auto-python.

Signed-off-by: Nic Cope <nicc@rk0n.org>
This commit is contained in:
Nic Cope 2023-11-19 13:37:50 -08:00
parent e930e1ddf8
commit e247d8766e
16 changed files with 1231 additions and 1 deletions

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,40 @@
---
name: Bug Report
about: Help us diagnose and fix bugs in this Function
labels: bug
---
<!--
Thank you for helping to improve Crossplane!
Please be sure to search for open issues before raising a new one. We use issues
for bug reports and feature requests. Please find us at https://slack.crossplane.io
for questions, support, and discussion.
-->
### What happened?
<!--
Please let us know what behaviour you expected and how this Function diverged
from that behaviour.
-->
### How can we reproduce it?
<!--
Help us to reproduce your bug as succinctly and precisely as possible. Artifacts
such as example manifests or a script that triggers the issue are highly
appreciated!
-->
### What environment did it happen in?
Function version:
<!--
Include at least the version or commit of Crossplane you were running. Consider
also including your:
* Cloud provider or hardware configuration
* Kubernetes version (use `kubectl version`)
* Kubernetes distribution (e.g. Tectonic, GKE, OpenShift)
* OS (e.g. from /etc/os-release)
* Kernel (e.g. `uname -a`)
-->

View File

@ -0,0 +1,24 @@
---
name: Feature Request
about: Help us make this Function more useful
labels: enhancement
---
<!--
Thank you for helping to improve Crossplane!
Please be sure to search for open issues before raising a new one. We use issues
for bug reports and feature requests. Please find us at https://slack.crossplane.io
for questions, support, and discussion.
-->
### What problem are you facing?
<!--
Please tell us a little about your use case - it's okay if it's hypothetical!
Leading with this context helps frame the feature request so we can ensure we
implement it sensibly.
--->
### How could this Function help solve your problem?
<!--
Let us know how you think this Function could help with your use case.
-->

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,30 @@
<!--
Thank you for helping to improve Crossplane!
Please read through https://git.io/fj2m9 if this is your first time opening a
Crossplane pull request. Find us in https://slack.crossplane.io/messages/dev if
you need any help contributing.
-->
### Description of your changes
<!--
Briefly describe what this pull request does, and how it is covered by tests.
Be proactive - direct your reviewers' attention to anything that needs special
consideration.
You MUST either [x] check or ~strikethrough~ every item in the checklist below.
We love pull requests that fix an open issue. If yours does, use the below line
to indicate which issue it fixes, for example "Fixes #500".
-->
Fixes #
I have:
- [ ] Read and followed Crossplane's [contribution process].
- [ ] Added or updated unit tests for my change.
[contribution process]: https://git.io/fj2m9
[docs]: https://docs.crossplane.io/contribute/contribute

80
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: CI
on:
push:
branches:
- main
- release-*
pull_request: {}
workflow_dispatch:
inputs:
version:
description: PyPI module version (e.g. v0.1.0)
required: false
env:
# Common versions
PYTHON_VERSION: '3.11.5'
jobs:
lint:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Hatch
run: pipx install hatch==1.7.0
- name: Lint
run: |
hatch run lint:check-format
hatch run lint:check
unit-test:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Hatch
run: pipx install hatch==1.7.0
- name: Run Unit Tests
run: hatch run test:unit
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Hatch
run: pipx install hatch==1.7.0
- name: Run Unit Tests
run: hatch build
- name: Upload Sdist and Wheel to GitHub
uses: actions/upload-artifact@v3
with:
name: module
path: "dist/*"
if-no-files-found: error
retention-days: 1

View File

@ -1,2 +1,39 @@
# function-sdk-python
The Python SDK for composition functions
[![CI](https://github.com/crossplane/function-sdk-python/actions/workflows/ci.yml/badge.svg)](https://github.com/crossplane/function-sdk-python/actions/workflows/ci.yml) ![GitHub release (latest SemVer)](https://img.shields.io/github/release/crossplane/function-sdk-python)
The [Python][python] SDK for writing [composition functions][functions].
This SDK is currently a beta. We try to avoid breaking changes, but it will not
have a stable API until it reaches v1.0.0. It follows the same [contributing
guidelines] as Crossplane.
To learn how to use this SDK:
* [Learn about how composition functions work][functions]
## Contributing
This project follows the Crossplane [contributing guidelines], where applicable
to Python. It is linted, tested, and built using [Hatch][hatch].
Some useful commands:
```shell
# Generate gRPC stubs.
hatch run generate:protoc
# Lint the code, using ruff.
hatch run lint:check
hatch run lint:check-format
# Run unit tests.
hatch run test:unit
# Build an sdist and wheel
hatch build
```
[python]: https://python.org
[functions]: https://docs.crossplane.io/latest/concepts/composition-functions
[contributing guidelines]: https://github.com/crossplane/crossplane/tree/master/contributing
[hatch]: https://github.com/pypa/hatch

View File

@ -0,0 +1,74 @@
"""Logging utilities for composition functions."""
import enum
import logging
import structlog
class Level(enum.Enum):
"""Supported log levels."""
DISABLED = 0
DEBUG = 1
INFO = 2
def configure(level: Level = Level.INFO) -> None:
"""Configure logging.
Args:
level: What log level to enable.
Must be called before calling get_logger. When debug logging is enabled logs
will be printed in a human readable fashion. When not enabled, logs will be
printed as JSON lines.
"""
def dropper(logger, method_name, event_dict): # noqa: ARG001 # We need this signature.
raise structlog.DropEvent
if level == Level.DISABLED:
structlog.configure(processors=[dropper])
return
processors = [
structlog.stdlib.add_log_level,
structlog.processors.CallsiteParameterAdder(
{
structlog.processors.CallsiteParameter.FILENAME,
structlog.processors.CallsiteParameter.LINENO,
}
),
]
if level == Level.DEBUG:
structlog.configure(
processors=[
*processors,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer(),
]
)
return
# Attempt to match function-sdk-go's production logger.
structlog.configure(
processors=[
*processors,
structlog.processors.dict_tracebacks,
structlog.processors.TimeStamper(key="ts"),
structlog.processors.EventRenamer(to="msg"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
def get_logger() -> structlog.stdlib.BoundLogger:
"""Get a logger.
You must call configure before calling get_logger.
"""
return structlog.stdlib.get_logger()

View File

@ -0,0 +1,202 @@
/*
Copyright 2022 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
syntax = "proto3";
import "google/protobuf/struct.proto";
import "google/protobuf/duration.proto";
// Note that the authoritative Composition Functions protobuf definition lives
// at the below URL. Each SDK maintains and manually syncs its own copy.
// https://github.com/crossplane/crossplane/tree/master/apis/apiextensions/fn/proto
package apiextensions.fn.proto.v1beta1;
// A FunctionRunnerService is a Composition Function.
service FunctionRunnerService {
// RunFunction runs the Composition Function.
rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {}
}
// A RunFunctionRequest requests that the Composition Function be run.
message RunFunctionRequest {
// Metadata pertaining to this request.
RequestMeta meta = 1;
// The observed state prior to invocation of a Function pipeline. State passed
// to each Function is fresh as of the time the pipeline was invoked, not as
// of the time each Function was invoked.
State observed = 2;
// Desired state according to a Function pipeline. The state passed to a
// particular Function may have been accumulated by previous Functions in the
// pipeline.
//
// Note that the desired state must be a partial object with only the fields
// that this function (and its predecessors in the pipeline) wants to have
// set in the object. Copying a non-partial observed state to desired is most
// likely not what you want to do. Leaving out fields that had been returned
// as desired before will result in them being deleted from the objects in the
// cluster.
State desired = 3;
// Optional input specific to this Function invocation. A JSON representation
// of the 'input' block of the relevant entry in a Composition's pipeline.
optional google.protobuf.Struct input = 4;
// Optional context. Crossplane may pass arbitary contextual information to a
// Function. A Function may also return context in its RunFunctionResponse,
// and that context will be passed to subsequent Functions. Crossplane
// discards all context returned by the last Function in the pipeline.
optional google.protobuf.Struct context = 5;
}
// A RunFunctionResponse contains the result of a Composition Function run.
message RunFunctionResponse {
// Metadata pertaining to this response.
ResponseMeta meta = 1;
// Desired state according to a Function pipeline. Functions may add desired
// state, and may mutate or delete any part of the desired state they are
// concerned with. A Function must pass through any part of the desired state
// that it is not concerned with.
//
//
// Note that the desired state must be a partial object with only the fields
// that this function (and its predecessors in the pipeline) wants to have
// set in the object. Copying a non-partial observed state to desired is most
// likely not what you want to do. Leaving out fields that had been returned
// as desired before will result in them being deleted from the objects in the
// cluster.
State desired = 2;
// Results of the Function run. Results are used for observability purposes.
repeated Result results = 3;
// Optional context to be passed to the next Function in the pipeline as part
// of the RunFunctionRequest. Dropped on the last function in the pipeline.
optional google.protobuf.Struct context = 4;
}
// RequestMeta contains metadata pertaining to a RunFunctionRequest.
message RequestMeta {
// An opaque string identifying the content of the request. Two identical
// requests should have the same tag.
string tag = 1;
}
// ResponseMeta contains metadata pertaining to a RunFunctionResponse.
message ResponseMeta {
// An opaque string identifying the content of the request. Must match the
// meta.tag of the corresponding RunFunctionRequest.
string tag = 1;
// Time-to-live of this response. Deterministic Functions with no side-effects
// (e.g. simple templating Functions) may specify a TTL. Crossplane may choose
// to cache responses until the TTL expires.
optional google.protobuf.Duration ttl = 2;
}
// State of the composite resource (XR) and any composed resources.
message State {
// The state of the composite resource (XR).
Resource composite = 1;
// The state of any composed resources.
map<string, Resource> resources = 2;
}
// A Resource represents the state of a composite or composed resource.
message Resource {
// The JSON representation of the resource.
//
// * Crossplane will set this field in a RunFunctionRequest to the entire
// observed state of a resource - including its metadata, spec, and status.
//
// * A Function should set this field in a RunFunctionRequest to communicate
// the desired state of a composite or composed resource.
//
// * A Function may only specify the desired status of a composite resource -
// not its metadata or spec. A Function should not return desired metadata
// or spec for a composite resource. This will be ignored.
//
// * A Function may not specify the desired status of a composed resource -
// only its metadata and spec. A Function should not return desired status
// for a composed resource. This will be ignored.
google.protobuf.Struct resource = 1;
// The resource's connection details.
//
// * Crossplane will set this field in a RunFunctionRequest to communicate the
// the observed connection details of a composite or composed resource.
//
// * A Function should set this field in a RunFunctionResponse to indicate the
// desired connection details of the composite resource.
//
// * A Function should not set this field in a RunFunctionResponse to indicate
// the desired connection details of a composed resource. This will be
// ignored.
map<string, bytes> connection_details = 2;
// Ready indicates whether the resource should be considered ready.
//
// * Crossplane will never set this field in a RunFunctionRequest.
//
// * A Function should set this field to READY_TRUE in a RunFunctionResponse
// to indicate that a desired composed resource is ready.
//
// * A Function should not set this field in a RunFunctionResponse to indicate
// that the desired composite resource is ready. This will be ignored.
Ready ready = 3;
}
// Ready indicates whether a composed resource should be considered ready.
enum Ready {
READY_UNSPECIFIED = 0;
// True means the composed resource has been observed to be ready.
READY_TRUE = 1;
// False means the composed resource has not been observed to be ready.
READY_FALSE = 2;
}
// A Result of running a Function.
message Result {
// Severity of this result.
Severity severity = 1;
// Human-readable details about the result.
string message = 2;
}
// Severity of Function results.
enum Severity {
SEVERITY_UNSPECIFIED = 0;
// Fatal results are fatal; subsequent Composition Functions may run, but
// the Composition Function pipeline run will be considered a failure and
// the first fatal result will be returned as an error.
SEVERITY_FATAL = 1;
// Warning results are non-fatal; the entire Composition will run to
// completion but warning events and debug logs associated with the
// composite resource will be emitted.
SEVERITY_WARNING = 2;
// Normal results are emitted as normal events and debug logs associated
// with the composite resource.
SEVERITY_NORMAL = 3;
}

View File

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: crossplane/function/proto/v1beta1/run_function.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2
from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n4crossplane/function/proto/v1beta1/run_function.proto\x12\x1e\x61piextensions.fn.proto.v1beta1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\"\xb2\x02\n\x12RunFunctionRequest\x12\x39\n\x04meta\x18\x01 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.RequestMeta\x12\x37\n\x08observed\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x36\n\x07\x64\x65sired\x18\x03 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12+\n\x05input\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12-\n\x07\x63ontext\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x42\x08\n\x06_inputB\n\n\x08_context\"\xfd\x01\n\x13RunFunctionResponse\x12:\n\x04meta\x18\x01 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.ResponseMeta\x12\x36\n\x07\x64\x65sired\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x37\n\x07results\x18\x03 \x03(\x0b\x32&.apiextensions.fn.proto.v1beta1.Result\x12-\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\n\n\x08_context\"\x1a\n\x0bRequestMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\"P\n\x0cResponseMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12+\n\x03ttl\x18\x02 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00\x88\x01\x01\x42\x06\n\x04_ttl\"\xe9\x01\n\x05State\x12;\n\tcomposite\x18\x01 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource\x12G\n\tresources\x18\x02 \x03(\x0b\x32\x34.apiextensions.fn.proto.v1beta1.State.ResourcesEntry\x1aZ\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x37\n\x05value\x18\x02 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource:\x02\x38\x01\"\x82\x02\n\x08Resource\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12[\n\x12\x63onnection_details\x18\x02 \x03(\x0b\x32?.apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry\x12\x34\n\x05ready\x18\x03 \x01(\x0e\x32%.apiextensions.fn.proto.v1beta1.Ready\x1a\x38\n\x16\x43onnectionDetailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"U\n\x06Result\x12:\n\x08severity\x18\x01 \x01(\x0e\x32(.apiextensions.fn.proto.v1beta1.Severity\x12\x0f\n\x07message\x18\x02 \x01(\t*?\n\x05Ready\x12\x15\n\x11READY_UNSPECIFIED\x10\x00\x12\x0e\n\nREADY_TRUE\x10\x01\x12\x0f\n\x0bREADY_FALSE\x10\x02*c\n\x08Severity\x12\x18\n\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eSEVERITY_FATAL\x10\x01\x12\x14\n\x10SEVERITY_WARNING\x10\x02\x12\x13\n\x0fSEVERITY_NORMAL\x10\x03\x32\x91\x01\n\x15\x46unctionRunnerService\x12x\n\x0bRunFunction\x12\x32.apiextensions.fn.proto.v1beta1.RunFunctionRequest\x1a\x33.apiextensions.fn.proto.v1beta1.RunFunctionResponse\"\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'crossplane.function.proto.v1beta1.run_function_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_STATE_RESOURCESENTRY._options = None
_STATE_RESOURCESENTRY._serialized_options = b'8\001'
_RESOURCE_CONNECTIONDETAILSENTRY._options = None
_RESOURCE_CONNECTIONDETAILSENTRY._serialized_options = b'8\001'
_globals['_READY']._serialized_start=1409
_globals['_READY']._serialized_end=1472
_globals['_SEVERITY']._serialized_start=1474
_globals['_SEVERITY']._serialized_end=1573
_globals['_RUNFUNCTIONREQUEST']._serialized_start=151
_globals['_RUNFUNCTIONREQUEST']._serialized_end=457
_globals['_RUNFUNCTIONRESPONSE']._serialized_start=460
_globals['_RUNFUNCTIONRESPONSE']._serialized_end=713
_globals['_REQUESTMETA']._serialized_start=715
_globals['_REQUESTMETA']._serialized_end=741
_globals['_RESPONSEMETA']._serialized_start=743
_globals['_RESPONSEMETA']._serialized_end=823
_globals['_STATE']._serialized_start=826
_globals['_STATE']._serialized_end=1059
_globals['_STATE_RESOURCESENTRY']._serialized_start=969
_globals['_STATE_RESOURCESENTRY']._serialized_end=1059
_globals['_RESOURCE']._serialized_start=1062
_globals['_RESOURCE']._serialized_end=1320
_globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_start=1264
_globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_end=1320
_globals['_RESULT']._serialized_start=1322
_globals['_RESULT']._serialized_end=1407
_globals['_FUNCTIONRUNNERSERVICE']._serialized_start=1576
_globals['_FUNCTIONRUNNERSERVICE']._serialized_end=1721
# @@protoc_insertion_point(module_scope)

View File

@ -0,0 +1,109 @@
from google.protobuf import struct_pb2 as _struct_pb2
from google.protobuf import duration_pb2 as _duration_pb2
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class Ready(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
READY_UNSPECIFIED: _ClassVar[Ready]
READY_TRUE: _ClassVar[Ready]
READY_FALSE: _ClassVar[Ready]
class Severity(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
SEVERITY_UNSPECIFIED: _ClassVar[Severity]
SEVERITY_FATAL: _ClassVar[Severity]
SEVERITY_WARNING: _ClassVar[Severity]
SEVERITY_NORMAL: _ClassVar[Severity]
READY_UNSPECIFIED: Ready
READY_TRUE: Ready
READY_FALSE: Ready
SEVERITY_UNSPECIFIED: Severity
SEVERITY_FATAL: Severity
SEVERITY_WARNING: Severity
SEVERITY_NORMAL: Severity
class RunFunctionRequest(_message.Message):
__slots__ = ["meta", "observed", "desired", "input", "context"]
META_FIELD_NUMBER: _ClassVar[int]
OBSERVED_FIELD_NUMBER: _ClassVar[int]
DESIRED_FIELD_NUMBER: _ClassVar[int]
INPUT_FIELD_NUMBER: _ClassVar[int]
CONTEXT_FIELD_NUMBER: _ClassVar[int]
meta: RequestMeta
observed: State
desired: State
input: _struct_pb2.Struct
context: _struct_pb2.Struct
def __init__(self, meta: _Optional[_Union[RequestMeta, _Mapping]] = ..., observed: _Optional[_Union[State, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class RunFunctionResponse(_message.Message):
__slots__ = ["meta", "desired", "results", "context"]
META_FIELD_NUMBER: _ClassVar[int]
DESIRED_FIELD_NUMBER: _ClassVar[int]
RESULTS_FIELD_NUMBER: _ClassVar[int]
CONTEXT_FIELD_NUMBER: _ClassVar[int]
meta: ResponseMeta
desired: State
results: _containers.RepeatedCompositeFieldContainer[Result]
context: _struct_pb2.Struct
def __init__(self, meta: _Optional[_Union[ResponseMeta, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., results: _Optional[_Iterable[_Union[Result, _Mapping]]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class RequestMeta(_message.Message):
__slots__ = ["tag"]
TAG_FIELD_NUMBER: _ClassVar[int]
tag: str
def __init__(self, tag: _Optional[str] = ...) -> None: ...
class ResponseMeta(_message.Message):
__slots__ = ["tag", "ttl"]
TAG_FIELD_NUMBER: _ClassVar[int]
TTL_FIELD_NUMBER: _ClassVar[int]
tag: str
ttl: _duration_pb2.Duration
def __init__(self, tag: _Optional[str] = ..., ttl: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ...) -> None: ...
class State(_message.Message):
__slots__ = ["composite", "resources"]
class ResourcesEntry(_message.Message):
__slots__ = ["key", "value"]
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: Resource
def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[Resource, _Mapping]] = ...) -> None: ...
COMPOSITE_FIELD_NUMBER: _ClassVar[int]
RESOURCES_FIELD_NUMBER: _ClassVar[int]
composite: Resource
resources: _containers.MessageMap[str, Resource]
def __init__(self, composite: _Optional[_Union[Resource, _Mapping]] = ..., resources: _Optional[_Mapping[str, Resource]] = ...) -> None: ...
class Resource(_message.Message):
__slots__ = ["resource", "connection_details", "ready"]
class ConnectionDetailsEntry(_message.Message):
__slots__ = ["key", "value"]
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: bytes
def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...
RESOURCE_FIELD_NUMBER: _ClassVar[int]
CONNECTION_DETAILS_FIELD_NUMBER: _ClassVar[int]
READY_FIELD_NUMBER: _ClassVar[int]
resource: _struct_pb2.Struct
connection_details: _containers.ScalarMap[str, bytes]
ready: Ready
def __init__(self, resource: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., connection_details: _Optional[_Mapping[str, bytes]] = ..., ready: _Optional[_Union[Ready, str]] = ...) -> None: ...
class Result(_message.Message):
__slots__ = ["severity", "message"]
SEVERITY_FIELD_NUMBER: _ClassVar[int]
MESSAGE_FIELD_NUMBER: _ClassVar[int]
severity: Severity
message: str
def __init__(self, severity: _Optional[_Union[Severity, str]] = ..., message: _Optional[str] = ...) -> None: ...

View File

@ -0,0 +1,70 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
from crossplane.function.proto.v1beta1 import run_function_pb2 as crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2
class FunctionRunnerServiceStub(object):
"""A FunctionRunnerService is a Composition Function.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.RunFunction = channel.unary_unary(
'/apiextensions.fn.proto.v1beta1.FunctionRunnerService/RunFunction',
request_serializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionRequest.SerializeToString,
response_deserializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionResponse.FromString,
)
class FunctionRunnerServiceServicer(object):
"""A FunctionRunnerService is a Composition Function.
"""
def RunFunction(self, request, context):
"""RunFunction runs the Composition Function.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_FunctionRunnerServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'RunFunction': grpc.unary_unary_rpc_method_handler(
servicer.RunFunction,
request_deserializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionRequest.FromString,
response_serializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'apiextensions.fn.proto.v1beta1.FunctionRunnerService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class FunctionRunnerService(object):
"""A FunctionRunnerService is a Composition Function.
"""
@staticmethod
def RunFunction(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/apiextensions.fn.proto.v1beta1.FunctionRunnerService/RunFunction',
crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionRequest.SerializeToString,
crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionResponse.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)

View File

@ -0,0 +1,94 @@
"""A composition function SDK."""
import dataclasses
import datetime
from google.protobuf import struct_pb2 as structpb
# TODO(negz): Do we really need dict_to_struct and struct_to_dict? They don't do
# much, but are perhaps useful for discoverability/"documentation" purposes.
def dict_to_struct(d: dict) -> structpb.Struct:
"""Create a Struct well-known type from the supplied dict.
Functions must return desired resources encoded as a protobuf struct. This
function makes it possible to work with a Python dict, then convert it to a
struct in a RunFunctionResponse.
"""
s = structpb.Struct()
s.update(d)
return s
def struct_to_dict(s: structpb.Struct) -> dict:
"""Create a dict from the supplied Struct well-known type.
Crossplane sends observed and desired resources to a function encoded as a
protobuf struct. This function makes it possible to convert resources to a
dictionary.
"""
return dict(s)
@dataclasses.dataclass
class Condition:
"""A status condition."""
"""Type of the condition - e.g. Ready."""
typ: str
"""Status of the condition - True, False, or Unknown."""
status: str
"""Reason for the condition status - typically CamelCase."""
reason: str | None = None
"""Optional message."""
message: str | None = None
"""The last time the status transitioned to this status."""
last_transition_time: datetime.time | None = None
def get_condition(resource: structpb.Struct, typ: str) -> Condition:
"""Get the supplied status condition of the supplied resource.
Args:
resource: A Crossplane resource.
typ: The type of status condition to get (e.g. Ready).
Returns:
The requested status condition.
A status condition is always returned. If the status condition isn't present
in the supplied resource, a condition with status "Unknown" is returned.
"""
unknown = Condition(typ=typ, status="Unknown")
if "status" not in resource:
return unknown
if "conditions" not in resource["status"]:
return unknown
for c in resource["status"]["conditions"]:
if c["type"] != typ:
continue
condition = Condition(
typ=c["type"],
status=c["status"],
)
if "message" in c:
condition.message = c["message"]
if "reason" in c:
condition.reason = c["reason"]
if "lastTransitionTime" in c:
condition.last_transition_time = datetime.datetime.fromisoformat(
c["lastTransitionTime"]
)
return condition
return unknown

View File

@ -0,0 +1,35 @@
"""Utilities for working with RunFunctionResponses."""
import datetime
from google.protobuf import duration_pb2 as durationpb
import crossplane.function.proto.v1beta1.run_function_pb2 as fnv1beta1
"""The default TTL for which a RunFunctionResponse may be cached."""
DEFAULT_TTL = datetime.timedelta(minutes=1)
def to(
req: fnv1beta1.RunFunctionRequest,
ttl: datetime.timedelta = DEFAULT_TTL,
) -> fnv1beta1.RunFunctionResponse:
"""Create a response to the supplied request.
Args:
req: The request to respond to.
ttl: How long Crossplane may optionally cache the response.
Returns:
A response to the supplied request.
The request's tag, desired resources, and context is automatically copied to
the response. Using response.to is a good pattern to ensure
"""
dttl = durationpb.Duration()
dttl.FromTimedelta(ttl)
return fnv1beta1.RunFunctionResponse(
meta=fnv1beta1.ResponseMeta(tag=req.meta.tag, ttl=dttl),
desired=req.desired,
context=req.context,
)

View File

@ -0,0 +1,99 @@
"""Utilities to create a composition function runtime."""
import asyncio
import os
import grpc
from grpc_reflection.v1alpha import reflection
import crossplane.function.proto.v1beta1.run_function_pb2 as fnv1beta1
import crossplane.function.proto.v1beta1.run_function_pb2_grpc as grpcv1beta1
SERVICE_NAMES = (
reflection.SERVICE_NAME,
fnv1beta1.DESCRIPTOR.services_by_name["FunctionRunnerService"].full_name,
)
def load_credentials(tls_certs_dir: str) -> grpc.ServerCredentials:
"""Load TLS credentials for a composition function gRPC server.
Args:
tls_certs_dir: A directory containing tls.crt, tls.key, and ca.crt.
Returns:
gRPC mTLS server credentials.
tls.crt and tls.key must be the function's PEM-encoded certificate and
private key. ca.cert must be a PEM-encoded CA certificate used to
authenticate callers (i.e. Crossplane).
"""
if tls_certs_dir is None:
return None
with open(os.path.join(tls_certs_dir, "tls.crt"), "rb") as f:
crt = f.read()
with open(os.path.join(tls_certs_dir, "tls.key"), "rb") as f:
key = f.read()
with open(os.path.join(tls_certs_dir, "ca.crt"), "rb") as f:
ca = f.read()
return grpc.ssl_server_credentials(
private_key_certificate_chain_pairs=[(key, crt)],
root_certificates=ca,
require_client_auth=True,
)
def serve(
function: grpcv1beta1.FunctionRunnerService,
address: str,
*,
creds: grpc.ServerCredentials,
insecure: bool,
) -> None:
"""Start a gRPC server and serve requests asychronously.
Args:
function: The function (class) to use to serve requests.
address: The address at which to listen for requests.
creds: The credentials used to authenticate requests.
insecure: Serve insecurely, without credentials or encryption.
Raises:
ValueError if creds is None and insecure is False.
If insecure is true requests will be served insecurely, even if credentials
are supplied.
"""
server = grpc.aio.server()
grpcv1beta1.add_FunctionRunnerServiceServicer_to_server(function, server)
reflection.enable_server_reflection(SERVICE_NAMES, server)
if creds is None and insecure is False:
msg = (
"no credentials were provided - did you provide credentials or use "
"the insecure flag?"
)
raise ValueError(msg)
if creds is not None:
server.add_secure_port(address, creds)
# TODO(negz): Does this override add_secure_port?
if insecure:
server.add_insecure_port(address)
async def start():
await server.start()
await server.wait_for_termination()
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(start())
finally:
loop.run_until_complete(server.stop(grace=5))
loop.close()

109
pyproject.toml Normal file
View File

@ -0,0 +1,109 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "function-sdk-python"
description = 'The Python SDK for Crossplane composition functions'
readme = "README.md"
requires-python = ">=3.11"
license = "Apache-2.0"
keywords = []
authors = [{ name = "Crossplane Maintainers", email = "info@crossplane.io" }]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.11",
]
dependencies = [
"click==8.*",
"grpcio==1.*",
"grpcio-reflection==1.*",
"structlog==23.*",
]
dynamic = ["version"]
[project.urls]
Documentation = "https://github.com/crossplane/function-sdk-python#readme"
Issues = "https://github.com/crossplane/function-sdk-python/issues"
Source = "https://github.com/crossplane/function-sdk-python"
[tool.hatch.version]
source = "vcs"
[tool.hatch.envs.default]
type = "virtual"
path = ".venv-default"
dependencies = ["ipython==8.17.2"]
[tool.hatch.envs.generate]
type = "virtual"
detached = true
path = ".venv-generate"
dependencies = ["grpcio-tools==1.59.2"]
[tool.hatch.envs.generate.scripts]
protoc = "python -m grpc_tools.protoc --proto_path=. --python_out=. --pyi_out=. --grpc_python_out=. crossplane/function/proto/v1beta1/run_function.proto"
[tool.hatch.envs.lint]
type = "virtual"
detached = true
path = ".venv-lint"
dependencies = ["ruff==0.1.6"]
[tool.hatch.envs.lint.scripts]
check = "ruff check crossplane tests"
check-format = "ruff format --diff crossplane tests"
[tool.hatch.envs.test]
type = "virtual"
path = ".venv-test"
[tool.hatch.envs.test.scripts]
unit = "python -m unittest tests/*.py"
[tool.ruff]
target-version = "py311"
exclude = ["crossplane/function/proto/*"]
select = [
"A",
"ARG",
"ASYNC",
"B",
"C",
"D",
"DTZ",
"E",
"EM",
"ERA",
"F",
"FBT",
"I",
"ICN",
"ISC",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
]
ignore = ["ISC001"] # Ruff warns this is incompatible with ruff format.
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D"] # Don't require docstrings for tests.
[tool.ruff.isort]
known-first-party = ["function"]
[tool.ruff.lint.pydocstyle]
convention = "google"

113
tests/test_resource.py Normal file
View File

@ -0,0 +1,113 @@
import dataclasses
import datetime
import unittest
from google.protobuf import struct_pb2 as structpb
from crossplane.function import logging, resource
class TestResource(unittest.TestCase):
def setUp(self) -> None:
logging.configure(level=logging.Level.DISABLED)
def test_get_condition(self) -> None:
@dataclasses.dataclass
class TestCase:
reason: str
res: structpb.Struct
typ: str
want: resource.Condition
cases = [
TestCase(
reason="Return an unknown condition if the resource has no status.",
res=resource.dict_to_struct({}),
typ="Ready",
want=resource.Condition(typ="Ready", status="Unknown"),
),
TestCase(
reason="Return an unknown condition if the resource has no conditions.",
res=resource.dict_to_struct({"status": {}}),
typ="Ready",
want=resource.Condition(typ="Ready", status="Unknown"),
),
TestCase(
reason="Return an unknown condition if the resource does not have the "
"requested type of condition.",
res=resource.dict_to_struct(
{
"status": {
"conditions": [
{
"type": "Cool",
"status": "True",
}
]
}
}
),
typ="Ready",
want=resource.Condition(typ="Ready", status="Unknown"),
),
TestCase(
reason="Return a minimal condition if it exists.",
res=resource.dict_to_struct(
{
"status": {
"conditions": [
{
"type": "Ready",
"status": "True",
}
]
}
}
),
typ="Ready",
want=resource.Condition(typ="Ready", status="True"),
),
TestCase(
reason="Return a full condition if it exists.",
res=resource.dict_to_struct(
{
"status": {
"conditions": [
{
"type": "Ready",
"status": "True",
"reason": "Cool",
"message": "This condition is very cool",
"lastTransitionTime": "2023-10-02T16:30:00Z",
}
]
}
}
),
typ="Ready",
want=resource.Condition(
typ="Ready",
status="True",
reason="Cool",
message="This condition is very cool",
last_transition_time=datetime.datetime(
year=2023,
month=10,
day=2,
hour=16,
minute=30,
tzinfo=datetime.UTC,
),
),
),
]
for case in cases:
got = resource.get_condition(case.res, case.typ)
self.assertEqual(
dataclasses.asdict(case.want), dataclasses.asdict(got), "-want, +got"
)
if __name__ == "__main__":
unittest.main()

61
tests/test_response.py Normal file
View File

@ -0,0 +1,61 @@
import dataclasses
import datetime
import unittest
from google.protobuf import duration_pb2 as durationpb
from google.protobuf import json_format
from crossplane.function import logging, resource, response
from crossplane.function.proto.v1beta1 import run_function_pb2 as fnv1beta1
class TestResponse(unittest.TestCase):
def setUp(self) -> None:
logging.configure(level=logging.Level.DISABLED)
def test_to(self) -> None:
@dataclasses.dataclass
class TestCase:
reason: str
req: fnv1beta1.RunFunctionRequest
ttl: datetime.timedelta
want: fnv1beta1.RunFunctionResponse
cases = [
TestCase(
reason="Tag, desired, and context should be copied.",
req=fnv1beta1.RunFunctionRequest(
meta=fnv1beta1.RequestMeta(tag="hi"),
desired=fnv1beta1.State(
resources={
"ready-composed-resource": fnv1beta1.Resource(),
}
),
context=resource.dict_to_struct({"cool-key": "cool-value"}),
),
ttl=datetime.timedelta(minutes=10),
want=fnv1beta1.RunFunctionResponse(
meta=fnv1beta1.ResponseMeta(
tag="hi", ttl=durationpb.Duration(seconds=60 * 10)
),
desired=fnv1beta1.State(
resources={
"ready-composed-resource": fnv1beta1.Resource(),
}
),
context=resource.dict_to_struct({"cool-key": "cool-value"}),
),
),
]
for case in cases:
got = response.to(case.req, case.ttl)
self.assertEqual(
json_format.MessageToJson(case.want),
json_format.MessageToJson(got),
"-want, +got",
)
if __name__ == "__main__":
unittest.main()