diff --git a/.github/workflows/notebook-local-verify.yaml b/.github/workflows/notebook-local-verify.yaml deleted file mode 100644 index 8a280c3c0..000000000 --- a/.github/workflows/notebook-local-verify.yaml +++ /dev/null @@ -1,41 +0,0 @@ -name: Notebook LocalClient tests - -on: - pull_request: - branches: [ main ] - -permissions: - contents: read - -jobs: - tests: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #4.1.7 - - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f #5.1.1 - with: - python-version: '3.11' - - name: patch notebooks - shell: bash - run: | - for f in tests/basic/*.py; do sed -i "s/import ServerlessClient/import LocalClient/;s/= ServerlessClient(/= LocalClient(/;/token=os\.environ\.get/d;/host=os\.environ\.get/d" "$f"; done - for f in tests/experimental/*.py; do sed -i "s/import ServerlessClient/import LocalClient/;s/= ServerlessClient(/= LocalClient(/;/token=os\.environ\.get/d;/host=os\.environ\.get/d" "$f"; done - rm tests/basic/06_function.py - rm tests/experimental/file_download.py - rm tests/experimental/manage_data_directory.py - - name: install dependencies - shell: bash - run: pip install client/ - - name: Run basic notebooks - shell: bash - run: | - cd tests/basic - for f in *.py; do echo "$f" && IN_TEST=True python "$f"; done - cd - - - name: Run experimental notebooks - shell: bash - run: | - cd tests/experimental - for f in *.py; do echo "$f" && IN_TEST=True python "$f"; done - cd - diff --git a/charts/qiskit-serverless/Chart.lock b/charts/qiskit-serverless/Chart.lock index 00ff750a1..2bdde9024 100644 --- a/charts/qiskit-serverless/Chart.lock +++ b/charts/qiskit-serverless/Chart.lock @@ -1,7 +1,7 @@ dependencies: - name: gateway repository: "" - version: 0.18.0 + version: 0.18.1 - name: nginx-ingress-controller repository: https://charts.bitnami.com/bitnami version: 9.11.0 @@ -11,5 +11,5 @@ dependencies: - name: kuberay-operator repository: https://ray-project.github.io/kuberay-helm version: 1.1.1 -digest: sha256:235cd506a12feb47413a0d091f440ae195d19aa7040a1cae1e4cb7e8e8e6144a -generated: "2024-11-04T13:26:38.419438091Z" +digest: sha256:d94ff7fe13a35912b401f0487f0b42715fc63defad450c6f882cdebc9f916155 +generated: "2024-12-16T14:18:23.162250097Z" diff --git a/charts/qiskit-serverless/Chart.yaml b/charts/qiskit-serverless/Chart.yaml index 0369610cf..2e0fd109c 100644 --- a/charts/qiskit-serverless/Chart.yaml +++ b/charts/qiskit-serverless/Chart.yaml @@ -4,13 +4,13 @@ description: Qiskit-Serverless helm chart that contains different dependencies. type: application -version: 0.18.0 -appVersion: "0.18.0" +version: 0.18.1 +appVersion: "0.18.1" dependencies: - name: gateway condition: gatewayEnable - version: 0.18.0 + version: 0.18.1 - name: nginx-ingress-controller condition: nginxIngressControllerEnable version: 9.11.0 diff --git a/charts/qiskit-serverless/charts/gateway/Chart.yaml b/charts/qiskit-serverless/charts/gateway/Chart.yaml index f8ce445d3..1a1eae550 100644 --- a/charts/qiskit-serverless/charts/gateway/Chart.yaml +++ b/charts/qiskit-serverless/charts/gateway/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.18.0 +version: 0.18.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.18.0" +appVersion: "0.18.1" diff --git a/charts/qiskit-serverless/charts/gateway/values.yaml b/charts/qiskit-serverless/charts/gateway/values.yaml index cb2498229..899912a67 100644 --- a/charts/qiskit-serverless/charts/gateway/values.yaml +++ b/charts/qiskit-serverless/charts/gateway/values.yaml @@ -18,7 +18,7 @@ application: superuser: enable: true ray: - nodeImage: "icr.io/quantum-public/qiskit-serverless/ray-node:0.18.0" + nodeImage: "icr.io/quantum-public/qiskit-serverless/ray-node:0.18.1" cpu: 2 memory: 2 gpu: 1 @@ -27,7 +27,7 @@ application: maxReplicas: 4 opensslImage: registry.access.redhat.com/ubi8/openssl:8.8-9 kubectlImage: alpine/k8s:1.29.2@sha256:a51aa37f0a34ff827c7f2f9cb7f6fbb8f0e290fa625341be14c2fcc4b1880f60 - proxyImage: "icr.io/quantum-public/qiskit-serverless/proxy:0.18.0" + proxyImage: "icr.io/quantum-public/qiskit-serverless/proxy:0.18.1" scrapeWithPrometheus: true openTelemetry: false openTelemetryCollector: diff --git a/charts/qiskit-serverless/values.yaml b/charts/qiskit-serverless/values.yaml index 55faff666..1ef11acea 100644 --- a/charts/qiskit-serverless/values.yaml +++ b/charts/qiskit-serverless/values.yaml @@ -2,7 +2,7 @@ # Qiskit Serverless Info # =================== global: - version: 0.18.0 + version: 0.18.1 # =================== # Qiskit Serverless configs @@ -47,7 +47,7 @@ gateway: image: repository: "icr.io/quantum-public/qiskit-serverless/gateway" pullPolicy: IfNotPresent - tag: "0.18.0" + tag: "0.18.1" application: siteHost: "http://gateway:8000" rayHost: "http://kuberay-head-svc:8265" @@ -59,7 +59,7 @@ gateway: type: ClusterIP port: 8000 ray: - nodeImage: "icr.io/quantum-public/qiskit-serverless/ray-node:0.18.0" + nodeImage: "icr.io/quantum-public/qiskit-serverless/ray-node:0.18.1" opensslImage: registry.access.redhat.com/ubi8/openssl:8.8-9 kubectlImage: alpine/k8s:1.29.2@sha256:a51aa37f0a34ff827c7f2f9cb7f6fbb8f0e290fa625341be14c2fcc4b1880f60 gpuJobsConfig: "/tmp/gpujobs/gpu-jobs.json" diff --git a/client/qiskit_serverless/VERSION.txt b/client/qiskit_serverless/VERSION.txt index 47d04a528..6b2d58c8e 100644 --- a/client/qiskit_serverless/VERSION.txt +++ b/client/qiskit_serverless/VERSION.txt @@ -1 +1 @@ -0.18.0 \ No newline at end of file +0.18.1 \ No newline at end of file diff --git a/client/qiskit_serverless/core/clients/local_client.py b/client/qiskit_serverless/core/clients/local_client.py index 237808959..d7a4966b2 100644 --- a/client/qiskit_serverless/core/clients/local_client.py +++ b/client/qiskit_serverless/core/clients/local_client.py @@ -40,8 +40,8 @@ from qiskit_ibm_runtime import QiskitRuntimeService from qiskit_serverless.core.constants import ( + JOB_ARGUMENTS_FILE, OT_PROGRAM_NAME, - ENV_JOB_ARGUMENTS, ) from qiskit_serverless.core.client import BaseClient from qiskit_serverless.core.job import ( @@ -112,11 +112,16 @@ def run( **(saved_program.env_vars or {}), **{OT_PROGRAM_NAME: saved_program.title}, **{"PATH": os.environ["PATH"]}, - **{ENV_JOB_ARGUMENTS: json.dumps(arguments, cls=QiskitObjectsEncoder)}, } + with open(JOB_ARGUMENTS_FILE, "w", encoding="utf-8") as f: + json.dump(arguments, f, cls=QiskitObjectsEncoder) + with Popen( - ["python", saved_program.working_dir + saved_program.entrypoint], + [ + "python", + os.path.join(saved_program.working_dir, saved_program.entrypoint), + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, @@ -126,6 +131,9 @@ def run( if pipe.wait(): status = "FAILED" output, _ = pipe.communicate() + + os.remove(JOB_ARGUMENTS_FILE) + results = re.search("\nSaved Result:(.+?):End Saved Result\n", output) result = "" if results: diff --git a/client/qiskit_serverless/core/clients/serverless_client.py b/client/qiskit_serverless/core/clients/serverless_client.py index afc9dbc87..9f4c6df4e 100644 --- a/client/qiskit_serverless/core/clients/serverless_client.py +++ b/client/qiskit_serverless/core/clients/serverless_client.py @@ -430,9 +430,15 @@ def file_delete(self, file: str, provider: Optional[str] = None): """Deletes file uploaded or produced by the programs,""" return self._files_client.delete(file, provider) - def file_upload(self, file: str, provider: Optional[str] = None): + def file_upload( + self, file: str, function: QiskitFunction, provider: Optional[str] = None + ): + """Upload file.""" + return self._files_client.upload(file, function, provider) + + def provider_file_upload(self, file: str, function: QiskitFunction, provider: str): """Upload file.""" - return self._files_client.upload(file, provider) + return self._files_client.provider_upload(file, function, provider) class IBMServerlessClient(ServerlessClient): diff --git a/client/qiskit_serverless/core/constants.py b/client/qiskit_serverless/core/constants.py index e63682e71..c495fdeb8 100644 --- a/client/qiskit_serverless/core/constants.py +++ b/client/qiskit_serverless/core/constants.py @@ -39,6 +39,8 @@ ENV_JOB_ID_GATEWAY = "ENV_JOB_ID_GATEWAY" ENV_JOB_ARGUMENTS = "ENV_JOB_ARGUMENTS" +JOB_ARGUMENTS_FILE = "arguments.serverless" + # artifact MAX_ARTIFACT_FILE_SIZE_MB = 50 MAX_ARTIFACT_FILE_SIZE_MB_OVERRIDE = "MAX_ARTIFACT_FILE_SIZE_MB_OVERRIDE" diff --git a/client/qiskit_serverless/core/files.py b/client/qiskit_serverless/core/files.py index 0ee0b5d96..f23ddfacd 100644 --- a/client/qiskit_serverless/core/files.py +++ b/client/qiskit_serverless/core/files.py @@ -132,13 +132,34 @@ def provider_download( ) @_trace - def upload(self, file: str, provider: Optional[str] = None) -> Optional[str]: + def upload( + self, file: str, function: QiskitFunction, provider: Optional[str] = None + ) -> Optional[str]: """Uploads file.""" with open(file, "rb") as f: with requests.post( os.path.join(self._files_url, "upload"), files={"file": f}, - data={"provider": provider}, + params={"provider": provider, "function": function.title}, + stream=True, + headers={"Authorization": f"Bearer {self._token}"}, + timeout=REQUESTS_STREAMING_TIMEOUT, + ) as req: + if req.ok: + return req.text + return "Upload failed" + return "Can not open file" + + @_trace + def provider_upload( + self, file: str, function: QiskitFunction, provider: str + ) -> Optional[str]: + """Uploads file to provider/function file storage.""" + with open(file, "rb") as f: + with requests.post( + os.path.join(self._files_url, "upload"), + files={"file": f}, + params={"provider": provider, "function": function.title}, stream=True, headers={"Authorization": f"Bearer {self._token}"}, timeout=REQUESTS_STREAMING_TIMEOUT, diff --git a/client/qiskit_serverless/serializers/program_serializers.py b/client/qiskit_serverless/serializers/program_serializers.py index a98072d9a..13f81c702 100644 --- a/client/qiskit_serverless/serializers/program_serializers.py +++ b/client/qiskit_serverless/serializers/program_serializers.py @@ -33,6 +33,8 @@ from qiskit_ibm_runtime import QiskitRuntimeService from qiskit_ibm_runtime.utils.json import RuntimeDecoder, RuntimeEncoder +from qiskit_serverless.core.constants import JOB_ARGUMENTS_FILE + class QiskitObjectsEncoder(RuntimeEncoder): """Json encoder for Qiskit objects.""" @@ -80,7 +82,7 @@ def get_arguments() -> Dict[str, Any]: Dictionary of arguments. """ arguments = "{}" - if os.path.isfile("arguments.serverless"): - with open("arguments.serverless", "r", encoding="utf-8") as f: + if os.path.isfile(JOB_ARGUMENTS_FILE): + with open(JOB_ARGUMENTS_FILE, "r", encoding="utf-8") as f: arguments = f.read() return json.loads(arguments, cls=QiskitObjectsDecoder) diff --git a/client/tests/serializers/test_program_serializers.py b/client/tests/serializers/test_program_serializers.py index 93ac794a1..16a096fd5 100644 --- a/client/tests/serializers/test_program_serializers.py +++ b/client/tests/serializers/test_program_serializers.py @@ -18,6 +18,7 @@ from qiskit.circuit.random import random_circuit from qiskit_ibm_runtime import QiskitRuntimeService +from qiskit_serverless.core.constants import JOB_ARGUMENTS_FILE from qiskit_serverless.serializers.program_serializers import ( QiskitObjectsDecoder, QiskitObjectsEncoder, @@ -59,7 +60,7 @@ def test_argument_parsing(self): circuit = random_circuit(4, 2) array = np.array([[42.0], [0.0]]) - with open("arguments.serverless", "w", encoding="utf-8") as f: + with open(JOB_ARGUMENTS_FILE, "w", encoding="utf-8") as f: json.dump({"circuit": circuit, "array": array}, f, cls=QiskitObjectsEncoder) parsed_arguments = get_arguments() diff --git a/docker-compose.yaml b/docker-compose.yaml index 86f22c0fe..7d7d748bb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,7 +3,7 @@ services: ray-head: user: "0" container_name: ray-head - image: icr.io/quantum-public/qiskit-serverless/ray-node:${VERSION:-0.18.0} + image: icr.io/quantum-public/qiskit-serverless/ray-node:${VERSION:-0.18.1} entrypoint: [ "ray", "start", "--head", "--port=6379", "--dashboard-host=0.0.0.0", "--block" @@ -28,7 +28,7 @@ services: always gateway: container_name: gateway - image: icr.io/quantum-public/qiskit-serverless/gateway:${VERSION:-0.18.0} + image: icr.io/quantum-public/qiskit-serverless/gateway:${VERSION:-0.18.1} command: gunicorn main.wsgi:application --bind 0.0.0.0:8000 --workers=4 ports: - 8000:8000 @@ -53,7 +53,7 @@ services: - postgres scheduler: container_name: scheduler - image: icr.io/quantum-public/qiskit-serverless/gateway:${VERSION:-0.18.0} + image: icr.io/quantum-public/qiskit-serverless/gateway:${VERSION:-0.18.1} entrypoint: "./scripts/scheduler.sh" environment: - DEBUG=0 diff --git a/docs/deployment/cloud.rst b/docs/deployment/cloud.rst index 48989e956..07bf24cd3 100644 --- a/docs/deployment/cloud.rst +++ b/docs/deployment/cloud.rst @@ -84,7 +84,7 @@ Once your cluster is ready, the installation is relatively straightforward with and run the next commands: .. code-block:: - :caption: run this commands with the release version like 0.18.0 in x.y.z (2 places) + :caption: run this commands with the release version like 0.18.1 in x.y.z (2 places) $ helm -n install qiskit-serverless --create-namespace https://github.com/Qiskit/qiskit-serverless/releases/download/vx.y.z/qiskit-serverless-x.y.z.tgz @@ -119,6 +119,6 @@ with the configuration of your domain and provider. Optionally, you can install an observability package to handle logging and monitoring on your cluster by running the following command: .. code-block:: - :caption: run this commands with the release version like 0.18.0 in x.y.z (2 places) using the same namespace as in the previous helm command + :caption: run this commands with the release version like 0.18.1 in x.y.z (2 places) using the same namespace as in the previous helm command $ helm -n install qs-observability https://github.com/Qiskit/qiskit-serverless/releases/download/vx.y.z/qs-observability-x.y.z.tgz diff --git a/docs/deployment/custom_function/Sample-Dockerfile b/docs/deployment/custom_function/Sample-Dockerfile index 4ed04551b..b04716fa1 100644 --- a/docs/deployment/custom_function/Sample-Dockerfile +++ b/docs/deployment/custom_function/Sample-Dockerfile @@ -1,4 +1,4 @@ -FROM icr.io/quantum-public/qiskit-serverless/ray-node:0.18.0 +FROM icr.io/quantum-public/qiskit-serverless/ray-node:0.18.1 # install all necessary dependencies for your custom image diff --git a/docs/deployment/deploying_custom_image_function.rst b/docs/deployment/deploying_custom_image_function.rst index b07bfbe57..410f9f920 100644 --- a/docs/deployment/deploying_custom_image_function.rst +++ b/docs/deployment/deploying_custom_image_function.rst @@ -50,7 +50,7 @@ In our simple case it will look something like this: .. code-block:: :caption: Dockerfile for custom image function. - FROM icr.io/quantum-public/qiskit-serverless/ray-node:0.18.0 + FROM icr.io/quantum-public/qiskit-serverless/ray-node:0.18.1 # install all necessary dependencies for your custom image @@ -92,7 +92,8 @@ Run it: Or if you are using kubernetes you will need to create the cluster and load the image in Kind: .. code-block:: - :caption: Run docker compose + :caption: Run your local cluster + tox -e cluster-deploy kind load docker-image test-local-provider-function:latest diff --git a/docs/deployment/example_custom_image_function.rst b/docs/deployment/example_custom_image_function.rst index 725b4721b..81401fa12 100644 --- a/docs/deployment/example_custom_image_function.rst +++ b/docs/deployment/example_custom_image_function.rst @@ -49,7 +49,7 @@ Dockerfile .. code-block:: :caption: Dockerfile - FROM icr.io/quantum-public/qiskit-serverless/ray-node:0.18.0 + FROM icr.io/quantum-public/qiskit-serverless/ray-node:0.18.1 # install all necessary dependencies for your custom image @@ -90,7 +90,8 @@ Run it Or if you are using kubernetes you will need to create the cluster and load the image in Kind .. code-block:: - :caption: Run docker compose + :caption: Run your local cluster + tox -e cluster-deploy kind load docker-image test-local-provider-function:latest diff --git a/gateway/api/services/file_storage.py b/gateway/api/services/file_storage.py index 5ae0f4e70..904eda48b 100644 --- a/gateway/api/services/file_storage.py +++ b/gateway/api/services/file_storage.py @@ -10,6 +10,7 @@ from wsgiref.util import FileWrapper from django.conf import settings +from django.core.files import File from utils import sanitize_file_path @@ -28,8 +29,6 @@ class WorkingDir(Enum): PROVIDER_STORAGE = 2 -SUPPORTED_FILE_EXTENSIONS = [".tar", ".h5"] - logger = logging.getLogger("gateway") @@ -44,21 +43,6 @@ class FileStorage: # pylint: disable=too-few-public-methods provider_name (str | None): name of the provider in caseis needed to build the path """ - @staticmethod - def is_valid_extension(file_name: str) -> bool: - """ - This method verifies if the extension of the file is valid. - - Args: - file_name (str): file name to verify - - Returns: - bool: True or False if it is valid or not - """ - return any( - file_name.endswith(extension) for extension in SUPPORTED_FILE_EXTENSIONS - ) - def __init__( self, username: str, @@ -120,8 +104,8 @@ def __get_provider_path(self, function_title: str, provider_name: str) -> str: def get_files(self) -> list[str]: """ This method returns a list of file names following the next rules: - - Only files with supported extensions are listed - It returns only files from a user or a provider file storage + - Directories are excluded Returns: list[str]: list of file names @@ -137,8 +121,8 @@ def get_files(self) -> list[str]: return [ os.path.basename(path) - for extension in SUPPORTED_FILE_EXTENSIONS - for path in glob.glob(f"{self.file_path}/*{extension}") + for path in glob.glob(f"{self.file_path}/*") + if os.path.isfile(path) ] def get_file(self, file_name: str) -> Optional[Tuple[FileWrapper, str, int]]: @@ -147,6 +131,9 @@ def get_file(self, file_name: str) -> Optional[Tuple[FileWrapper, str, int]]: - Only files with supported extensions are available to download - It returns only a file from a user or a provider file storage + Args: + file_name (str): the name of the file to download + Returns: FileWrapper: the file itself str: with the type of the file @@ -171,3 +158,56 @@ def get_file(self, file_name: str) -> Optional[Tuple[FileWrapper, str, int]]: file_size = os.path.getsize(path_to_file) return file_wrapper, file_type, file_size + + def upload_file(self, file: File) -> str: + """ + This method upload a file to the specific path: + - Only files with supported extensions are available to download + - It returns only a file from a user or a provider file storage + + Args: + file (django.File): the file to store in the specific path + + Returns: + str: the path where the file was stored + """ + + file_name = sanitize_file_path(file.name) + basename = os.path.basename(file_name) + path_to_file = sanitize_file_path(os.path.join(self.file_path, basename)) + + with open(path_to_file, "wb+") as destination: + for chunk in file.chunks(): + destination.write(chunk) + + return path_to_file + + def remove_file(self, file_name: str) -> bool: + """ + This method remove a file in the path of file_name + + Args: + file_name (str): the name of the file to remove + + Returns: + - True if it was deleted + - False otherwise + """ + + file_name_path = os.path.basename(file_name) + path_to_file = sanitize_file_path(os.path.join(self.file_path, file_name_path)) + + try: + os.remove(path_to_file) + except FileNotFoundError: + logger.warning( + "Directory %s does not exist for file %s.", + path_to_file, + file_name_path, + ) + return False + except OSError as ex: + logger.warning("OSError: %s.", ex.strerror) + return False + + return True diff --git a/gateway/api/v1/views/files.py b/gateway/api/v1/views/files.py index 9609b7ba8..456ad7d15 100644 --- a/gateway/api/v1/views/files.py +++ b/gateway/api/v1/views/files.py @@ -125,36 +125,124 @@ def provider_download(self, request): @swagger_auto_schema( operation_description="Deletes file uploaded or produced by the programs", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "file": openapi.Schema( - type=openapi.TYPE_STRING, description="file name" - ), - "provider": openapi.Schema( - type=openapi.TYPE_STRING, description="provider name" - ), - }, - required=["file"], - ), + manual_parameters=[ + openapi.Parameter( + "file", + openapi.IN_QUERY, + description="File name", + type=openapi.TYPE_STRING, + required=True, + ), + openapi.Parameter( + "function", + openapi.IN_QUERY, + description="Qiskit Function title", + type=openapi.TYPE_STRING, + required=True, + ), + openapi.Parameter( + "provider", + openapi.IN_QUERY, + description="Provider name", + type=openapi.TYPE_STRING, + required=False, + ), + ], ) @action(methods=["DELETE"], detail=False) def delete(self, request): return super().delete(request) + @swagger_auto_schema( + operation_description="Deletes file uploaded or produced by the programs", + manual_parameters=[ + openapi.Parameter( + "file", + openapi.IN_QUERY, + description="File name", + type=openapi.TYPE_STRING, + required=True, + ), + openapi.Parameter( + "function", + openapi.IN_QUERY, + description="Qiskit Function title", + type=openapi.TYPE_STRING, + required=True, + ), + openapi.Parameter( + "provider", + openapi.IN_QUERY, + description="Provider name", + type=openapi.TYPE_STRING, + required=True, + ), + ], + ) + @action(methods=["DELETE"], detail=False, url_path="provider/delete") + def provider_delete(self, request): + return super().provider_delete(request) + @swagger_auto_schema( operation_description="Upload selected file", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ - "file": openapi.Schema(type=openapi.TYPE_FILE, description="file name"), - "provider": openapi.Schema( - type=openapi.TYPE_STRING, description="provider name" - ), + "file": openapi.Schema( + type=openapi.TYPE_FILE, description="File to be uploaded" + ) }, required=["file"], ), + manual_parameters=[ + openapi.Parameter( + "function", + openapi.IN_QUERY, + description="Qiskit Function title", + type=openapi.TYPE_STRING, + required=True, + ), + openapi.Parameter( + "provider", + openapi.IN_QUERY, + description="Provider name", + type=openapi.TYPE_STRING, + required=False, + ), + ], ) @action(methods=["POST"], detail=False) def upload(self, request): return super().upload(request) + + @swagger_auto_schema( + operation_description="Upload a file into the provider directory", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "file": openapi.Schema( + type=openapi.TYPE_FILE, description="File to be uploaded" + ) + }, + required=["file"], + ), + manual_parameters=[ + openapi.Parameter( + "function", + openapi.IN_QUERY, + description="Qiskit Function title", + type=openapi.TYPE_STRING, + required=True, + ), + openapi.Parameter( + "provider", + openapi.IN_QUERY, + description="Provider name", + type=openapi.TYPE_STRING, + required=True, + ), + ], + ) + @action(methods=["POST"], detail=False, url_path="provider/upload") + def provider_upload(self, request): + return super().provider_upload(request) diff --git a/gateway/api/views/files.py b/gateway/api/views/files.py index 4cb40b5fb..a10af8661 100644 --- a/gateway/api/views/files.py +++ b/gateway/api/views/files.py @@ -6,7 +6,6 @@ import logging import os -from django.conf import settings from django.http import StreamingHttpResponse # pylint: disable=duplicate-code @@ -20,10 +19,9 @@ from rest_framework.decorators import action from rest_framework.response import Response -from api.services.file_storage import SUPPORTED_FILE_EXTENSIONS, FileStorage, WorkingDir +from api.services.file_storage import FileStorage, WorkingDir from api.utils import sanitize_file_name, sanitize_name from api.models import Provider, Program -from utils import sanitize_file_path # pylint: disable=duplicate-code logger = logging.getLogger("gateway") @@ -311,15 +309,6 @@ def download(self, request): status=status.HTTP_400_BAD_REQUEST, ) - if not FileStorage.is_valid_extension(requested_file_name): - extensions = ", ".join(SUPPORTED_FILE_EXTENSIONS) - return Response( - { - "message": f"File name needs to have a valid extension: {extensions}" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - function = self.get_function( user=request.user, function_title=function_title, @@ -383,15 +372,6 @@ def provider_download(self, request): status=status.HTTP_400_BAD_REQUEST, ) - if not FileStorage.is_valid_extension(requested_file_name): - extensions = ", ".join(SUPPORTED_FILE_EXTENSIONS) - return Response( - { - "message": f"File name needs to have a valid extension: {extensions}" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - if not self.user_has_provider_access(request.user, provider_name): return Response( {"message": f"Provider {provider_name} doesn't exist."}, @@ -433,70 +413,211 @@ def provider_download(self, request): return response @action(methods=["DELETE"], detail=False) - def delete(self, request): # pylint: disable=invalid-name + def delete(self, request): """Deletes file uploaded or produced by the programs,""" # default response for file not found, overwritten if file is found - response = Response( - {"message": "Requested file was not found."}, - status=status.HTTP_404_NOT_FOUND, - ) tracer = trace.get_tracer("gateway.tracer") ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) with tracer.start_as_current_span("gateway.files.delete", context=ctx): - if request.data and "file" in request.data: - # look for file in user's folder - filename = os.path.basename(request.data["file"]) - provider_name = request.data.get("provider") - user_dir = request.user.username - if provider_name is not None: - if self.check_user_has_provider(request.user, provider_name): - user_dir = provider_name - else: - return response - user_dir = os.path.join( - sanitize_file_path(settings.MEDIA_ROOT), - sanitize_file_path(user_dir), + # look for file in user's folder + username = request.user.username + file_name = sanitize_file_name(request.query_params.get("file", None)) + provider_name = sanitize_name(request.query_params.get("provider")) + function_title = sanitize_name(request.query_params.get("function", None)) + working_dir = WorkingDir.USER_STORAGE + + if not all([file_name, function_title]): + return Response( + {"message": "File name and Qiskit Function title are mandatory"}, + status=status.HTTP_400_BAD_REQUEST, ) - file_path = os.path.join( - sanitize_file_path(user_dir), sanitize_file_path(filename) + + function = self.get_function( + user=request.user, + function_title=function_title, + provider_name=provider_name, + ) + + if not function: + if provider_name: + error_message = f"Qiskit Function {provider_name}/{function_title} doesn't exist." # pylint: disable=line-too-long + else: + error_message = f"Qiskit Function {function_title} doesn't exist." + return Response( + {"message": error_message}, + status=status.HTTP_404_NOT_FOUND, ) - if os.path.exists(user_dir) and os.path.exists(file_path) and filename: - os.remove(file_path) - response = Response( - {"message": "Requested file was deleted."}, - status=status.HTTP_200_OK, - ) - return response + + file_storage = FileStorage( + username=username, + working_dir=working_dir, + function_title=function_title, + provider_name=provider_name, + ) + result = file_storage.remove_file(file_name=file_name) + if not result: + return Response( + {"message": "Requested file was not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + {"message": "Requested file was deleted."}, status=status.HTTP_200_OK + ) + + @action(methods=["DELETE"], detail=False, url_path="provider/delete") + def provider_delete(self, request): + """Deletes file uploaded or produced by the programs,""" + # default response for file not found, overwritten if file is found + tracer = trace.get_tracer("gateway.tracer") + ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) + with tracer.start_as_current_span("gateway.files.delete", context=ctx): + # look for file in user's folder + username = request.user.username + file_name = sanitize_file_name(request.query_params.get("file")) + provider_name = sanitize_name(request.query_params.get("provider")) + function_title = sanitize_name(request.query_params.get("function", None)) + working_dir = WorkingDir.USER_STORAGE + + if not all([file_name, function_title, provider_name]): + return Response( + {"message": "File name and Qiskit Function title are mandatory"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not self.user_has_provider_access(request.user, provider_name): + return Response( + {"message": f"Provider {provider_name} doesn't exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + function = self.get_function( + user=request.user, + function_title=function_title, + provider_name=provider_name, + ) + + if not function: + error_message = f"Qiskit Function {provider_name}/{function_title} doesn't exist." # pylint: disable=line-too-long + return Response( + {"message": error_message}, + status=status.HTTP_404_NOT_FOUND, + ) + + file_storage = FileStorage( + username=username, + working_dir=working_dir, + function_title=function_title, + provider_name=provider_name, + ) + result = file_storage.remove_file(file_name=file_name) + if not result: + return Response( + {"message": "Requested file was not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + {"message": "Requested file was deleted."}, status=status.HTTP_200_OK + ) @action(methods=["POST"], detail=False) - def upload(self, request): # pylint: disable=invalid-name - """Upload selected file.""" - response = Response( - {"message": "Requested file was not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + def upload(self, request): + """ + It upload a file to a specific user paths: + - username/ + - username/provider_name/function_title + """ tracer = trace.get_tracer("gateway.tracer") ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) with tracer.start_as_current_span("gateway.files.download", context=ctx): + username = request.user.username upload_file = request.FILES["file"] - filename = os.path.basename(upload_file.name) - user_dir = request.user.username - if request.data and "provider" in request.data: - provider_name = request.data["provider"] - if provider_name is not None: - if self.check_user_has_provider(request.user, provider_name): - user_dir = provider_name - else: - return response - user_dir = os.path.join( - sanitize_file_path(settings.MEDIA_ROOT), - sanitize_file_path(user_dir), + file_name = sanitize_file_name(upload_file.name) + provider_name = sanitize_name(request.query_params.get("provider", None)) + function_title = sanitize_name(request.query_params.get("function", None)) + working_dir = WorkingDir.USER_STORAGE + + if not all([file_name, function_title]): + return Response( + {"message": "A file and Qiskit Function title are mandatory"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + function = self.get_function( + user=request.user, + function_title=function_title, + provider_name=provider_name, ) - file_path = os.path.join( - sanitize_file_path(user_dir), sanitize_file_path(filename) + if not function: + if provider_name: + error_message = f"Qiskit Function {provider_name}/{function_title} doesn't exist." # pylint: disable=line-too-long + else: + error_message = f"Qiskit Function {function_title} doesn't exist." + return Response( + {"message": error_message}, + status=status.HTTP_404_NOT_FOUND, + ) + + file_storage = FileStorage( + username=username, + working_dir=working_dir, + function_title=function_title, + provider_name=provider_name, ) - with open(file_path, "wb+") as destination: - for chunk in upload_file.chunks(): - destination.write(chunk) - return Response({"message": file_path}) - return Response("server error", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + result = file_storage.upload_file(file=upload_file) + + return Response({"message": result}) + + @action(methods=["POST"], detail=False, url_path="provider/upload") + def provider_upload(self, request): + """ + It upload a file to a specific user paths: + - provider_name/function_title + """ + tracer = trace.get_tracer("gateway.tracer") + ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) + with tracer.start_as_current_span("gateway.files.download", context=ctx): + username = request.user.username + upload_file = request.FILES["file"] + file_name = sanitize_file_name(upload_file.name) + provider_name = sanitize_name(request.query_params.get("provider", None)) + function_title = sanitize_name(request.query_params.get("function", None)) + working_dir = WorkingDir.PROVIDER_STORAGE + + if not all([file_name, function_title, provider_name]): + return Response( + { + "message": "The file, Qiskit Function title and Provider name are mandatory" # pylint: disable=line-too-long + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not self.user_has_provider_access(request.user, provider_name): + return Response( + {"message": f"Provider {provider_name} doesn't exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + function = self.get_function( + user=request.user, + function_title=function_title, + provider_name=provider_name, + ) + if not function: + return Response( + { + "message": f"Qiskit Function {provider_name}/{function_title} doesn't exist." # pylint: disable=line-too-long + }, + status=status.HTTP_404_NOT_FOUND, + ) + + file_storage = FileStorage( + username=username, + working_dir=working_dir, + function_title=function_title, + provider_name=provider_name, + ) + result = file_storage.upload_file(file=upload_file) + + return Response({"message": result}) diff --git a/gateway/main/settings.py b/gateway/main/settings.py index d9b2d19d6..bffb2672c 100644 --- a/gateway/main/settings.py +++ b/gateway/main/settings.py @@ -338,7 +338,7 @@ ), } RAY_NODE_IMAGE = os.environ.get( - "RAY_NODE_IMAGE", "icr.io/quantum-public/qiskit-serverless/ray-node:0.18.0" + "RAY_NODE_IMAGE", "icr.io/quantum-public/qiskit-serverless/ray-node:0.18.1" ) RAY_CLUSTER_WORKER_REPLICAS = int(os.environ.get("RAY_CLUSTER_WORKER_REPLICAS", "1")) RAY_CLUSTER_WORKER_REPLICAS_MAX = int( diff --git a/gateway/tests/api/test_v1_files.py b/gateway/tests/api/test_v1_files.py index 4524c5a60..bf8b98399 100644 --- a/gateway/tests/api/test_v1_files.py +++ b/gateway/tests/api/test_v1_files.py @@ -1,9 +1,10 @@ """Tests files api.""" import os -from urllib.parse import quote_plus +from urllib.parse import urlencode from django.urls import reverse +from pytest import mark from rest_framework import status from rest_framework.test import APITestCase from django.contrib.auth import models @@ -306,21 +307,28 @@ def test_file_delete(self): "fake_media", ) media_root = os.path.normpath(os.path.join(os.getcwd(), media_root)) + function = "personal-program" + file = "artifact_delete.tar" + username = "test_user_2" + functionPath = os.path.join(media_root, username) + + if not os.path.exists(functionPath): + os.makedirs(functionPath) with open( - os.path.join(media_root, "test_user", "artifact_delete.tar"), "w" + os.path.join(functionPath, file), + "w+", ) as fp: fp.write("This is first line") print(fp) fp.close() with self.settings(MEDIA_ROOT=media_root): - user = models.User.objects.get(username="test_user") + query_params = {"function": function, "file": file} + user = models.User.objects.get(username=username) self.client.force_authenticate(user=user) url = reverse("v1:files-delete") - response = self.client.delete( - url, data={"file": "artifact_delete.tar"}, format="json" - ) + response = self.client.delete(f"{url}?{urlencode(query_params)}") self.assertEqual(response.status_code, status.HTTP_200_OK) def test_provider_file_delete(self): @@ -332,23 +340,29 @@ def test_provider_file_delete(self): "fake_media", ) media_root = os.path.normpath(os.path.join(os.getcwd(), media_root)) + provider = "default" + function = "Program" + file = "artifact_delete.tar" + username = "test_user_2" + functionPath = os.path.join(media_root, username, provider, function) + + if not os.path.exists(functionPath): + os.makedirs(functionPath) with open( - os.path.join(media_root, "default", "artifact_delete.tar"), "w" + os.path.join(functionPath, file), + "w+", ) as fp: fp.write("This is first line") print(fp) fp.close() with self.settings(MEDIA_ROOT=media_root): - user = models.User.objects.get(username="test_user_2") + query_params = {"function": function, "provider": provider, "file": file} + user = models.User.objects.get(username=username) self.client.force_authenticate(user=user) - url = reverse("v1:files-delete") - response = self.client.delete( - url, - data={"file": "artifact_delete.tar", "provider": "default"}, - format="json", - ) + url = reverse("v1:files-provider-delete") + response = self.client.delete(f"{url}?{urlencode(query_params)}") self.assertEqual(response.status_code, status.HTTP_200_OK) def test_non_existing_file_delete(self): @@ -360,14 +374,38 @@ def test_non_existing_file_delete(self): "fake_media", ) media_root = os.path.normpath(os.path.join(os.getcwd(), media_root)) + function = "personal-program" + file = "non-existing-artifact_delete.tar" + username = "test_user_2" with self.settings(MEDIA_ROOT=media_root): - user = models.User.objects.get(username="test_user") + query_params = {"function": function, "file": file} + user = models.User.objects.get(username=username) self.client.force_authenticate(user=user) url = reverse("v1:files-delete") - response = self.client.delete( - url, data={"file": "artifact_delete.tar"}, format="json" - ) + response = self.client.delete(f"{url}?{urlencode(query_params)}") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_non_existing_provider_file_delete(self): + """Tests delete file.""" + media_root = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "resources", + "fake_media", + ) + media_root = os.path.normpath(os.path.join(os.getcwd(), media_root)) + provider = "default" + function = "Program" + file = "non-existing-artifact_delete.tar" + username = "test_user_2" + + with self.settings(MEDIA_ROOT=media_root): + query_params = {"function": function, "provider": provider, "file": file} + user = models.User.objects.get(username=username) + self.client.force_authenticate(user=user) + url = reverse("v1:files-provider-delete") + response = self.client.delete(f"{url}?{urlencode(query_params)}") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_file_upload(self): @@ -381,18 +419,22 @@ def test_file_upload(self): media_root = os.path.normpath(os.path.join(os.getcwd(), media_root)) with self.settings(MEDIA_ROOT=media_root): - user = models.User.objects.get(username="test_user") + function = "personal-program" + user = models.User.objects.get(username="test_user_2") self.client.force_authenticate(user=user) url = reverse("v1:files-upload") + with open("README.md") as f: + query_params = {"function": function} response = self.client.post( - url, - data={"file": f}, + f"{url}?{urlencode(query_params)}", + {"file": f}, format="multipart", ) + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(os.path.join(media_root, "test_user", "README.md")) - os.remove(os.path.join(media_root, "test_user", "README.md")) + self.assertTrue(os.path.join(media_root, "test_user_2", "README.md")) + os.remove(os.path.join(media_root, "test_user_2", "README.md")) def test_provider_file_upload(self): """Tests uploading existing file.""" @@ -405,18 +447,25 @@ def test_provider_file_upload(self): media_root = os.path.normpath(os.path.join(os.getcwd(), media_root)) with self.settings(MEDIA_ROOT=media_root): + provider = "default" + function = "Program" user = models.User.objects.get(username="test_user_2") self.client.force_authenticate(user=user) - url = reverse("v1:files-upload") + url = reverse("v1:files-provider-upload") + with open("README.md") as f: + query_params = {"function": function, "provider": provider} response = self.client.post( - url, - data={"file": f, "provider": "default"}, + f"{url}?{urlencode(query_params)}", + {"file": f}, format="multipart", ) + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(os.path.join(media_root, "test_user", "README.md")) - os.remove(os.path.join(media_root, "default", "README.md")) + self.assertTrue( + os.path.join(media_root, "default", "Program", "README.md") + ) + os.remove(os.path.join(media_root, "default", "Program", "README.md")) def test_escape_directory(self): """Tests directory escape / injection.""" diff --git a/gateway/tox.ini b/gateway/tox.ini index 2a5ef96f8..08be0844d 100644 --- a/gateway/tox.ini +++ b/gateway/tox.ini @@ -24,14 +24,12 @@ commands = python manage.py test [testenv:lint] -envdir = .tox/lint skip_install = true commands = black --diff --check . pylint --load-plugins pylint_django --load-plugins pylint_django.checkers.migrations --django-settings-module=main.settings --ignore api.migrations -rn api main [testenv:black] -envdir = .tox/lint skip_install = true commands = black . diff --git a/tests/docker/conftest.py b/tests/docker/conftest.py index 1507c5da9..fda2e3492 100644 --- a/tests/docker/conftest.py +++ b/tests/docker/conftest.py @@ -5,15 +5,32 @@ from pytest import fixture from testcontainers.compose import DockerCompose from qiskit_serverless import ServerlessClient, QiskitFunction +from qiskit_serverless.core.clients.local_client import LocalClient resources_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "source_files" ) +@fixture(scope="module", params=["serverless", "local"]) +def base_client(request): + """Fixture for testing files with every client.""" + if request.param == "serverless": + [compose, serverless] = set_up_serverless_client() + yield serverless + compose.stop() + else: + yield LocalClient() + + @fixture(scope="module") -def serverless_client(): - """Fixture for testing files.""" +def local_client(): + """Fixture for testing files with local client.""" + return LocalClient() + + +def set_up_serverless_client(): + """Auxiliar fixture function to create a serverless client""" compose = DockerCompose( resources_path, compose_file_name="../../../docker-compose-dev.yaml", @@ -37,6 +54,14 @@ def serverless_client(): ) serverless.upload(function) + return [compose, serverless] + + +@fixture(scope="module") +def serverless_client(): + """Fixture for testing files with serverless client.""" + [compose, serverless] = set_up_serverless_client() + yield serverless compose.stop() diff --git a/tests/docker/test_docker.py b/tests/docker/test_docker.py index 8488372e2..1911e1a78 100644 --- a/tests/docker/test_docker.py +++ b/tests/docker/test_docker.py @@ -2,12 +2,13 @@ """Tests jobs.""" import os -from pytest import fixture, raises, mark +from pytest import raises, mark from qiskit import QuantumCircuit from qiskit.circuit.random import random_circuit -from qiskit_serverless import ServerlessClient, QiskitFunction +from qiskit_serverless import QiskitFunction +from qiskit_serverless.core.client import BaseClient from qiskit_serverless.exception import QiskitServerlessException @@ -19,26 +20,20 @@ class TestFunctionsDocker: """Test class for integration testing with docker.""" - @fixture(scope="class") - def simple_function(self): - """Fixture of a simple function""" - return QiskitFunction( + @mark.order(1) + def test_simple_function(self, base_client: BaseClient): + """Integration test function uploading.""" + simple_function = QiskitFunction( title="my-first-pattern", entrypoint="pattern.py", working_dir=resources_path, ) - @mark.order(1) - def test_simple_function( - self, serverless_client: ServerlessClient, simple_function: QiskitFunction - ): - """Integration test function uploading.""" - - runnable_function = serverless_client.upload(simple_function) + runnable_function = base_client.upload(simple_function) assert runnable_function is not None - runnable_function = serverless_client.function(simple_function.title) + runnable_function = base_client.function(simple_function.title) assert runnable_function is not None @@ -49,7 +44,7 @@ def test_simple_function( assert job.status() == "DONE" assert isinstance(job.logs(), str) - def test_function_with_arguments(self, serverless_client: ServerlessClient): + def test_function_with_arguments(self, base_client: BaseClient): """Integration test for Functions with arguments.""" circuit = QuantumCircuit(2) circuit.h(0) @@ -63,7 +58,7 @@ def test_function_with_arguments(self, serverless_client: ServerlessClient): working_dir=resources_path, ) - runnable_function = serverless_client.upload(arguments_function) + runnable_function = base_client.upload(arguments_function) job = runnable_function.run(circuit=circuit) @@ -72,7 +67,7 @@ def test_function_with_arguments(self, serverless_client: ServerlessClient): assert job.status() == "DONE" assert isinstance(job.logs(), str) - def test_dependencies_function(self, serverless_client: ServerlessClient): + def test_dependencies_function(self, base_client: BaseClient): """Integration test for Functions with dependencies.""" function = QiskitFunction( title="pattern-with-dependencies", @@ -81,7 +76,7 @@ def test_dependencies_function(self, serverless_client: ServerlessClient): dependencies=["pendulum"], ) - runnable_function = serverless_client.upload(function) + runnable_function = base_client.upload(function) job = runnable_function.run() @@ -90,7 +85,7 @@ def test_dependencies_function(self, serverless_client: ServerlessClient): assert job.status() == "DONE" assert isinstance(job.logs(), str) - def test_distributed_workloads(self, serverless_client: ServerlessClient): + def test_distributed_workloads(self, base_client: BaseClient): """Integration test for Functions for distributed workloads.""" circuits = [random_circuit(2, 2) for _ in range(3)] @@ -102,7 +97,7 @@ def test_distributed_workloads(self, serverless_client: ServerlessClient): entrypoint="pattern_with_parallel_workflow.py", working_dir=resources_path, ) - runnable_function = serverless_client.upload(function) + runnable_function = base_client.upload(function) job = runnable_function.run(circuits=circuits) @@ -111,7 +106,7 @@ def test_distributed_workloads(self, serverless_client: ServerlessClient): assert job.status() == "DONE" assert isinstance(job.logs(), str) - def test_multiple_runs(self, serverless_client: ServerlessClient): + def test_multiple_runs(self, base_client: BaseClient): """Integration test for run functions multiple times.""" circuits = [random_circuit(2, 2) for _ in range(3)] @@ -123,7 +118,7 @@ def test_multiple_runs(self, serverless_client: ServerlessClient): entrypoint="pattern.py", working_dir=resources_path, ) - runnable_function = serverless_client.upload(function) + runnable_function = base_client.upload(function) job1 = runnable_function.run() job2 = runnable_function.run() @@ -133,8 +128,8 @@ def test_multiple_runs(self, serverless_client: ServerlessClient): assert job1.job_id != job2.job_id - retrieved_job1 = serverless_client.job(job1.job_id) - retrieved_job2 = serverless_client.job(job2.job_id) + retrieved_job1 = base_client.job(job1.job_id) + retrieved_job2 = base_client.job(job2.job_id) assert retrieved_job1.result() is not None assert retrieved_job2.result() is not None @@ -142,7 +137,11 @@ def test_multiple_runs(self, serverless_client: ServerlessClient): assert isinstance(retrieved_job1.logs(), str) assert isinstance(retrieved_job2.logs(), str) - def test_error(self, serverless_client: ServerlessClient): + @mark.skip( + reason="Images are not working in tests jet and " + + "LocalClient does not manage image instead of working_dir+entrypoint" + ) + def test_error(self, base_client: BaseClient): """Integration test to force an error.""" description = """ @@ -161,7 +160,7 @@ def test_error(self, serverless_client: ServerlessClient): description=description, ) - runnable_function = serverless_client.upload(function_with_custom_image) + runnable_function = base_client.upload(function_with_custom_image) job = runnable_function.run(message="Argument for the custum function")