diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d10c947..1988827 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,6 +70,9 @@ jobs: - name: Install Hatch uses: pypa/hatch@install + - name: Generate run_function protobuf + run: hatch run generate:protoc + - name: Run Unit Tests run: hatch run test:ci @@ -114,6 +117,9 @@ jobs: - name: Set PyPI Project Version run: hatch version ${{ env.PYPI_VERSION }} + - name: Generate run_function protobuf + run: hatch run generate:protoc + - name: Build Sdist and Wheel run: hatch build diff --git a/.gitignore b/.gitignore index c5ab72d..bb74a64 100644 --- a/.gitignore +++ b/.gitignore @@ -212,6 +212,7 @@ __marimo__/ # function-pythonic crossplane/pythonic/__version__.py +crossplane/pythonic/proto/v1/run_function_pb2* pocs/ pythonic-packages/ tests/protobuf/pytest_pb2* diff --git a/crossplane/pythonic/composite.py b/crossplane/pythonic/composite.py index 1a0f2f1..6f56013 100644 --- a/crossplane/pythonic/composite.py +++ b/crossplane/pythonic/composite.py @@ -1,10 +1,12 @@ import datetime from google.protobuf.duration_pb2 import Duration -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from . import auto_ready -from . import protobuf +from . import ( + auto_ready, + protobuf, +) +from .proto.v1 import run_function_pb2 as fnv1 _notset = object() diff --git a/crossplane/pythonic/function.py b/crossplane/pythonic/function.py index c1abe38..355e7ba 100644 --- a/crossplane/pythonic/function.py +++ b/crossplane/pythonic/function.py @@ -7,8 +7,8 @@ import sys import grpc -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 +from .proto.v1 import run_function_pb2 as fnv1 +from .proto.v1 import run_function_pb2_grpc as grpcv1 from .. import pythonic logger = logging.getLogger(__name__) diff --git a/crossplane/pythonic/grpc.py b/crossplane/pythonic/grpc.py index 04551a3..fe03df1 100644 --- a/crossplane/pythonic/grpc.py +++ b/crossplane/pythonic/grpc.py @@ -7,7 +7,6 @@ import signal import sys -import crossplane.function.proto.v1.run_function_pb2_grpc as grpcv1 import grpc from . import ( @@ -15,6 +14,8 @@ command, function, ) +from .proto.v1 import run_function_pb2_grpc as grpcv1 + logger = logging.getLogger(__name__) diff --git a/crossplane/pythonic/proto/v1/run_function.proto b/crossplane/pythonic/proto/v1/run_function.proto new file mode 100644 index 0000000..e58a4b5 --- /dev/null +++ b/crossplane/pythonic/proto/v1/run_function.proto @@ -0,0 +1,436 @@ +/* + 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"; + +// This package defines the RPC for both composition and operation functions. +// Only composition functions are part of the 'apiextensions' API group. In +// retrospect this package should've been crossplane.proto.fn.v1, but it's too +// late to change it now. + +//buf:lint:ignore PACKAGE_DIRECTORY_MATCH +package apiextensions.fn.proto.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; + +option go_package = "github.com/crossplane/crossplane/v2/proto/fn/v1"; + +// A FunctionRunnerService is a function. +service FunctionRunnerService { + // RunFunction runs the function. + rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {} +} + +// A RunFunctionRequest requests that the 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 function pipeline. + optional google.protobuf.Struct input = 4; + + // Optional context. Crossplane may pass arbitrary 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; + + // Optional resources that the function specified in its requirements. Note + // that resources is a map to Resources, plural. The map key corresponds to + // the key in a RunFunctionResponse's requirements.extra_resources field. If a + // function requested extra resources that did not exist, Crossplane sets + // the map key to an empty Resources message to indicate that it attempted to + // satisfy the request. This field is only populated when the function uses + // extra_resources in its requirements. + // + // Deprecated: Use required_resources instead. + map extra_resources = 6 [deprecated = true]; + + // Optional credentials that this function may use to communicate with an + // external system. + map credentials = 7; + + // Optional resources that the function specified in its requirements. Note + // that resources is a map to Resources, plural. The map key corresponds to + // the key in a RunFunctionResponse's requirements.resources field. If a + // function requested required resources that did not exist, Crossplane sets + // the map key to an empty Resources message to indicate that it attempted to + // satisfy the request. This field is only populated when the function uses + // resources in its requirements. + map required_resources = 8; + + // Optional schemas that the function specified in its requirements. The map + // key corresponds to the key in a RunFunctionResponse's requirements.schemas + // field. If a function requested a schema that could not be found, Crossplane + // sets the map key to an empty Schema message to indicate that it attempted + // to satisfy the request. + map required_schemas = 9; +} + +// Credentials that a function may use to communicate with an external system. +message Credentials { + // Source of the credentials. + oneof source { + // Credential data loaded by Crossplane, for example from a Secret. + CredentialData credential_data = 1; + } +} + +// CredentialData loaded by Crossplane, for example from a Secret. +message CredentialData { + map data = 1; +} + +// Resources represents the state of several Crossplane resources. +message Resources { + repeated Resource items = 1; +} + +// A RunFunctionResponse contains the result of a 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; + + // Requirements that must be satisfied for this function to run successfully. + Requirements requirements = 5; + + // Status conditions to be applied to the XR. Conditions may also optionally + // be applied to the XR's associated claim. + // + // Conditions are only used for composition. They're ignored by Operations. + repeated Condition conditions = 6; + + // Optional output specific to this function invocation. + // + // Only Operations use function output. XRs will discard any function output. + optional google.protobuf.Struct output = 7; +} + +// RequestMeta contains metadata pertaining to a RunFunctionRequest. +message RequestMeta { + // An opaque string identifying a request. Requests with identical tags will + // be otherwise identical. + string tag = 1; + + // Capabilities supported by this version of Crossplane. Functions may use + // this to determine whether Crossplane will honor certain fields in their + // response, or populate certain fields in their request. + repeated Capability capabilities = 2; +} + +// Capability indicates that Crossplane supports a particular feature. +// Functions can check for capabilities to determine whether Crossplane will +// honor a particular request or response field. +enum Capability { + CAPABILITY_UNSPECIFIED = 0; + + // Crossplane sends capabilities in RequestMeta. If this capability is + // present, the function knows that if another capability is absent, it's + // because Crossplane doesn't support it (not because Crossplane predates + // capability advertisement). Added in Crossplane v2.2. + CAPABILITY_CAPABILITIES = 1; + + // Crossplane supports the requirements.resources field. Functions can return + // resource requirements and Crossplane will fetch the requested resources and + // return them in required_resources. Added in Crossplane v1.15. + CAPABILITY_REQUIRED_RESOURCES = 2; + + // Crossplane supports the credentials field. Functions can receive + // credentials from secrets specified in the Composition. Added in Crossplane + // v1.16. + CAPABILITY_CREDENTIALS = 3; + + // Crossplane supports the conditions field. Functions can return status + // conditions to be applied to the XR and optionally its claim. Added in + // Crossplane v1.17. + CAPABILITY_CONDITIONS = 4; + + // Crossplane supports the requirements.schemas field. Functions can request + // OpenAPI schemas and Crossplane will return them in required_schemas. Added + // in Crossplane v2.2. + CAPABILITY_REQUIRED_SCHEMAS = 5; +} + +// Requirements that must be satisfied for a function to run successfully. +message Requirements { + // Resources that this function requires. The map key uniquely identifies the + // group of resources. + // + // Deprecated: Use resources instead. + map extra_resources = 1 [deprecated = true]; + + // Resources that this function requires. The map key uniquely identifies the + // group of resources. + map resources = 2; + + // Schemas that this function requires. The map key uniquely identifies the + // schema request. + map schemas = 3; +} + +// SchemaSelector identifies a resource kind whose OpenAPI schema is requested. +message SchemaSelector { + // API version of the resource kind, e.g. "example.org/v1". + string api_version = 1; + + // Kind of resource, e.g. "MyResource". + string kind = 2; +} + +// Schema represents the OpenAPI schema for a resource kind. +message Schema { + // The OpenAPI v3 schema of the resource kind as unstructured JSON. + // For CRDs this is the spec.versions[].schema.openAPIV3Schema field. + // Empty if Crossplane could not find a schema for the requested kind. + optional google.protobuf.Struct openapi_v3 = 1; +} + +// ResourceSelector selects a group of resources, either by name or by label. +message ResourceSelector { + // API version of resources to select. + string api_version = 1; + + // Kind of resources to select. + string kind = 2; + + // Resources to match. + oneof match { + // Match the resource with this name. + string match_name = 3; + + // Match all resources with these labels. + MatchLabels match_labels = 4; + } + + // Match resources in this namespace. Omit namespace to match cluster scoped + // resources, or to match namespaced resources by labels across all + // namespaces. + optional string namespace = 5; +} + +// MatchLabels defines a set of labels to match resources against. +message MatchLabels { + map labels = 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. Crossplane will call the function again when + // the TTL expires. Crossplane may cache the response to avoid calling the + // function again until the TTL expires. + optional google.protobuf.Duration ttl = 2; +} + +// State of the XR (XR) and any resources. +message State { + // The state of the XR (XR). + Resource composite = 1; + + // The state of any other resources. In composition functions these are the + // composed resources. In operation functions they're arbitrary resources that + // the operation wants to create or update. + map resources = 2; +} + +// A Resource represents the state of a Kubernetes 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 the resource. + // + // * A function may only specify the desired status of a XR - not its metadata + // or spec. A function should not return desired metadata or spec for a XR. + // This will be ignored. + // + // * A function may not specify the desired status of any other resource - + // e.g. composed resources. It may only specify their metadata and spec. + // Status 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 legacy XRs. For modern XRs, this will be ignored. + // + // * A function should not set this field in a RunFunctionResponse to indicate + // the desired connection details of a composed resource. This will be + // ignored. + // + // Connection details are only used for composition. They're ignored by + // Operations. + 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 resource is ready. + // + // * A function should set this field to READY_TRUE in a RunFunctionResponse + // to indicate that a desired XR is ready. This overwrites the standard + // readiness detection that determines the ready state of the composite by the + // ready state of the composed resources. + // + // Ready is only used for composition. It's ignored by Operations. + Ready ready = 3; +} + +// Ready indicates whether a resource should be considered ready. +enum Ready { + READY_UNSPECIFIED = 0; + + // True means the resource has been observed to be ready. + READY_TRUE = 1; + + // False means the 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; + + // Optional PascalCase, machine-readable reason for this result. If omitted, + // the value will be ComposeResources. + optional string reason = 3; + + // The resources this result targets. + optional Target target = 4; +} + +// Severity of function results. +enum Severity { + SEVERITY_UNSPECIFIED = 0; + + // Fatal results are fatal; subsequent functions may run, but the 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 pipeline will run to completion + // but warning events and debug logs associated with the XR or Operation will + // be emitted. + SEVERITY_WARNING = 2; + + // Normal results are emitted as normal events and debug logs associated with + // the XR or operation. + SEVERITY_NORMAL = 3; +} + +// Target of function results and conditions. +enum Target { + // If the target is unspecified, the result targets the XR. + TARGET_UNSPECIFIED = 0; + + // Target the XR. Results that target the XR should include detailed, advanced + // information. + TARGET_COMPOSITE = 1; + + // Target the XR and the claim. Results that target the XR and the claim + // should include only end-user friendly information. + TARGET_COMPOSITE_AND_CLAIM = 2; +} + +// Status condition to be applied to the XR. Condition may also optionally be +// applied to the XR's associated claim. For detailed information on proper +// usage of status conditions, please see +// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties. +message Condition { + // Type of condition in PascalCase. + string type = 1; + + // Status of the condition. + Status status = 2; + + // Reason contains a programmatic identifier indicating the reason for the + // condition's last transition. Producers of specific condition types may + // define expected values and meanings for this field, and whether the values + // are considered a guaranteed API. The value should be a PascalCase string. + // This field may not be empty. + string reason = 3; + + // Message is a human readable message indicating details about the + // transition. This may be an empty string. + optional string message = 4; + + // The resources this condition targets. + optional Target target = 5; +} + +enum Status { + STATUS_CONDITION_UNSPECIFIED = 0; + + STATUS_CONDITION_UNKNOWN = 1; + + STATUS_CONDITION_TRUE = 2; + + STATUS_CONDITION_FALSE = 3; +} diff --git a/crossplane/pythonic/render.py b/crossplane/pythonic/render.py index fedb8dd..268983b 100644 --- a/crossplane/pythonic/render.py +++ b/crossplane/pythonic/render.py @@ -9,7 +9,6 @@ import pathlib import sys import yaml -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from . import ( command, @@ -17,6 +16,8 @@ protobuf, ) from .composite import BaseComposite +from .proto.v1 import run_function_pb2 as fnv1 + INFLECT = inflect.engine() INFLECT.classical(all=False) diff --git a/pyproject.toml b/pyproject.toml index 05e42ad..061d79d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "crossplane-function-pythonic" description = 'A Python centric Crossplane Function' @@ -17,9 +21,10 @@ classifiers = [ dynamic = ["version"] dependencies = [ - "crossplane-function-sdk-python==0.12.0", + "grpcio==1.80.0", "inflect==7.5.0", "kr8s==0.20.15", + "protobuf==7.35.0", "pyyaml==6.0.3", ] @@ -35,14 +40,19 @@ Source = "https://github.com/crossplane-contrib/function-pythonic" [project.scripts] function-pythonic = "crossplane.pythonic.main:main" -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [tool.hatch.version] path = "crossplane/pythonic/__about__.py" validate-bump = false +[tool.hatch.envs.generate] +type = "virtual" +path = ".venv-generate" +post-install-commands = [ + "pip install --no-deps grpcio-tools==1.80.0", +] +[tool.hatch.envs.generate.scripts] +protoc = "python -m grpc_tools.protoc --proto_path=. --python_out=. --pyi_out=. --grpc_python_out=. crossplane/pythonic/proto/v1/run_function.proto" + [tool.hatch.build.targets.wheel] only-include = ["crossplane"] @@ -77,11 +87,13 @@ check = "ruff check crossplane tests && ruff format --diff crossplane tests" type = "virtual" path = ".venv-test" dependencies = [ - "grpcio-tools==1.76.0", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-cov==7.1.0", ] +post-install-commands = [ + "pip install --no-deps grpcio-tools==1.80.0", +] packages = ["crossplane"] [tool.hatch.envs.test.scripts] all = "python -m pytest tests -x --verbose --verbose --cov --cov-report=term --cov-report=html:reports" diff --git a/tests/test_auto_ready.py b/tests/test_auto_ready.py index 4426594..b6df5e4 100644 --- a/tests/test_auto_ready.py +++ b/tests/test_auto_ready.py @@ -2,7 +2,7 @@ import logging import pathlib import pytest -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 +from crossplane.pythonic.proto.v1 import run_function_pb2 as fnv1 from crossplane.pythonic import ( auto_ready, diff --git a/tests/test_fn.py b/tests/test_fn.py index b04c997..a5f0a86 100644 --- a/tests/test_fn.py +++ b/tests/test_fn.py @@ -1,7 +1,7 @@ import pathlib import pytest -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 +from crossplane.pythonic.proto.v1 import run_function_pb2 as fnv1 from crossplane.pythonic import function from tests import utils