# Copyright 2016-2024, Pulumi Corporation. # # 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. from __future__ import annotations from typing import ( TYPE_CHECKING, Awaitable, Callable, Dict, List, Mapping, Union, cast, Optional, ) import uuid import grpc from grpc import aio from google.protobuf.message import Message from .. import log from .proto import ( alias_pb2, callback_pb2, callback_pb2_grpc, resource_pb2, resource_pb2_grpc, ) from .rpc import deserialize_properties, serialize_properties from ..invoke import InvokeOptions, InvokeTransform if TYPE_CHECKING: from ..resource import Alias, ResourceOptions, ResourceTransform # _MAX_RPC_MESSAGE_SIZE raises the gRPC Max Message size from `4194304` (4mb) to `419430400` (400mb) _MAX_RPC_MESSAGE_SIZE = 1024 * 1024 * 400 _GRPC_CHANNEL_OPTIONS = [("grpc.max_receive_message_length", _MAX_RPC_MESSAGE_SIZE)] _CallbackFunction = Callable[[bytes], Awaitable[Message]] class _CallbackServicer(callback_pb2_grpc.CallbacksServicer): _servicers: List[_CallbackServicer] = [] _callbacks: Dict[str, _CallbackFunction] _monitor: resource_pb2_grpc.ResourceMonitorStub _server: aio.Server _target: str _transforms: Dict[Union[ResourceTransform, InvokeTransform], str] def __init__(self, monitor: resource_pb2_grpc.ResourceMonitorStub): log.debug("Creating CallbackServicer") _CallbackServicer._servicers.append(self) self._callbacks = {} self._transforms = {} self._monitor = monitor self._server = aio.server(options=_GRPC_CHANNEL_OPTIONS) callback_pb2_grpc.add_CallbacksServicer_to_server(self, self._server) port = self._server.add_insecure_port(address="127.0.0.1:0") self._target = f"127.0.0.1:{port}" async def serve(self): await self._server.start() @classmethod async def shutdown(cls): for servicer in cls._servicers: await servicer._server.stop(grace=0) # aio handles this being async but the pyi typings don't expect it. async def Invoke( # pylint: disable=invalid-overridden-method self, request: callback_pb2.CallbackInvokeRequest, context ) -> callback_pb2.CallbackInvokeResponse: log.debug(f"Invoke callback {request.token}") callback = self._callbacks.get(request.token) if callback is None: context.abort( grpc.StatusCode.INVALID_ARGUMENT, f"Callback with token {request.token} not found!", ) raise Exception("Callback not found!") response = await callback(request.request) return callback_pb2.CallbackInvokeResponse( response=response.SerializeToString() ) def register_transform(self, transform: ResourceTransform) -> callback_pb2.Callback: # If this transform function has already been registered, return it. token = self._transforms.get(transform) if token is not None: return callback_pb2.Callback(token=token, target=self._target) from ..resource import ( # pylint: disable=import-outside-toplevel ResourceTransformArgs, ResourceTransformResult, ) async def cb(s: bytes) -> Message: request: resource_pb2.TransformRequest = ( resource_pb2.TransformRequest.FromString(s) ) args = ResourceTransformArgs( custom=request.custom, type_=request.type, name=request.name, props=deserialize_properties(request.properties), opts=self._resource_options(request), ) maybeAwaitable = transform(args) result: Optional[ResourceTransformResult] = None if isinstance(maybeAwaitable, Awaitable): result = await maybeAwaitable else: result = maybeAwaitable if result is None: return resource_pb2.TransformResponse( properties=request.properties, options=request.options, ) result_props = await serialize_properties(result.props, {}) result_opts = ( await self._transformation_resource_options(result.opts) if result.opts is not None else None ) return resource_pb2.TransformResponse( properties=result_props, options=result_opts, ) token = str(uuid.uuid4()) self._callbacks[token] = cb self._transforms[transform] = token return callback_pb2.Callback( token=token, target=self._target, ) def register_stack_transform(self, transform: ResourceTransform): callback = self.register_transform(transform) try: self._monitor.RegisterStackTransform(callback) except: # Remove the transform since we didn't manage to actually register it. self._transforms.pop(transform) self._callbacks.pop(callback.token) raise def do_register_invoke_transform( self, transform: InvokeTransform ) -> callback_pb2.Callback: # If this transform function has already been registered, return it. token = self._transforms.get(transform) if token is not None: return callback_pb2.Callback(token=token, target=self._target) from ..invoke import ( # pylint: disable=import-outside-toplevel InvokeTransformArgs, InvokeTransformResult, ) async def cb(s: bytes) -> Message: request: resource_pb2.TransformInvokeRequest = ( resource_pb2.TransformInvokeRequest.FromString(s) ) args = InvokeTransformArgs( token=request.token, args=deserialize_properties(request.args), opts=self._invoke_options(request), ) maybeAwaitable = transform(args) result: Optional[InvokeTransformResult] = None if isinstance(maybeAwaitable, Awaitable): result = await maybeAwaitable else: result = maybeAwaitable if result is None: return resource_pb2.TransformInvokeResponse( args=request.args, options=request.options, ) result_args = await serialize_properties(result.args, {}) result_opts = ( await self._transformation_invoke_options(result.opts) if result.opts is not None else None ) return resource_pb2.TransformInvokeResponse( args=result_args, options=result_opts, ) token = str(uuid.uuid4()) self._callbacks[token] = cb self._transforms[transform] = token return callback_pb2.Callback( token=token, target=self._target, ) def register_invoke_transform(self, transform: InvokeTransform): callback = self.do_register_invoke_transform(transform) try: self._monitor.RegisterStackInvokeTransform(callback) except: # Remove the transform since we didn't manage to actually register it. self._transforms.pop(transform) self._callbacks.pop(callback.token) raise def _resource_options( self, request: resource_pb2.TransformRequest ) -> ResourceOptions: from ..resource import ( # pylint: disable=import-outside-toplevel CustomTimeouts, DependencyProviderResource, DependencyResource, ResourceOptions, ) opts = ( request.options if request.HasField("options") else resource_pb2.TransformResourceOptions() ) ropts = ResourceOptions() if opts.HasField("delete_before_replace"): ropts.delete_before_replace = opts.delete_before_replace additional_secret_outputs = list(opts.additional_secret_outputs) if additional_secret_outputs: ropts.additional_secret_outputs = additional_secret_outputs providers = { k: DependencyProviderResource(v) for k, v in opts.providers.items() } if providers: ropts.providers = providers aliases = [self._alias(a) for a in opts.aliases] if aliases: ropts.aliases = aliases if opts.HasField("custom_timeouts"): custom_timeouts = CustomTimeouts() if opts.custom_timeouts.create: custom_timeouts.create = opts.custom_timeouts.create if opts.custom_timeouts.update: custom_timeouts.update = opts.custom_timeouts.update if opts.custom_timeouts.delete: custom_timeouts.delete = opts.custom_timeouts.delete ropts.custom_timeouts = custom_timeouts if opts.deleted_with: ropts.deleted_with = DependencyResource(opts.deleted_with) depends_on = [DependencyResource(d) for d in opts.depends_on] if depends_on: ropts.depends_on = depends_on ignore_changes = list(opts.ignore_changes) if ignore_changes: ropts.ignore_changes = ignore_changes if request.parent: ropts.parent = DependencyResource(request.parent) if opts.plugin_download_url: ropts.plugin_download_url = opts.plugin_download_url if opts.protect: ropts.protect = opts.protect if opts.provider: ropts.provider = DependencyProviderResource(opts.provider) replace_on_changes = list(opts.replace_on_changes) if replace_on_changes: ropts.replace_on_changes = replace_on_changes if opts.retain_on_delete: ropts.retain_on_delete = opts.retain_on_delete if opts.version: ropts.version = opts.version return ropts def _alias(self, alias: alias_pb2.Alias) -> Union[str, Alias]: if alias.HasField("urn"): return alias.urn # pylint: disable=import-outside-toplevel from ..resource import Alias a = Alias() if alias.spec.name: a.name = alias.spec.name if alias.spec.type: a.type_ = alias.spec.type if alias.spec.project: a.project = alias.spec.project if alias.spec.stack: a.stack = alias.spec.stack if alias.spec.parentUrn: a.parent = alias.spec.parentUrn elif alias.spec.noParent: a.parent = None return a async def _transformation_resource_options( self, opts: ResourceOptions ) -> resource_pb2.TransformResourceOptions: from ._depends_on import ( # pylint: disable=import-outside-toplevel _resolve_depends_on_urns, ) from .resource import ( # pylint: disable=import-outside-toplevel _create_custom_timeouts, _create_provider_ref, create_alias_spec, ) from ..resource import ( # pylint: disable=import-outside-toplevel Alias, ProviderResource, _collapse_providers, ) from ..output import Output # pylint: disable=import-outside-toplevel aliases: List[alias_pb2.Alias] = [] if opts.aliases: for alias in opts.aliases: resolved = await Output.from_input(alias).future() if resolved is None: continue if isinstance(resolved, str): aliases.append(alias_pb2.Alias(urn=resolved)) else: spec = await create_alias_spec(cast(Alias, resolved)) aliases.append(alias_pb2.Alias(spec=spec)) custom_timeouts = None if opts.custom_timeouts is not None: custom_timeouts = _create_custom_timeouts(opts.custom_timeouts) depends_on = await _resolve_depends_on_urns(opts._depends_on_list()) ignore_changes = None if opts.ignore_changes: ignore_changes = list(opts.ignore_changes) replace_on_changes = None if opts.replace_on_changes: replace_on_changes = list(opts.replace_on_changes) additional_secret_outputs = None if opts.additional_secret_outputs: additional_secret_outputs = list(opts.additional_secret_outputs) result = resource_pb2.TransformResourceOptions( aliases=aliases or None, custom_timeouts=custom_timeouts, depends_on=depends_on or None, ignore_changes=ignore_changes, replace_on_changes=replace_on_changes, additional_secret_outputs=additional_secret_outputs, ) if opts.deleted_with is not None: result.deleted_with = cast(str, await opts.deleted_with.urn.future()) if opts.plugin_download_url: result.plugin_download_url = opts.plugin_download_url if opts.protect: result.protect = opts.protect if opts.retain_on_delete: result.retain_on_delete = opts.retain_on_delete if opts.version: result.version = opts.version if opts.provider is not None: result.provider = await _create_provider_ref(opts.provider) if opts.delete_before_replace: result.delete_before_replace = opts.delete_before_replace if opts.providers: _collapse_providers(opts) providers = cast(Mapping[str, ProviderResource], opts.providers) result.providers.update( {k: await _create_provider_ref(v) for k, v in providers.items()} ) return result def _invoke_options( self, request: resource_pb2.TransformInvokeRequest ) -> InvokeOptions: from ..resource import ( # pylint: disable=import-outside-toplevel DependencyProviderResource, ) opts = ( request.options if request.HasField("options") else resource_pb2.TransformInvokeOptions() ) ropts = InvokeOptions() if opts.plugin_download_url: ropts.plugin_download_url = opts.plugin_download_url if opts.provider: ropts.provider = DependencyProviderResource(opts.provider) if opts.version: ropts.version = opts.version return ropts async def _transformation_invoke_options( self, opts: InvokeOptions ) -> resource_pb2.TransformInvokeOptions: from .resource import ( # pylint: disable=import-outside-toplevel _create_provider_ref, ) result = resource_pb2.TransformInvokeOptions() if opts.plugin_download_url: result.plugin_download_url = opts.plugin_download_url if opts.version: result.version = opts.version if opts.provider is not None: result.provider = await _create_provider_ref(opts.provider) return result