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:
parent
e930e1ddf8
commit
e247d8766e
|
@ -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`)
|
||||||
|
-->
|
|
@ -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.
|
||||||
|
-->
|
|
@ -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
|
|
@ -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
|
39
README.md
39
README.md
|
@ -1,2 +1,39 @@
|
||||||
# function-sdk-python
|
# function-sdk-python
|
||||||
The Python SDK for composition functions
|
[](https://github.com/crossplane/function-sdk-python/actions/workflows/ci.yml) 
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
@ -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()
|
|
@ -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;
|
||||||
|
}
|
|
@ -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)
|
|
@ -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: ...
|
|
@ -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)
|
|
@ -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
|
|
@ -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,
|
||||||
|
)
|
|
@ -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()
|
|
@ -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"
|
|
@ -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()
|
|
@ -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()
|
Loading…
Reference in New Issue