diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 675aa20..f96101d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -127,13 +127,13 @@ jobs: build-args: PYTHON_VERSION=${{ env.PYTHON_VERSION }} outputs: type=docker,dest=runtime-${{ matrix.arch }}.tar - + - name: Setup the Crossplane CLI - run: "curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" + run: "mkdir bin && cd bin && curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" - name: Build Package - run: ./crossplane xpkg build --package-file=${{ matrix.arch }}.xpkg --package-root=package/ --embed-runtime-image-tarball=runtime-${{ matrix.arch }}.tar - + run: bin/crossplane xpkg build --package-file=${{ matrix.arch }}.xpkg --package-root=package/ --embed-runtime-image-tarball=runtime-${{ matrix.arch }}.tar + - name: Upload Single-Platform Package uses: actions/upload-artifact@v4 with: @@ -165,7 +165,15 @@ jobs: merge-multiple: true - name: Setup the Crossplane CLI - run: "curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" + run: "mkdir bin && cd bin && curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" + + # If a version wasn't explicitly passed as a workflow_dispatch input we + # default to version v0.0.0--, for example + # v0.0.0-20231101115142-1091066df799. This is a simple implementation of + # Go's pseudo-versions: https://go.dev/ref/mod#pseudo-versions. + - name: Set Default Multi-Platform Package Version + if: env.XPKG_VERSION == '' + run: echo "XPKG_VERSION=v0.0.0-$(date -d@$(git show -s --format=%ct) +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)" >> $GITHUB_ENV # - name: Login to Upbound # uses: docker/login-action@v3 @@ -175,17 +183,9 @@ jobs: # username: ${{ secrets.XPKG_ACCESS_ID }} # password: ${{ secrets.XPKG_TOKEN }} - # If a version wasn't explicitly passed as a workflow_dispatch input we - # default to version v0.0.0--, for example - # v0.0.0-20231101115142-1091066df799. This is a simple implementation of - # Go's pseudo-versions: https://go.dev/ref/mod#pseudo-versions. - # - name: Set Default Multi-Platform Package Version - # if: env.XPKG_VERSION == '' - # run: echo "XPKG_VERSION=v0.0.0-$(date -d@$(git show -s --format=%ct) +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)" >> $GITHUB_ENV - # - name: Push Multi-Platform Package to Upbound # if: env.XPKG_ACCESS_ID != '' - # run: "./crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.XPKG }}:${{ env.XPKG_VERSION }}" + # run: "bin/crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.XPKG }}:${{ env.XPKG_VERSION }}" - name: Login to GHCR uses: docker/login-action@v3 @@ -195,4 +195,4 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Push Multi-Platform Package to GHCR - run: "./crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.CROSSPLANE_REGORG }}:${{ env.XPKG_VERSION }}" + run: "bin/crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.CROSSPLANE_REGORG }}:${{ env.XPKG_VERSION }}" diff --git a/CloudOps.groovy b/CloudOps.groovy deleted file mode 100644 index a993281..0000000 --- a/CloudOps.groovy +++ /dev/null @@ -1,25 +0,0 @@ -cloudops.dockerImage { - - container { - name 'podman' - image 'quay.io/podman/stable:latest' - requests { - cpu '1' - memory '4Gi' - } - root true - privileged true - } - - build { context -> - def arches = ['amd64', 'arm64'] - arches.each { arch -> - container('podman') { - sh "podman build --platform=linux/$arch --tag=$arch ." - sh "podman image save --format docker-archive localhost/$arch --output image.$arch" - } - sh "crossplane xpkg build --package-root=package --embed-runtime-image-tarball=image.$arch --package-file=xpkg.$arch" - } - sh "crossplane xpkg push --package-files=${arches.collect{"xpkg.$it"}.join(',')} $context.INTERIM_IMAGE" - } -} diff --git a/Dockerfile b/Dockerfile index 0ab87ca..c2a5c1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,17 +27,17 @@ RUN \ && /venv/build/bin/pip install hatch \ && /venv/build/bin/hatch build -t wheel /whl -# Create a fresh venv and install only the function wheel into it. +# Create a fresh venv and install only the pythonic wheel into it. #RUN --mount=type=cache,target=/root/.cache/pip \ RUN \ python3 -m venv /venv/fn \ && /venv/fn/bin/pip install /whl/*.whl -# Copy the function venv to our runtime stage. It's important that the path be +# Copy the pythonic venv to our runtime stage. It's important that the path be # the same as in the build stage, to avoid shebang paths and symlinks breaking. FROM gcr.io/distroless/python3-debian12 AS image WORKDIR / USER nonroot:nonroot COPY --from=build --chown=nonroot:nonroot /venv/fn /venv/fn EXPOSE 9443 -ENTRYPOINT ["/venv/fn/bin/function"] +ENTRYPOINT ["/venv/fn/bin/pythonic"] diff --git a/README.md b/README.md index a1e9cd6..44798f6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ spec: vpc.spec.forProvider.cidrBlock = self.spec.cidr self.status.vpcId = vpc.status.atProvider.vpcId ``` +In addtion to an inline script, the python implementation can be specified +as the complete path to a python class. See [Filing system Composites](#filing-system-composites). ## Examples @@ -270,6 +272,38 @@ spec: self.status.composite = 'Hello, World!' ``` +## Filing system Composites + +Composition Composite implementations can be coded in a stand alone python files +by configuring the function-pythonic deployment with the code mounted into +the package-runtime container, and then adding the mount point to the python +path using the --python-path command line option. +```yaml +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: function-pythonic +spec: + deploymentTemplate: + spec: + template: + spec: + containers: + - name: package-runtime + args: + - --debug + - --python-path + - /mnt/composites + volumeMounts: + - name: composites + mountPath: /mnt/composites + volumes: + - name: composites + configMap: + name: pythonic-composites +``` +See the [filing-system](examples/filing-system) example. + ## Install Additional Python Packages function-pythonic supports a `--pip-install` command line option which will run pip install diff --git a/crossplane/pythonic/__init__.py b/crossplane/pythonic/__init__.py new file mode 100644 index 0000000..5861dc9 --- /dev/null +++ b/crossplane/pythonic/__init__.py @@ -0,0 +1,17 @@ +import base64 + +from .composite import BaseComposite +from .protobuf import Map, List, Unknown, Yaml, Json +B64Encode = lambda s: base64.b64encode(s.encode('utf-8')).decode('utf-8') +B64Decode = lambda s: base64.b64decode(s.encode('utf-8')).decode('utf-8') + +__all__ = [ + 'BaseComposite', + 'Map', + 'List', + 'Unknown', + 'Yaml', + 'Json', + 'B64Encode', + 'B64Decode', +] diff --git a/function/__version__.py b/crossplane/pythonic/__version__.py similarity index 100% rename from function/__version__.py rename to crossplane/pythonic/__version__.py diff --git a/function/composite.py b/crossplane/pythonic/composite.py similarity index 95% rename from function/composite.py rename to crossplane/pythonic/composite.py index 26841d5..99d7e6e 100644 --- a/function/composite.py +++ b/crossplane/pythonic/composite.py @@ -2,7 +2,7 @@ import datetime from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -import function.protobuf +from . import protobuf _notset = object() @@ -10,8 +10,8 @@ class BaseComposite: def __init__(self, request, response, logger): - self.request = function.protobuf.Message(None, None, request.DESCRIPTOR, request, 'Function Request') - self.response = function.protobuf.Message(None, None, response.DESCRIPTOR, response) + self.request = protobuf.Message(None, None, request.DESCRIPTOR, request, 'Function Request') + self.response = protobuf.Message(None, None, response.DESCRIPTOR, response) self.logger = logger self.autoReady = True self.credentials = Credentials(self.request) @@ -54,7 +54,7 @@ def ready(self): def ready(self, ready): if ready: ready = fnv1.Ready.READY_TRUE - elif ready == None or (isinstance(ready, function.protobuf.Values) and ready._isUnknown): + elif ready == None or (isinstance(ready, protobuf.Values) and ready._isUnknown): ready = fnv1.Ready.READY_UNSPECIFIED else: ready = fnv1.Ready.READY_FALSE @@ -115,7 +115,6 @@ def __setattr__(self, key, resource): self[key] = resource def __setitem__(self, key, resource): - print('SETITEM', key, resource) self._composite.response.desired.resources[key].resource = resource def __delattr__(self, key): @@ -216,7 +215,7 @@ def ready(self): def ready(self, ready): if ready: ready = fnv1.Ready.READY_TRUE - elif ready == None or (isinstance(ready, function.protobuf.Values) and ready._isUnknown): + elif ready == None or (isinstance(ready, protobuf.Values) and ready._isUnknown): ready = fnv1.Ready.READY_UNSPECIFIED else: ready = fnv1.Ready.READY_FALSE @@ -422,7 +421,7 @@ def claim(self, claim): if bool(self): if claim: self._result.target = fnv1.Target.TARGET_COMPOSITE_AND_CLAIM - elif claim == None or (isinstance(claim, function.protobuf.Values) and claim._isUnknown): + elif claim == None or (isinstance(claim, protobuf.Values) and claim._isUnknown): self._result.target = fnv1.Target.TARGET_UNSPECIFIED else: self._result.target = fnv1.Target.TARGET_COMPOSITE @@ -461,7 +460,7 @@ def __getitem__(self, type): return Condition(self, type) -class Condition(function.protobuf.ProtobufValue): +class Condition(protobuf.ProtobufValue): def __init__(self, conditions, type): self._conditions = conditions self.type = type @@ -509,7 +508,7 @@ def status(self, status): condition.status = fnv1.Status.STATUS_CONDITION_TRUE elif status == None: condition.status = fnv1.Status.STATUS_CONDITION_UNKNOWN - elif isinstance(status, function.protobuf.Values) and status._isUnknown: + elif isinstance(status, protobuf.Values) and status._isUnknown: condition.status = fnv1.Status.STATUS_CONDITION_UNSPECIFIED else: condition.status = fnv1.Status.STATUS_CONDITION_FALSE @@ -556,7 +555,7 @@ def claim(self, claim): condition = self._find_condition(True) if claim: condition.target = fnv1.Target.TARGET_COMPOSITE_AND_CLAIM - elif claim == None or (isinstance(claim, function.protobuf.Values) and claim._isUnknown): + elif claim == None or (isinstance(claim, protobuf.Values) and claim._isUnknown): condition.target = fnv1.Target.TARGET_UNSPECIFIED else: condition.target = fnv1.Target.TARGET_COMPOSITE diff --git a/function/fn.py b/crossplane/pythonic/function.py similarity index 55% rename from function/fn.py rename to crossplane/pythonic/function.py index 6e0de12..a1529b2 100644 --- a/function/fn.py +++ b/crossplane/pythonic/function.py @@ -2,6 +2,8 @@ import asyncio import base64 +import builtins +import importlib import inspect import grpc @@ -9,8 +11,16 @@ import crossplane.function.response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 -import function.composite -import function.protobuf +from .. import pythonic + +builtins.BaseComposite = pythonic.BaseComposite +builtins.Map = pythonic.Map +builtins.List = pythonic.List +builtins.Unknown = pythonic.Unknown +builtins.Yaml = pythonic.Yaml +builtins.Json = pythonic.Json +builtins.B64Encode = pythonic.B64Encode +builtins.B64Decode = pythonic.B64Decode class FunctionRunner(grpcv1.FunctionRunnerService): @@ -19,7 +29,7 @@ class FunctionRunner(grpcv1.FunctionRunnerService): def __init__(self): """Create a new FunctionRunner.""" self.logger = crossplane.function.logging.get_logger() - self.modules = {} + self.clazzes = {} async def RunFunction( self, request: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext @@ -52,23 +62,47 @@ async def RunFunction( return response composite = input['composite'] - module = self.modules.get(composite) - if not module: - module = Module() - try: - exec(composite, module.__dict__) - except Exception as e: - crossplane.function.response.fatal(response, f"Exec exception: {e}") - logger.exception('Exec exception') + clazz = self.clazzes.get(composite) + if not clazz: + if '\n' in composite: + module = Module() + try: + exec(composite, module.__dict__) + except Exception as e: + crossplane.function.response.fatal(response, f"Exec exception: {e}") + logger.exception('Exec exception') + return response + composite = ['