From e247d8766e4a9393c727f8c4bd7f45cba09542a4 Mon Sep 17 00:00:00 2001 From: Nic Cope Date: Sun, 19 Nov 2023 13:37:50 -0800 Subject: [PATCH] Break the SDK out into its own repo I prototyped this in https://github.com/negz/function-auto-python. Signed-off-by: Nic Cope --- .github/ISSUE_TEMPLATE/bug_report.md | 40 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 24 +++ .github/PULL_REQUEST_TEMPLATE.md | 30 +++ .github/workflows/ci.yml | 80 +++++++ README.md | 39 +++- crossplane/function/logging.py | 74 +++++++ .../function/proto/v1beta1/run_function.proto | 202 ++++++++++++++++++ .../proto/v1beta1/run_function_pb2.py | 53 +++++ .../proto/v1beta1/run_function_pb2.pyi | 109 ++++++++++ .../proto/v1beta1/run_function_pb2_grpc.py | 70 ++++++ crossplane/function/resource.py | 94 ++++++++ crossplane/function/response.py | 35 +++ crossplane/function/runtime.py | 99 +++++++++ pyproject.toml | 109 ++++++++++ tests/test_resource.py | 113 ++++++++++ tests/test_response.py | 61 ++++++ 16 files changed, 1231 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 crossplane/function/logging.py create mode 100644 crossplane/function/proto/v1beta1/run_function.proto create mode 100644 crossplane/function/proto/v1beta1/run_function_pb2.py create mode 100644 crossplane/function/proto/v1beta1/run_function_pb2.pyi create mode 100644 crossplane/function/proto/v1beta1/run_function_pb2_grpc.py create mode 100644 crossplane/function/resource.py create mode 100644 crossplane/function/response.py create mode 100644 crossplane/function/runtime.py create mode 100644 pyproject.toml create mode 100644 tests/test_resource.py create mode 100644 tests/test_response.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d834757 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug Report +about: Help us diagnose and fix bugs in this Function +labels: bug +--- + + +### What happened? + + + +### How can we reproduce it? + + +### What environment did it happen in? +Function version: + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a9ddcf0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature Request +about: Help us make this Function more useful +labels: enhancement +--- + + +### What problem are you facing? + + +### How could this Function help solve your problem? + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d893e4d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ + + +### Description of your changes + + + +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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e9427e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 333bd7b..e0fbdb8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crossplane/function/logging.py b/crossplane/function/logging.py new file mode 100644 index 0000000..9c60117 --- /dev/null +++ b/crossplane/function/logging.py @@ -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() diff --git a/crossplane/function/proto/v1beta1/run_function.proto b/crossplane/function/proto/v1beta1/run_function.proto new file mode 100644 index 0000000..2a3d2b7 --- /dev/null +++ b/crossplane/function/proto/v1beta1/run_function.proto @@ -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 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 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; +} \ No newline at end of file diff --git a/crossplane/function/proto/v1beta1/run_function_pb2.py b/crossplane/function/proto/v1beta1/run_function_pb2.py new file mode 100644 index 0000000..4db6394 --- /dev/null +++ b/crossplane/function/proto/v1beta1/run_function_pb2.py @@ -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) diff --git a/crossplane/function/proto/v1beta1/run_function_pb2.pyi b/crossplane/function/proto/v1beta1/run_function_pb2.pyi new file mode 100644 index 0000000..d72f651 --- /dev/null +++ b/crossplane/function/proto/v1beta1/run_function_pb2.pyi @@ -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: ... diff --git a/crossplane/function/proto/v1beta1/run_function_pb2_grpc.py b/crossplane/function/proto/v1beta1/run_function_pb2_grpc.py new file mode 100644 index 0000000..ce8b70b --- /dev/null +++ b/crossplane/function/proto/v1beta1/run_function_pb2_grpc.py @@ -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) diff --git a/crossplane/function/resource.py b/crossplane/function/resource.py new file mode 100644 index 0000000..9a9dc55 --- /dev/null +++ b/crossplane/function/resource.py @@ -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 diff --git a/crossplane/function/response.py b/crossplane/function/response.py new file mode 100644 index 0000000..7b7230f --- /dev/null +++ b/crossplane/function/response.py @@ -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, + ) diff --git a/crossplane/function/runtime.py b/crossplane/function/runtime.py new file mode 100644 index 0000000..fd5f44e --- /dev/null +++ b/crossplane/function/runtime.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe64e3e --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/tests/test_resource.py b/tests/test_resource.py new file mode 100644 index 0000000..9f07c7f --- /dev/null +++ b/tests/test_resource.py @@ -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() diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..ee53cfc --- /dev/null +++ b/tests/test_response.py @@ -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()