From daf4dff6e95b5f01b162a21ba284151d97af198d Mon Sep 17 00:00:00 2001 From: Diptanu Choudhury Date: Fri, 15 May 2026 04:55:49 +0000 Subject: [PATCH 1/4] tl deploy: build function images through the sandbox image builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `--image-builder-version sandbox` to `tl deploy`. When selected, every unique function image is built via the SDK's existing `build_sandbox_image()` instead of the Image Builder Service, which registers each image as a sandbox template and gives back a template id. A new `image_ref: ImageRef | None` field on FunctionManifest carries that template id to the platform — kind="sandbox_template", id=template_id — so the dataplane can resolve the function's image to a pre-built ext4 snapshot and boot a Firecracker VM from it directly, instead of importing an OCI rootfs at runtime. When `image_ref` is absent (the v2/v3 paths, unchanged), the platform falls through to its legacy implicit image lookup, preserving existing behavior. The plumbing is one parameter, `image_refs: dict[Image._id, ImageRef]`, threaded from `cli.deploy._prepare_images_sandbox` through `deploy_applications` → `create_application_manifest` → `create_function_manifest`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tensorlake/applications/remote/deploy.py | 8 +- .../remote/manifests/application.py | 16 +++- .../applications/remote/manifests/function.py | 16 +++- .../remote/manifests/function_manifests.py | 20 +++++ src/tensorlake/cli/deploy.py | 84 +++++++++++++++++-- tests/cli/test_deploy.py | 1 + 6 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/tensorlake/applications/remote/deploy.py b/src/tensorlake/applications/remote/deploy.py index 2689ba5b2..ad45ef6ad 100644 --- a/src/tensorlake/applications/remote/deploy.py +++ b/src/tensorlake/applications/remote/deploy.py @@ -1,5 +1,5 @@ import os -from typing import List, Set +from typing import Dict, List, Set from ..applications import filter_applications from ..interface.function import Function @@ -8,6 +8,7 @@ ApplicationManifest, create_application_manifest, ) +from ..remote.manifests.function_manifests import ImageRef from .code.ignored_code_paths import ignored_code_paths from .code.loader import load_code from .code.zip import zip_code @@ -18,6 +19,7 @@ def deploy_applications( upgrade_running_requests: bool = True, load_source_dir_modules: bool = False, api_client=None, + image_refs: Dict[str, ImageRef] | None = None, ) -> None: """Deploys all applications in the supplied .py file so they are runnable in remote mode (i.e. on Tensorlake Cloud). @@ -61,7 +63,9 @@ def deploy_applications( try: for application in filter_applications(functions): app_manifest: ApplicationManifest = create_application_manifest( - application_function=application, all_functions=functions + application_function=application, + all_functions=functions, + image_refs=image_refs, ) api_client.upsert_application( manifest_json=app_manifest.model_dump_json(), diff --git a/src/tensorlake/applications/remote/manifests/application.py b/src/tensorlake/applications/remote/manifests/application.py index 100dee155..9a82b3ce4 100644 --- a/src/tensorlake/applications/remote/manifests/application.py +++ b/src/tensorlake/applications/remote/manifests/application.py @@ -18,6 +18,7 @@ ) from ...interface.function import Function, _ApplicationConfiguration from .function import FunctionManifest, create_function_manifest +from .function_manifests import ImageRef class EntryPointInputManifest(BaseModel): @@ -63,18 +64,29 @@ def model_dump_json(self, **kwargs: Any) -> str: def create_application_manifest( - application_function: Function, all_functions: List[Function] + application_function: Function, + all_functions: List[Function], + image_refs: Dict[str, ImageRef] | None = None, ) -> ApplicationManifest: """Creates ApplicationManifest for the supplied application function. + `image_refs` maps `Image._id` to the pre-built image reference for any + function whose image was built up-front (e.g. through the sandbox-template + builder). Functions without a mapped image get `image_ref=None` and the + platform falls through to the legacy image lookup. + Raises TensorlakeError on error. """ app_config: _ApplicationConfiguration = application_function._application_config app_signature: inspect.Signature = function_signature(application_function) + image_refs = image_refs or {} function_manifests: Dict[str, FunctionManifest] = { fn._function_config.function_name: create_function_manifest( - application_function, app_config.version, fn + application_function, + app_config.version, + fn, + image_ref=image_refs.get(fn._function_config.image._id), ) for fn in all_functions } diff --git a/src/tensorlake/applications/remote/manifests/function.py b/src/tensorlake/applications/remote/manifests/function.py index 099661fde..c6b56c84f 100644 --- a/src/tensorlake/applications/remote/manifests/function.py +++ b/src/tensorlake/applications/remote/manifests/function.py @@ -32,6 +32,7 @@ ) from .function_manifests import ( FunctionResourcesManifest, + ImageRef, JSONSchema, ParameterManifest, PlacementConstraintsManifest, @@ -60,6 +61,10 @@ class FunctionManifest(pydantic.BaseModel): warm_containers: int | None = None min_containers: int | None = None max_containers: int | None = None + # Reference to the pre-built image the platform should run this function + # on. When None, the platform looks the image up through its legacy + # Image-Builder-Service-keyed path (function namespace/app/name/version). + image_ref: ImageRef | None = None @dataclass @@ -208,10 +213,18 @@ def _function_return_type_schema( def create_function_manifest( - application_function: Function, application_version: str, function: Function + application_function: Function, + application_version: str, + function: Function, + image_ref: ImageRef | None = None, ) -> FunctionManifest: """Creates FunctionManifest for the supplied function. + `image_ref` is the pre-built image reference the platform should use to + run this function. Pass it when the deploy flow built the function's + image through the sandbox-template path; leave None to fall through to + the platform's legacy image-builder-service lookup. + Raises TensorlakeError on error. """ app_config: _ApplicationConfiguration = application_function._application_config @@ -286,6 +299,7 @@ def create_function_manifest( description=function._function_config.description, docstring=docstring, is_api=function._application_config is not None, + image_ref=image_ref, secret_names=function._function_config.secrets, # When a function doesn't have a class_init_timeout set it means it's not a class method. # In this case FE initialization timeout should be the same as function timeout. diff --git a/src/tensorlake/applications/remote/manifests/function_manifests.py b/src/tensorlake/applications/remote/manifests/function_manifests.py index 0d5485e91..e22a8cbdd 100644 --- a/src/tensorlake/applications/remote/manifests/function_manifests.py +++ b/src/tensorlake/applications/remote/manifests/function_manifests.py @@ -2,6 +2,7 @@ Any, Dict, List, + Literal, Union, ) @@ -108,3 +109,22 @@ class RetryPolicyManifest(BaseModel): class PlacementConstraintsManifest(BaseModel): filter_expressions: List[str] + + +class ImageRef(BaseModel): + """Reference to a pre-built image for a function executor. + + `kind == "sandbox_template"` points at a sandbox template registered via + the SDK's sandbox image builder; the dataplane resolves it to an ext4 + filesystem snapshot and boots a Firecracker VM directly from that rootfs. + + `kind == "oci"` points at an OCI image (e.g. from the Image Builder + Service); the dataplane imports the rootfs at runtime. This is the legacy + path and is the implicit shape when `image_ref` is absent on a function + manifest. + """ + + kind: Literal["sandbox_template", "oci"] + # For `sandbox_template`: the platform sandbox-template id. For `oci`: + # the image URI (e.g., `registry/path@digest`). + id: str diff --git a/src/tensorlake/cli/deploy.py b/src/tensorlake/cli/deploy.py index 5219b8897..6d6a9f29e 100644 --- a/src/tensorlake/cli/deploy.py +++ b/src/tensorlake/cli/deploy.py @@ -7,11 +7,15 @@ from urllib.parse import urlparse from tensorlake.applications import Function, SDKUsageError, TensorlakeError -from tensorlake.applications.applications import filter_applications +from tensorlake.applications.applications import ( + filter_applications, + functions_for_application, +) from tensorlake.applications.registry import get_functions from tensorlake.applications.remote.code.loader import load_code from tensorlake.applications.remote.curl_command import example_application_curl_command from tensorlake.applications.remote.deploy import deploy_applications +from tensorlake.applications.remote.manifests.function_manifests import ImageRef from tensorlake.applications.secrets import list_secret_names from tensorlake.applications.validation import ( ValidationMessage, @@ -26,6 +30,10 @@ ) from tensorlake.builder.client_v3 import ImageBuilderV3Client from tensorlake.cli._common import Context +from tensorlake.image.sandbox_builder import ( + SandboxImageError, + build_sandbox_image, +) def _emit(obj): @@ -150,7 +158,7 @@ def deploy( upgrade_running_requests: bool, image_builder_version: str = "v3", build_envs: list[tuple[str, str]] | None = None, -): +) -> None: """Deploys applications to Tensorlake Cloud, emitting NDJSON events to stdout.""" _emit( { @@ -214,9 +222,13 @@ def deploy( if missing: _emit({"type": "missing_secrets", "count": len(missing), "names": missing}) - builder = mk_builder(image_builder_version, auth) + image_refs: dict[str, ImageRef] | None = None try: - asyncio.run(_prepare_images(builder, functions, build_envs)) + if image_builder_version == "sandbox": + image_refs = _prepare_images_sandbox(functions) + else: + builder = mk_builder(image_builder_version, auth) + asyncio.run(_prepare_images(builder, functions, build_envs)) except KeyboardInterrupt: _emit({"type": "error", "message": "build cancelled by user"}) sys.exit(1) @@ -230,6 +242,7 @@ def deploy( application_file_path=application_file_path, upgrade_running_requests=upgrade_running_requests, functions=functions, + image_refs=image_refs, ) @@ -264,12 +277,65 @@ async def _prepare_images( _emit({"type": "build_done"}) +def _prepare_images_sandbox(functions: list[Function]) -> dict[str, ImageRef]: + """Build every unique function image through the sandbox image builder. + + Returns a `{Image._id: ImageRef(kind="sandbox_template", id=template_id)}` + map that the deploy step plumbs into each function's manifest. The + platform reads `image_ref` to skip its legacy Image-Builder-Service + lookup and boot the function directly from the registered sandbox-template + filesystem snapshot. + """ + image_refs: dict[str, ImageRef] = {} + seen: set[str] = set() + for application in filter_applications(functions): + for fn in functions_for_application(application, functions): + image = fn._function_config.image + if image._id in seen: + continue + seen.add(image._id) + + _emit({"type": "build_start", "image": image.name}) + try: + result = build_sandbox_image(image, emit=_emit) + except SandboxImageError as error: + _emit( + { + "type": "build_failed", + "image": image.name, + "error": _format_build_failure_message(image.name, error), + } + ) + sys.exit(1) + + template_id = result.get("id") + if not template_id: + _emit( + { + "type": "build_failed", + "image": image.name, + "error": ( + f"image '{image.name}' built but the platform did not " + "return a template id" + ), + } + ) + sys.exit(1) + image_refs[image._id] = ImageRef( + kind="sandbox_template", id=template_id + ) + + _emit({"type": "build_done"}) + return image_refs + + def _deploy_applications( api_client, api_url: str, application_file_path: str, upgrade_running_requests: bool, functions: list[Function], + image_refs: dict[str, ImageRef] | None = None, ): _emit({"type": "status", "message": "Deploying applications..."}) @@ -279,6 +345,7 @@ def _deploy_applications( upgrade_running_requests=upgrade_running_requests, load_source_dir_modules=False, api_client=api_client, + image_refs=image_refs, ) for application_function in filter_applications(functions): @@ -331,9 +398,14 @@ def deploy_entrypoint(): ) parser.add_argument( "--image-builder-version", - choices=["v2", "v3"], + choices=["v2", "v3", "sandbox"], default="v3", - help="Select image builder version", + help=( + "Select image builder version. `v2` and `v3` go through the " + "Image Builder Service. `sandbox` builds each function image " + "through the SDK's sandbox image builder and ships a " + "sandbox-template reference in the manifest." + ), ) parser.add_argument( "--build-env", diff --git a/tests/cli/test_deploy.py b/tests/cli/test_deploy.py index 9f30ad723..4093c3c86 100644 --- a/tests/cli/test_deploy.py +++ b/tests/cli/test_deploy.py @@ -419,6 +419,7 @@ def test_deploy_runs_build_and_deploy_flow(self): upgrade_running_requests=True, load_source_dir_modules=False, api_client=auth.cloud_client, + image_refs=None, ) deployed_event = next( call.args[0] From bf6ba4c7734c2474c6717626e91f80d08460c67a Mon Sep 17 00:00:00 2001 From: Diptanu Choudhury Date: Fri, 15 May 2026 06:18:32 +0000 Subject: [PATCH 2/4] tl deploy: ship sandbox-template *name* in image_ref, not public id The dataplane resolves function images through the same internal `/projects/{ns}/sandbox-templates/by-name/{name}` endpoint sandbox allocations already use, so the manifest's image_ref must carry the template name. Falls back to `image.name` if the platform response stops echoing the template name. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tensorlake/cli/deploy.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/tensorlake/cli/deploy.py b/src/tensorlake/cli/deploy.py index 6d6a9f29e..809bec0b1 100644 --- a/src/tensorlake/cli/deploy.py +++ b/src/tensorlake/cli/deploy.py @@ -308,21 +308,27 @@ def _prepare_images_sandbox(functions: list[Function]) -> dict[str, ImageRef]: ) sys.exit(1) - template_id = result.get("id") - if not template_id: + # Carry the template *name*, not the public id — the dataplane + # resolves function images through the existing internal + # `/projects/{ns}/sandbox-templates/by-name/{name}` endpoint, the + # same path sandbox allocations already use. Falling back to + # `image.name` keeps the contract stable if the platform stops + # echoing the name in its create response. + template_name = result.get("name") or image.name + if not template_name: _emit( { "type": "build_failed", "image": image.name, "error": ( - f"image '{image.name}' built but the platform did not " - "return a template id" + f"image '{image.name}' built but neither the platform " + "response nor the Image carried a template name" ), } ) sys.exit(1) image_refs[image._id] = ImageRef( - kind="sandbox_template", id=template_id + kind="sandbox_template", id=template_name ) _emit({"type": "build_done"}) From 3aba37a185d6bac055f1b60cd62900ff96fa2f94 Mon Sep 17 00:00:00 2001 From: Diptanu Choudhury Date: Fri, 15 May 2026 16:31:54 +0000 Subject: [PATCH 3/4] tl deploy: drop Image Builder Service paths, sandbox-template only The dataplane no longer resolves function images through the Image Builder Service. The SDK's v2/v3 builder clients post to an endpoint that's been removed downstream, so they cannot produce a working deployment. - Deletes ImageBuilderV2Client, ImageBuilderV3Client, log_events, the builder/ package shell, ApplicationBuildRequest / collect_application_build_request, ApplicationImageBuildError, and the tests/builder/ suite. - Drops the --image-builder-version and --build-env flags from tl deploy. The Rust CLI's BuildImages command keeps its local --build-env (for the local Docker workflow), but the deploy help text no longer references it. - tl build-images loses its --image-builder-version flag; it now only emits image definitions for the local Docker workflow. - tests/cli/test_deploy.py rewritten to cover the sandbox path (dedupes images by Image._id, falls back to image.name when the platform omits it, surfaces SandboxImageBuildError as build_failed) plus the original helper tests. 11 deploy tests pass; 15 application-manifest tests pass; Rust CLI compiles clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cli/src/main.rs | 2 +- src/tensorlake/builder/__init__.py | 74 -- src/tensorlake/builder/client_v2.py | 212 ------ src/tensorlake/builder/client_v3.py | 451 ------------- src/tensorlake/builder/log_events.py | 38 -- src/tensorlake/cli/build_images.py | 82 +-- src/tensorlake/cli/deploy.py | 115 +--- tests/builder/test_client_v2.py | 35 - tests/builder/test_client_v3.py | 387 ----------- tests/cli/test_build_images.py | 364 ---------- tests/cli/test_deploy.py | 971 ++++----------------------- 11 files changed, 160 insertions(+), 2571 deletions(-) delete mode 100644 src/tensorlake/builder/__init__.py delete mode 100644 src/tensorlake/builder/client_v2.py delete mode 100644 src/tensorlake/builder/client_v3.py delete mode 100644 src/tensorlake/builder/log_events.py delete mode 100644 tests/builder/test_client_v2.py delete mode 100644 tests/builder/test_client_v3.py delete mode 100644 tests/cli/test_build_images.py diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a06ae3002..ce789ba7f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -111,7 +111,7 @@ enum Commands { /// Deploy applications to Tensorlake Cloud Deploy { - /// Arguments passed to the deploy Python module (use --build-env KEY=VALUE to inject ENV directives into generated Dockerfiles) + /// Arguments passed to the deploy Python module #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, diff --git a/src/tensorlake/builder/__init__.py b/src/tensorlake/builder/__init__.py deleted file mode 100644 index c7b63918d..000000000 --- a/src/tensorlake/builder/__init__.py +++ /dev/null @@ -1,74 +0,0 @@ -import hashlib -import os -import tempfile -from dataclasses import dataclass -from pathlib import Path - -from tensorlake.applications import Function, Image -from tensorlake.applications.applications import functions_for_application -from tensorlake.applications.image import create_image_context_file - - -@dataclass(frozen=True) -class ApplicationBuildImageRequest: - key: str - name: str - context_sha256: str - function_names: list[str] - context_tar_gz: bytes - - -@dataclass(frozen=True) -class ApplicationBuildRequest: - name: str - version: str - images: list[ApplicationBuildImageRequest] - - -def collect_application_build_request( - application: Function, - functions: list[Function], - build_env_vars: list[tuple[str, str]] | None = None, -) -> ApplicationBuildRequest: - image_requests: dict[Image, ApplicationBuildImageRequest] = {} - image_functions: dict[Image, list[str]] = {} - - for function in functions_for_application(application, functions): - image = function._function_config.image - if image not in image_requests: - context_tar_gz = build_image_context(image, extra_env_vars=build_env_vars) - image_requests[image] = ApplicationBuildImageRequest( - key=image._id, - name=image.name, - context_sha256=hashlib.sha256(context_tar_gz).hexdigest(), - function_names=[], - context_tar_gz=context_tar_gz, - ) - image_functions[image] = image_requests[image].function_names - - image_functions[image].append(function._function_config.function_name) - - return ApplicationBuildRequest( - name=application._function_config.function_name, - version=application._application_config.version, - images=list(image_requests.values()), - ) - - -def build_image_context( - image: Image, - extra_env_vars: list[tuple[str, str]] | None = None, -) -> bytes: - fd, context_file_path = tempfile.mkstemp() - os.close(fd) - - try: - create_image_context_file( - image, - context_file_path, - extra_env_vars=extra_env_vars, - ) - return Path(context_file_path).read_bytes() - finally: - if os.path.exists(context_file_path): - os.remove(context_file_path) diff --git a/src/tensorlake/builder/client_v2.py b/src/tensorlake/builder/client_v2.py deleted file mode 100644 index c059b17a1..000000000 --- a/src/tensorlake/builder/client_v2.py +++ /dev/null @@ -1,212 +0,0 @@ -import asyncio -import os -import sys -from typing import Callable -from urllib.parse import urlparse - -from pydantic import BaseModel - -from tensorlake.builder import ApplicationBuildImageRequest, ApplicationBuildRequest -from tensorlake.builder.log_events import BuildLogEvent, emit_build_log_event -from tensorlake.cloud_client import CloudClient - - -class BuildInfo(BaseModel): - id: str - status: str - created_at: str - updated_at: str - finished_at: str | None - error_message: str | None = None - - -class ApplicationImageBuildError(RuntimeError): - def __init__(self, image_name: str, error: Exception | BaseException): - self.image_name = image_name - self.error = error - super().__init__(f"Error building image {image_name}: {error}") - - -class ImageBuilderV2Client: - """Client for interacting with the image builder service.""" - - def __init__( - self, - cloud_client: CloudClient, - build_service_path: str = "/images/v2", - on_build_start: ( - Callable[[ApplicationBuildImageRequest, str], None] | None - ) = None, - ): - self._cloud_client = cloud_client - self._build_service_path = build_service_path - self._on_build_start = on_build_start - - @classmethod - def from_env(cls) -> "ImageBuilderV2Client": - api_key = os.getenv("TENSORLAKE_API_KEY") - if not api_key: - api_key = os.getenv("TENSORLAKE_PAT") - - organization_id = os.getenv("TENSORLAKE_ORGANIZATION_ID") - project_id = os.getenv("TENSORLAKE_PROJECT_ID") - - server_url = os.getenv("TENSORLAKE_API_URL", "https://api.tensorlake.ai") - build_url = os.getenv("TENSORLAKE_BUILD_SERVICE", f"{server_url}/images/v2") - - parsed = urlparse(build_url) - api_url = f"{parsed.scheme}://{parsed.netloc}" - build_service_path = parsed.path.rstrip("/") or "/images/v2" - - client = CloudClient( - api_url=api_url, - api_key=api_key, - organization_id=organization_id, - project_id=project_id, - ) - return cls(cloud_client=client, build_service_path=build_service_path) - - async def build(self, request: ApplicationBuildRequest) -> None: - print("Building images...", file=sys.stderr) - for image_request in request.images: - for function_name in image_request.function_names: - if self._on_build_start is not None: - self._on_build_start(image_request, function_name) - print(f"Building {image_request.name}", file=sys.stderr) - try: - await self._build_single( - application_name=request.name, - application_version=request.version, - function_name=function_name, - image_name=image_request.name, - image_key=image_request.key, - context_tar_gz=image_request.context_tar_gz, - ) - print( - f"Built {image_request.name} with context sha256 {image_request.context_sha256}", - file=sys.stderr, - ) - except (asyncio.CancelledError, KeyboardInterrupt): - raise - except Exception as error: - raise ApplicationImageBuildError( - image_name=image_request.name, - error=error, - ) from error - - async def _build_single( - self, - application_name: str, - application_version: str, - function_name: str, - image_name: str, - image_key: str, - context_tar_gz: bytes, - ) -> BuildInfo: - print( - f"{image_name}: Posting {len(context_tar_gz)} bytes of context to build service....", - file=sys.stderr, - ) - - try: - build_json = await asyncio.to_thread( - self._cloud_client.start_image_build, - self._build_service_path, - application_name, - application_version, - function_name, - image_name, - image_key, - context_tar_gz, - ) - except Exception as e: - raise RuntimeError(f"Error building image {image_name}: {e}") from e - - build = BuildInfo.model_validate_json(build_json) - - print( - f"Waiting for build {build.id} of {image_name} to complete...", - file=sys.stderr, - ) - - try: - return await self.stream_logs(build) - except (asyncio.CancelledError, KeyboardInterrupt): - await self._cancel_build(build, image_name) - raise - - async def stream_logs(self, build: BuildInfo) -> BuildInfo: - print(f"Streaming logs for build {build.id}", file=sys.stderr) - try: - await asyncio.to_thread( - self._cloud_client.stream_build_logs_to_stderr, - self._build_service_path, - build.id, - ) - except Exception: - events_json: list[str] = await asyncio.to_thread( - self._cloud_client.stream_build_logs_json, - self._build_service_path, - build.id, - ) - for event_json in events_json: - log_entry = BuildLogEvent.model_validate_json(event_json) - self._print_build_log_event(event=log_entry) - - return await self.build_info(build.id) - - def _print_build_log_event(self, event: BuildLogEvent): - emit_build_log_event( - event, - emit_message=lambda message: print(message, file=sys.stderr, flush=True), - emit_stderr_message=lambda message: print( - message, - file=sys.stderr, - flush=True, - ), - emit_stdout_message=lambda message: print( - message, - end="", - file=sys.stderr, - flush=True, - ), - ) - - async def build_info(self, build_id: str) -> BuildInfo: - try: - build_info_json = await asyncio.to_thread( - self._cloud_client.build_info_json, - self._build_service_path, - build_id, - ) - except Exception as e: - print(f"Error building image: {e}", file=sys.stderr) - raise RuntimeError(f"Error building image: {e}") from e - - build_info = BuildInfo.model_validate_json(build_info_json) - - if build_info.status == "failed": - print( - f"Build {build_info.id} failed with error: {build_info.error_message}", - file=sys.stderr, - ) - raise RuntimeError( - f"Build {build_info.id} failed with error: {build_info.error_message}" - ) - - return build_info - - async def _cancel_build(self, build: BuildInfo, image_name: str): - try: - print(f"\nCancelling build for image {image_name} ...", file=sys.stderr) - await asyncio.to_thread( - self._cloud_client.cancel_build, - self._build_service_path, - build_id=build.id, - ) - print( - f"Build for image {image_name} cancelled successfully", - file=sys.stderr, - ) - except Exception as e: - print(f"Failed to cancel build {build.id}: {e}", file=sys.stderr) diff --git a/src/tensorlake/builder/client_v3.py b/src/tensorlake/builder/client_v3.py deleted file mode 100644 index eb177ef85..000000000 --- a/src/tensorlake/builder/client_v3.py +++ /dev/null @@ -1,451 +0,0 @@ -import asyncio -import json -import sys -from dataclasses import dataclass -from typing import Annotated - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from tensorlake.builder import ApplicationBuildRequest -from tensorlake.builder.client_v2 import ApplicationImageBuildError -from tensorlake.builder.log_events import BuildLogEvent, emit_build_log_event -from tensorlake.cloud_client import CloudClient - -NonEmptyString = Annotated[str, Field(min_length=1)] -Sha256Hex = Annotated[str, Field(pattern=r"^[0-9a-fA-F]{64}$")] - - -class CreateApplicationBuildImagePayload(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - key: NonEmptyString - name: str | None = None - description: str | None = None - context_tar_part_name: NonEmptyString - context_sha256: Sha256Hex - function_names: Annotated[list[NonEmptyString], Field(min_length=1)] - - -class CreateApplicationBuildPayload(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - name: NonEmptyString - version: NonEmptyString - images: Annotated[list[CreateApplicationBuildImagePayload], Field(min_length=1)] - - @model_validator(mode="after") - def validate_unique_fields(self) -> "CreateApplicationBuildPayload": - image_keys = [image.key for image in self.images] - if len(image_keys) != len(set(image_keys)): - raise ValueError("image keys must be unique within an application build") - - context_part_names = [image.context_tar_part_name for image in self.images] - if len(context_part_names) != len(set(context_part_names)): - raise ValueError( - "context_tar_part_name values must be unique within an application build" - ) - - function_names = [ - function_name - for image in self.images - for function_name in image.function_names - ] - if len(function_names) != len(set(function_names)): - raise ValueError( - "function_names must be unique across images in an application build" - ) - - return self - - -class ApplicationBuildImageResult(BaseModel): - model_config = ConfigDict(extra="ignore", strict=True) - - id: str - app_version_id: str | None = None - key: str | None = None - name: str | None = None - description: str | None = None - context_sha256: str | None = None - status: str - error_message: str | None = None - image_uri: str | None = None - image_digest: str | None = None - created_at: str | None = None - updated_at: str | None = None - finished_at: str | None = None - function_names: list[str] = Field(default_factory=list) - - -class ApplicationBuildResult(BaseModel): - model_config = ConfigDict(extra="ignore", strict=True) - - id: str - organization_id: str - project_id: str - name: str - version: str - status: str - created_at: str | None = None - updated_at: str | None = None - finished_at: str | None = None - image_builds: list[ApplicationBuildImageResult] - - -_IMAGE_NAME_PREFIX_COLORS: list[str] = [ - "magenta", - "cyan", - "green", - "yellow", - "blue", - "white", - "red", - "bright_magenta", - "bright_cyan", - "bright_green", - "bright_yellow", - "bright_blue", - "bright_white", - "bright_red", -] - -_ANSI_COLOR_CODES: dict[str, str] = { - "red": "31", - "green": "32", - "yellow": "33", - "blue": "34", - "magenta": "35", - "cyan": "36", - "white": "37", - "bright_red": "91", - "bright_green": "92", - "bright_yellow": "93", - "bright_blue": "94", - "bright_magenta": "95", - "bright_cyan": "96", - "bright_white": "97", -} - - -def _styled_text( - message: str, - *, - stream, - color: str | None = None, - bold: bool = False, -) -> str: - if not hasattr(stream, "isatty") or not stream.isatty(): - return message - - codes: list[str] = [] - if bold: - codes.append("1") - if color is not None and color in _ANSI_COLOR_CODES: - codes.append(_ANSI_COLOR_CODES[color]) - if not codes: - return message - return f"\033[{';'.join(codes)}m{message}\033[0m" - - -def _print_message( - message: str, - *, - err: bool = False, - nl: bool = True, - color: str | None = None, - bold: bool = False, -) -> None: - stream = sys.stderr if err else sys.stdout - stream.write( - _styled_text(message, stream=stream, color=color, bold=bold) - + ("\n" if nl else "") - ) - stream.flush() - - -@dataclass -class BuildSummary: - total: int - succeeded: int = 0 - failed: int = 0 - canceled: int = 0 - unknown: int = 0 - - -class _ImageBuildReporter: - _instance_count = 0 - - def __init__( - self, - application_name: str, - info: ApplicationBuildImageResult, - *, - disambiguate_name: bool = False, - ): - self._info = info - self._last_seen_status = info.status - self._display_name = self._build_display_name( - application_name, - info, - disambiguate_name=disambiguate_name, - ) - self._color = _IMAGE_NAME_PREFIX_COLORS[ - _ImageBuildReporter._instance_count % len(_IMAGE_NAME_PREFIX_COLORS) - ] - _ImageBuildReporter._instance_count += 1 - - @staticmethod - def _build_display_name( - application_name: str, - info: ApplicationBuildImageResult, - *, - disambiguate_name: bool = False, - ) -> str: - image_name = info.name or "image" - display_name = f"{application_name}/{image_name}" - if not disambiguate_name: - return display_name - - if len(info.function_names) == 1: - qualifier = info.function_names[0] - elif len(info.function_names) > 1: - qualifier = f"{info.function_names[0]}+{len(info.function_names) - 1}" - else: - qualifier = info.key or info.id - - return f"{display_name} [{qualifier}]" - - @property - def display_name(self) -> str: - return self._display_name - - @property - def color(self) -> str: - return self._color - - @property - def image_build_id(self) -> str: - return self._info.id - - @property - def last_seen_status(self) -> str: - return self._last_seen_status - - def print_final_result(self, info: ApplicationBuildImageResult | None) -> str: - status = info.status if info is not None else self._last_seen_status - error_message = info.error_message if info is not None else None - - if status == "succeeded": - self._print_message("Image build succeeded") - elif status == "failed": - message = "Image build failed" - if error_message: - message = f"{message}: {error_message}" - self._print_message(message, err=True) - elif status in {"canceled", "canceling"}: - self._print_message("Image build canceled", err=True) - else: - self._print_message(f"Image build ended with status: {status}", err=True) - - return status - - def print_log_event(self, event: BuildLogEvent) -> None: - self._last_seen_status = event.build_status - emit_build_log_event( - event, - emit_message=lambda message: self._print_message(message, err=True), - emit_stderr_message=lambda message: self._print_message(message, err=True), - emit_stdout_message=lambda message: self._print_message(message, nl=False), - ) - - def _print_prefix(self, err: bool) -> None: - if _ImageBuildReporter._instance_count > 1: - _print_message( - f"{self._display_name}: ", - nl=False, - err=err, - color=self._color, - ) - - def _print_message( - self, - message: str, - *, - err: bool = False, - nl: bool = True, - ) -> None: - self._print_prefix(err) - _print_message(message, err=err, nl=nl) - - -class ImageBuilderV3Client: - def __init__( - self, - cloud_client: CloudClient, - build_service_path: str = "/images/v3/applications", - ): - self._cloud_client = cloud_client - self._build_service_path = build_service_path - self._image_service_path = self._derive_image_service_path(build_service_path) - - @staticmethod - def _derive_image_service_path(build_service_path: str) -> str: - normalized_path = build_service_path.rstrip("/") - if normalized_path.endswith("/applications"): - return normalized_path.removesuffix("/applications") - return normalized_path - - async def build(self, request: ApplicationBuildRequest) -> ApplicationBuildResult: - request_payload = CreateApplicationBuildPayload( - name=request.name, - version=request.version, - images=[ - CreateApplicationBuildImagePayload( - key=image.key, - name=image.name, - context_tar_part_name=image.key, - context_sha256=image.context_sha256, - function_names=image.function_names, - ) - for image in request.images - ], - ) - request_json = request_payload.model_dump_json(exclude_none=True) - image_contexts = [(image.key, image.context_tar_gz) for image in request.images] - response_json = await asyncio.to_thread( - self._cloud_client.create_application_build, - self._build_service_path, - request_json, - image_contexts, - ) - created_result = ApplicationBuildResult.model_validate_json(response_json) - reporters = self._build_reporters(created_result) - - try: - await asyncio.gather( - *[ - self._stream_build_logs(reporters[image_build.id]) - for image_build in created_result.image_builds - ] - ) - except (asyncio.CancelledError, KeyboardInterrupt): - await self._cancel_application_build(created_result.id) - raise - - final_result = await self._application_build_info(created_result.id) - self._print_summary(reporters, final_result) - self._raise_for_failed_builds(final_result) - return final_result - - @staticmethod - def _build_reporters( - result: ApplicationBuildResult, - ) -> dict[str, _ImageBuildReporter]: - _ImageBuildReporter._instance_count = 0 - image_name_counts: dict[str, int] = {} - for image_build in result.image_builds: - image_name = image_build.name or "image" - image_name_counts[image_name] = image_name_counts.get(image_name, 0) + 1 - - return { - image_build.id: _ImageBuildReporter( - result.name, - image_build, - disambiguate_name=image_name_counts.get(image_build.name or "image", 0) - > 1, - ) - for image_build in result.image_builds - } - - async def _application_build_info( - self, application_build_id: str - ) -> ApplicationBuildResult: - response_json = await asyncio.to_thread( - self._cloud_client.application_build_info_json, - self._build_service_path, - application_build_id, - ) - return ApplicationBuildResult.model_validate_json(response_json) - - async def _cancel_application_build( - self, application_build_id: str - ) -> ApplicationBuildResult: - response_json = await asyncio.to_thread( - self._cloud_client.cancel_application_build, - self._build_service_path, - application_build_id, - ) - return ApplicationBuildResult.model_validate_json(response_json) - - async def _stream_build_logs(self, reporter: _ImageBuildReporter) -> None: - try: - await asyncio.to_thread( - self._cloud_client.stream_build_logs_to_stderr_prefixed, - self._image_service_path, - reporter.image_build_id, - reporter.display_name, - reporter.color, - ) - except Exception: - events_json: list[str] = await asyncio.to_thread( - self._cloud_client.stream_build_logs_json, - self._image_service_path, - reporter.image_build_id, - ) - for event_json in events_json: - reporter.print_log_event(BuildLogEvent.model_validate_json(event_json)) - - @staticmethod - def _print_summary( - reporters: dict[str, _ImageBuildReporter], - final_result: ApplicationBuildResult, - ) -> None: - summary = BuildSummary(total=len(reporters)) - final_builds = { - image_build.id: image_build for image_build in final_result.image_builds - } - - _print_message("", err=True) - _print_message("Image build summary:", bold=True, err=True) - for reporter in reporters.values(): - status = reporter.print_final_result( - final_builds.get(reporter.image_build_id) - ) - if status == "succeeded": - summary.succeeded += 1 - elif status == "failed": - summary.failed += 1 - elif status in {"canceled", "canceling"}: - summary.canceled += 1 - else: - summary.unknown += 1 - - _print_message("", err=True) - _print_message( - ( - f"total={summary.total} " - f"succeeded={summary.succeeded} " - f"failed={summary.failed} " - f"canceled={summary.canceled} " - f"unknown={summary.unknown}" - ), - err=True, - bold=True, - ) - - @staticmethod - def _raise_for_failed_builds(result: ApplicationBuildResult) -> None: - for image_build in result.image_builds: - if image_build.status == "failed": - raise ApplicationImageBuildError( - image_name=image_build.name or image_build.key or image_build.id, - error=RuntimeError( - image_build.error_message - or f"Image build {image_build.id} failed" - ), - ) - if image_build.status in {"canceled", "canceling"}: - raise RuntimeError( - image_build.error_message - or f"Image build {image_build.id} was canceled" - ) diff --git a/src/tensorlake/builder/log_events.py b/src/tensorlake/builder/log_events.py deleted file mode 100644 index 75dc91f2c..000000000 --- a/src/tensorlake/builder/log_events.py +++ /dev/null @@ -1,38 +0,0 @@ -from collections.abc import Callable - -from pydantic import AliasChoices, BaseModel, ConfigDict, Field - - -class BuildLogEvent(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - image_build_id: str = Field( - validation_alias=AliasChoices("image_build_id", "build_id") - ) - timestamp: str - stream: str - message: str - sequence_number: int - build_status: str - - -def emit_build_log_event( - event: BuildLogEvent, - emit_message: Callable[[str], None], - *, - emit_stderr_message: Callable[[str], None], - emit_stdout_message: Callable[[str], None], -) -> None: - if event.build_status in {"pending", "enqueued"}: - emit_stderr_message("Build waiting in queue...") - return - - match event.stream: - case "stdout": - emit_stdout_message(event.message) - case "stderr": - emit_stderr_message(event.message) - case "info": - emit_stderr_message(f"{event.timestamp}: {event.message}") - case _: - emit_message(event.message) diff --git a/src/tensorlake/cli/build_images.py b/src/tensorlake/cli/build_images.py index b83d7e844..2d9157f2a 100644 --- a/src/tensorlake/cli/build_images.py +++ b/src/tensorlake/cli/build_images.py @@ -1,5 +1,4 @@ import argparse -import asyncio import importlib.metadata import json import os @@ -7,9 +6,7 @@ import traceback from tensorlake.applications.image import ImageInformation, image_infos -from tensorlake.applications.registry import get_functions from tensorlake.applications.remote.code.loader import load_code -from tensorlake.cli import deploy as deploy_module def _emit(obj): @@ -115,63 +112,6 @@ def build_images( _emit({"type": "done"}) -def build_images_with_builder( - application_file_path: str, - image_builder_version: str, -): - """Load application file and build images via the configured remote image builder.""" - try: - application_file_path = os.path.abspath(application_file_path) - load_code(application_file_path) - except SyntaxError as e: - _emit( - { - "type": "error", - "message": f"syntax error in {e.filename}, line {e.lineno}: {e.msg}", - } - ) - sys.exit(1) - except ImportError as e: - _emit( - { - "type": "error", - "message": "failed to import application file. make sure all dependencies are installed in your current environment.", - "details": f"{type(e).__name__}: {e}", - } - ) - sys.exit(1) - except Exception as e: - event = { - "type": "error", - "message": f"failed to load {application_file_path}", - "details": f"{type(e).__name__}: {e}", - } - if _debug_enabled(): - event["traceback"] = traceback.format_exc() - _emit(event) - sys.exit(1) - - auth = deploy_module._build_context_from_env() - functions = get_functions() - builder = deploy_module.mk_builder(image_builder_version, auth) - - try: - asyncio.run(deploy_module._prepare_images(builder, functions)) - except KeyboardInterrupt: - _emit({"type": "error", "message": "build cancelled by user"}) - sys.exit(1) - except Exception as e: - event = { - "type": "error", - "message": f"build-images failed ({type(e).__name__})", - "details": f"{type(e).__name__}: {e}", - } - if _debug_enabled(): - event["traceback"] = traceback.format_exc() - _emit(event) - sys.exit(1) - - def main(): parser = argparse.ArgumentParser( description="Emit image definitions for images defined in a Tensorlake application file" @@ -192,26 +132,14 @@ def main(): default=None, help="Build only the image with this name", ) - parser.add_argument( - "--image-builder-version", - choices=["v2", "v3"], - default=None, - help="Build images through the remote image builder instead of emitting definitions", - ) args = parser.parse_args() try: - if args.image_builder_version is None: - build_images( - application_file_path=args.application_file_path, - tag=args.tag, - image_name=args.image_name, - ) - else: - build_images_with_builder( - application_file_path=args.application_file_path, - image_builder_version=args.image_builder_version, - ) + build_images( + application_file_path=args.application_file_path, + tag=args.tag, + image_name=args.image_name, + ) except SystemExit: raise except Exception as e: diff --git a/src/tensorlake/cli/deploy.py b/src/tensorlake/cli/deploy.py index 809bec0b1..074585c02 100644 --- a/src/tensorlake/cli/deploy.py +++ b/src/tensorlake/cli/deploy.py @@ -1,10 +1,8 @@ import argparse -import asyncio import json import os import sys import traceback -from urllib.parse import urlparse from tensorlake.applications import Function, SDKUsageError, TensorlakeError from tensorlake.applications.applications import ( @@ -23,12 +21,6 @@ has_error_message, validate_loaded_applications, ) -from tensorlake.builder import collect_application_build_request -from tensorlake.builder.client_v2 import ( - ApplicationImageBuildError, - ImageBuilderV2Client, -) -from tensorlake.builder.client_v3 import ImageBuilderV3Client from tensorlake.cli._common import Context from tensorlake.image.sandbox_builder import ( SandboxImageError, @@ -108,18 +100,6 @@ def _warning_missing_secrets(auth: Context, secrets: list[str]) -> list[str]: return [s for s in secrets if s not in existing] -def _parse_build_envs(build_envs: list[str]) -> list[tuple[str, str]]: - parsed_build_envs: list[tuple[str, str]] = [] - for build_env in build_envs: - key, separator, value = build_env.partition("=") - if separator != "=" or not key: - raise ValueError( - f"invalid --build-env value '{build_env}'; expected KEY=VALUE" - ) - parsed_build_envs.append((key, value)) - return parsed_build_envs - - def _onprem_enabled() -> bool: return os.environ.get("TENSORLAKE_ONPREM", "").lower() in { "1", @@ -129,35 +109,9 @@ def _onprem_enabled() -> bool: } -def mk_builder(version: str, auth: Context): - default_build_service_path = ( - "/images/v3/applications" if version == "v3" else "/images/v2" - ) - build_service = ( - os.getenv("TENSORLAKE_BUILD_SERVICE") - or f"{auth.api_url}{default_build_service_path}" - ) - parsed = urlparse(build_service) - build_service_path = parsed.path.rstrip("/") or default_build_service_path - if version == "v3": - return ImageBuilderV3Client( - cloud_client=auth.cloud_client, - build_service_path=build_service_path, - ) - return ImageBuilderV2Client( - cloud_client=auth.cloud_client, - build_service_path=build_service_path, - on_build_start=lambda image, _function_name: _emit( - {"type": "build_start", "image": image.name} - ), - ) - - def deploy( application_file_path: str, upgrade_running_requests: bool, - image_builder_version: str = "v3", - build_envs: list[tuple[str, str]] | None = None, ) -> None: """Deploys applications to Tensorlake Cloud, emitting NDJSON events to stdout.""" _emit( @@ -222,13 +176,8 @@ def deploy( if missing: _emit({"type": "missing_secrets", "count": len(missing), "names": missing}) - image_refs: dict[str, ImageRef] | None = None try: - if image_builder_version == "sandbox": - image_refs = _prepare_images_sandbox(functions) - else: - builder = mk_builder(image_builder_version, auth) - asyncio.run(_prepare_images(builder, functions, build_envs)) + image_refs = _prepare_images(functions) except KeyboardInterrupt: _emit({"type": "error", "message": "build cancelled by user"}) sys.exit(1) @@ -246,45 +195,13 @@ def deploy( ) -async def _prepare_images( - builder, - functions: list[Function], - build_envs: list[tuple[str, str]] | None = None, -): - for application in filter_applications(functions): - try: - await builder.build( - collect_application_build_request( - application, - functions, - build_env_vars=build_envs, - ) - ) - except (asyncio.CancelledError, KeyboardInterrupt) as error: - raise error - except ApplicationImageBuildError as error: - _emit( - { - "type": "build_failed", - "image": error.image_name, - "error": _format_build_failure_message( - error.image_name, error.error - ), - } - ) - sys.exit(1) - - _emit({"type": "build_done"}) - - -def _prepare_images_sandbox(functions: list[Function]) -> dict[str, ImageRef]: +def _prepare_images(functions: list[Function]) -> dict[str, ImageRef]: """Build every unique function image through the sandbox image builder. - Returns a `{Image._id: ImageRef(kind="sandbox_template", id=template_id)}` - map that the deploy step plumbs into each function's manifest. The - platform reads `image_ref` to skip its legacy Image-Builder-Service - lookup and boot the function directly from the registered sandbox-template - filesystem snapshot. + Returns a `{Image._id: ImageRef(kind="sandbox_template", id=template_name)}` + map that the deploy step plumbs into each function's manifest. The platform + reads `image_ref` and the dataplane resolves the function's image directly + to the registered sandbox-template filesystem snapshot. """ image_refs: dict[str, ImageRef] = {} seen: set[str] = set() @@ -402,32 +319,12 @@ def deploy_entrypoint(): default=False, help="Upgrade requests that are already queued or running", ) - parser.add_argument( - "--image-builder-version", - choices=["v2", "v3", "sandbox"], - default="v3", - help=( - "Select image builder version. `v2` and `v3` go through the " - "Image Builder Service. `sandbox` builds each function image " - "through the SDK's sandbox image builder and ships a " - "sandbox-template reference in the manifest." - ), - ) - parser.add_argument( - "--build-env", - action="append", - default=[], - metavar="KEY=VALUE", - help="Environment variable to inject into generated Dockerfiles (repeatable)", - ) args = parser.parse_args() try: deploy( application_file_path=args.application_file_path, upgrade_running_requests=args.upgrade_running_requests, - image_builder_version=args.image_builder_version, - build_envs=_parse_build_envs(args.build_env), ) except SystemExit: raise diff --git a/tests/builder/test_client_v2.py b/tests/builder/test_client_v2.py deleted file mode 100644 index 061196750..000000000 --- a/tests/builder/test_client_v2.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest -from io import StringIO -from unittest.mock import AsyncMock, MagicMock, patch - -from tensorlake.builder import ApplicationBuildImageRequest, ApplicationBuildRequest -from tensorlake.builder.client_v2 import ( - ApplicationImageBuildError, - ImageBuilderV2Client, -) - - -class TestImageBuilderV2Client(unittest.IsolatedAsyncioTestCase): - async def test_build_wraps_failures_with_image_name(self): - builder = ImageBuilderV2Client(cloud_client=MagicMock()) - builder._build_single = AsyncMock(side_effect=RuntimeError("boom")) - - request = ApplicationBuildRequest( - name="app_fn", - version="v1", - images=[ - ApplicationBuildImageRequest( - key="img-1", - name="image-a", - context_sha256="sha-a", - function_names=["fn-1"], - context_tar_gz=b"context-a", - ) - ], - ) - - with self.assertRaises(ApplicationImageBuildError) as exc: - await builder.build(request) - - self.assertEqual(exc.exception.image_name, "image-a") - self.assertEqual(str(exc.exception.error), "boom") diff --git a/tests/builder/test_client_v3.py b/tests/builder/test_client_v3.py deleted file mode 100644 index 02b891b56..000000000 --- a/tests/builder/test_client_v3.py +++ /dev/null @@ -1,387 +0,0 @@ -import asyncio -import json -import unittest -from unittest.mock import MagicMock, patch - -from tensorlake.builder import ApplicationBuildImageRequest, ApplicationBuildRequest -from tensorlake.builder.client_v3 import ImageBuilderV3Client -from tensorlake.builder.log_events import BuildLogEvent - -SHA_A = "a" * 64 -SHA_B = "b" * 64 - - -class TestImageBuilderV3Client(unittest.IsolatedAsyncioTestCase): - @staticmethod - def _make_reporter(cloud_client: MagicMock, *, image_name: str = "image-a"): - builder = ImageBuilderV3Client( - cloud_client=cloud_client, - build_service_path="/images/v3/applications", - ) - reporter = builder._build_reporters( - type( - "Result", - (), - { - "image_builds": [ - type( - "ImageBuild", - (), - { - "id": "img-build-1", - "key": "img-1", - "name": image_name, - "status": "pending", - }, - )() - ], - "name": "app_fn", - }, - )() - )["img-build-1"] - return builder, reporter - - async def _assert_stream_build_logs_event(self, event_payload: dict) -> None: - cloud_client = MagicMock() - cloud_client.stream_build_logs_to_stderr_prefixed.side_effect = RuntimeError( - "sse stream failed" - ) - cloud_client.stream_build_logs_json.return_value = [ - json.dumps(event_payload), - ] - builder, reporter = self._make_reporter(cloud_client) - - with patch.object(reporter, "print_log_event") as print_log_event: - await builder._stream_build_logs(reporter) - - cloud_client.stream_build_logs_to_stderr_prefixed.assert_called_once_with( - "/images/v3", - "img-build-1", - "app_fn/image-a", - "magenta", - ) - cloud_client.stream_build_logs_json.assert_called_once_with( - "/images/v3", - "img-build-1", - ) - print_log_event.assert_called_once_with( - BuildLogEvent(**event_payload), - ) - - async def test_build_raises_application_image_build_error_for_failed_image(self): - cloud_client = MagicMock() - cloud_client.create_application_build.return_value = json.dumps( - { - "id": "app-build-1", - "organization_id": "org-1", - "project_id": "proj-1", - "name": "app_fn", - "version": "v1", - "status": "building", - "image_builds": [ - { - "id": "img-build-1", - "app_version_id": "app-version-1", - "key": "img-1", - "name": "image-a", - "status": "building", - "created_at": "2026-03-07T10:00:00Z", - "updated_at": "2026-03-07T10:01:00Z", - "function_names": ["fn-1"], - } - ], - } - ) - cloud_client.application_build_info_json.return_value = json.dumps( - { - "id": "app-build-1", - "organization_id": "org-1", - "project_id": "proj-1", - "name": "app_fn", - "version": "v1", - "status": "failed", - "created_at": "2026-03-07T10:00:00Z", - "updated_at": "2026-03-07T10:02:00Z", - "finished_at": "2026-03-07T10:03:00Z", - "image_builds": [ - { - "id": "img-build-1", - "key": "img-1", - "name": "image-a", - "context_sha256": SHA_A, - "status": "failed", - "error_message": "docker build failed", - "image_uri": "registry.example.com/app/image-a:latest", - "image_digest": None, - "created_at": "2026-03-07T10:00:00Z", - "updated_at": "2026-03-07T10:02:00Z", - "finished_at": "2026-03-07T10:03:00Z", - "function_names": ["fn-1"], - } - ], - } - ) - builder = ImageBuilderV3Client( - cloud_client=cloud_client, - build_service_path="/images/v3/applications", - ) - - with self.assertRaisesRegex(Exception, "docker build failed"): - await builder.build( - ApplicationBuildRequest( - name="app_fn", - version="v1", - images=[ - ApplicationBuildImageRequest( - key="img-1", - name="image-a", - context_sha256=SHA_A, - function_names=["fn-1"], - context_tar_gz=b"context-a", - ) - ], - ) - ) - - async def test_build_cancels_application_build_on_stream_cancellation(self): - cloud_client = MagicMock() - cloud_client.create_application_build.return_value = json.dumps( - { - "id": "app-build-1", - "organization_id": "org-1", - "project_id": "proj-1", - "name": "app_fn", - "version": "v1", - "status": "building", - "image_builds": [ - { - "id": "img-build-1", - "app_version_id": "app-version-1", - "key": "img-1", - "name": "image-a", - "status": "building", - "created_at": "2026-03-07T10:00:00Z", - "updated_at": "2026-03-07T10:01:00Z", - "function_names": ["fn-1"], - } - ], - } - ) - cloud_client.stream_build_logs_to_stderr_prefixed.side_effect = ( - asyncio.CancelledError - ) - cloud_client.cancel_application_build.return_value = json.dumps( - { - "id": "app-build-1", - "organization_id": "org-1", - "project_id": "proj-1", - "name": "app_fn", - "version": "v1", - "status": "canceled", - "created_at": "2026-03-07T10:00:00Z", - "updated_at": "2026-03-07T10:02:00Z", - "finished_at": "2026-03-07T10:03:00Z", - "image_builds": [], - } - ) - builder = ImageBuilderV3Client( - cloud_client=cloud_client, - build_service_path="/images/v3/applications", - ) - - with self.assertRaises(asyncio.CancelledError): - await builder.build( - ApplicationBuildRequest( - name="app_fn", - version="v1", - images=[ - ApplicationBuildImageRequest( - key="img-1", - name="image-a", - context_sha256=SHA_A, - function_names=["fn-1"], - context_tar_gz=b"context-a", - ) - ], - ) - ) - - cloud_client.cancel_application_build.assert_called_once_with( - "/images/v3/applications", - "app-build-1", - ) - - async def test_stream_build_logs_falls_back_to_json_events(self): - await self._assert_stream_build_logs_event( - { - "image_build_id": "img-build-1", - "timestamp": "2026-03-07T10:01:00Z", - "stream": "stdout", - "message": "step 1", - "sequence_number": 1, - "build_status": "building", - } - ) - - async def test_stream_build_logs_handles_single_terminal_event_for_finished_builds( - self, - ): - for build_status, message in ( - ("succeeded", "Build finished successfully."), - ("failed", "Build failed."), - ("canceled", "Build canceled."), - ): - with self.subTest(build_status=build_status): - await self._assert_stream_build_logs_event( - { - "image_build_id": "img-build-1", - "timestamp": "2026-03-10T12:00:00Z", - "stream": "info", - "message": message, - "sequence_number": -1, - "build_status": build_status, - } - ) - - def test_build_reporters_disambiguates_duplicate_image_names(self): - builder = ImageBuilderV3Client( - cloud_client=MagicMock(), - build_service_path="/images/v3/applications", - ) - - reporters = builder._build_reporters( - type( - "Result", - (), - { - "image_builds": [ - type( - "ImageBuild", - (), - { - "id": "img-build-1", - "key": "img-1", - "name": "default", - "function_names": ["deploy_template"], - "status": "pending", - }, - )(), - type( - "ImageBuild", - (), - { - "id": "img-build-2", - "key": "img-2", - "name": "default", - "function_names": [ - "discover_template_files", - "fetch_file", - "deploy_via_cli", - ], - "status": "pending", - }, - )(), - ], - "name": "deploy_template", - }, - )() - ) - - self.assertEqual( - reporters["img-build-1"].display_name, - "deploy_template/default [deploy_template]", - ) - self.assertEqual( - reporters["img-build-2"].display_name, - "deploy_template/default [discover_template_files+2]", - ) - - def test_reporter_print_log_event_handles_pending_and_enqueued(self): - builder = ImageBuilderV3Client( - cloud_client=MagicMock(), - build_service_path="/images/v3/applications", - ) - reporter = builder._build_reporters( - type( - "Result", - (), - { - "image_builds": [ - type( - "ImageBuild", - (), - { - "id": "img-build-1", - "key": "img-1", - "name": "image-a", - "status": "pending", - }, - )() - ], - "name": "app_fn", - }, - )() - )["img-build-1"] - - with patch("tensorlake.builder.client_v3._print_message") as print_message: - reporter.print_log_event( - BuildLogEvent( - image_build_id="img-build-1", - timestamp="2026-03-07T10:01:00Z", - stream="info", - message="queued", - sequence_number=1, - build_status="pending", - ) - ) - reporter.print_log_event( - BuildLogEvent( - image_build_id="img-build-1", - timestamp="2026-03-07T10:02:00Z", - stream="info", - message="still queued", - sequence_number=2, - build_status="enqueued", - ) - ) - - waiting_calls = [ - call - for call in print_message.call_args_list - if call.args and call.args[0] == "Build waiting in queue..." - ] - self.assertEqual(len(waiting_calls), 2) - - async def test_build_rejects_duplicate_function_names_across_images(self): - builder = ImageBuilderV3Client( - cloud_client=MagicMock(), - build_service_path="/images/v3/applications", - ) - - with self.assertRaisesRegex(ValueError, "function_names must be unique"): - await builder.build( - ApplicationBuildRequest( - name="app_fn", - version="v1", - images=[ - ApplicationBuildImageRequest( - key="img-1", - name="image-a", - context_sha256="a" * 64, - function_names=["fn-shared"], - context_tar_gz=b"context-a", - ), - ApplicationBuildImageRequest( - key="img-2", - name="image-b", - context_sha256="b" * 64, - function_names=["fn-shared"], - context_tar_gz=b"context-b", - ), - ], - ) - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/cli/test_build_images.py b/tests/cli/test_build_images.py deleted file mode 100644 index 2f5ca37e2..000000000 --- a/tests/cli/test_build_images.py +++ /dev/null @@ -1,364 +0,0 @@ -import os -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -from tensorlake.cli import build_images as build_images_module - - -def _make_image(name="my-image", tag="latest", base_image="python:3.11"): - image = MagicMock() - image.name = name - image.tag = tag - image._base_image = base_image - image._build_operations = [] - return image - - -def _make_image_info(name="my-image", tag="latest", base_image="python:3.11"): - info = MagicMock() - info.image = _make_image(name=name, tag=tag, base_image=base_image) - return info - - -class TestBuildImages(unittest.TestCase): - def test_emits_user_friendly_import_error(self): - with ( - patch.object( - build_images_module, "load_code", side_effect=ImportError("secret") - ), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("failed to import application file", event["message"]) - self.assertNotIn("secret", event["message"]) - self.assertIn("ImportError: secret", event["details"]) - - def test_emits_syntax_error(self): - syntax_err = SyntaxError("unexpected EOF") - syntax_err.filename = "my_app.py" - syntax_err.lineno = 5 - - with ( - patch.object(build_images_module, "load_code", side_effect=syntax_err), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("syntax error", event["message"]) - self.assertIn("my_app.py", event["message"]) - self.assertIn("5", event["message"]) - - def test_emits_error_for_unhandled_load_exception(self): - with ( - patch.object( - build_images_module, - "load_code", - side_effect=RuntimeError("something unexpected"), - ), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("failed to load", event["message"]) - - def test_emits_traceback_for_load_exception_when_debug_enabled(self): - with ( - patch.dict(os.environ, {"TENSORLAKE_DEBUG": "1"}, clear=True), - patch.object( - build_images_module, - "load_code", - side_effect=RuntimeError("oops"), - ), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit): - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - event = emit.call_args.args[0] - self.assertIn("traceback", event) - self.assertIn("RuntimeError: oops", event["traceback"]) - - def test_emits_error_when_no_images_in_application(self): - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "image_infos", return_value={}), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("no images found", event["message"]) - - def test_emits_image_definitions_for_all_images(self): - infos = { - "img1": _make_image_info(name="image-one", tag="v1"), - "img2": _make_image_info(name="image-two", tag="v2"), - } - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "image_infos", return_value=infos), - patch.object( - build_images_module.importlib.metadata, - "version", - return_value="1.2.3", - ), - patch.object(build_images_module, "_emit") as emit, - ): - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - emitted = [call.args[0] for call in emit.call_args_list] - image_events = [e for e in emitted if e["type"] == "image"] - self.assertEqual(len(image_events), 2) - names = {e["name"] for e in image_events} - self.assertEqual(names, {"image-one", "image-two"}) - self.assertEqual(emitted[-1]["type"], "done") - - def test_emits_image_definition_with_correct_fields(self): - info = _make_image_info(name="my-image", tag="latest", base_image="python:3.12") - infos = {"img": info} - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "image_infos", return_value=infos), - patch.object( - build_images_module.importlib.metadata, - "version", - return_value="1.0.0", - ), - patch.object(build_images_module, "_emit") as emit, - ): - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name=None, - ) - - image_event = next( - e for e in (c.args[0] for c in emit.call_args_list) if e["type"] == "image" - ) - self.assertEqual(image_event["name"], "my-image") - self.assertEqual(image_event["tag"], "latest") - self.assertEqual(image_event["base_image"], "python:3.12") - self.assertEqual(image_event["sdk_version"], "1.0.0") - self.assertIn("operations", image_event) - - def test_tag_override_replaces_image_tag(self): - info = _make_image_info(name="my-image", tag="original-tag") - infos = {"img": info} - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "image_infos", return_value=infos), - patch.object( - build_images_module.importlib.metadata, "version", return_value="0.1.0" - ), - patch.object(build_images_module, "_emit") as emit, - ): - build_images_module.build_images( - application_file_path="my_app.py", - tag="override-tag", - image_name=None, - ) - - image_event = next( - e for e in (c.args[0] for c in emit.call_args_list) if e["type"] == "image" - ) - self.assertEqual(image_event["tag"], "override-tag") - - def test_image_name_filter_emits_only_matching_image(self): - infos = { - "img1": _make_image_info(name="wanted"), - "img2": _make_image_info(name="unwanted"), - } - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "image_infos", return_value=infos), - patch.object( - build_images_module.importlib.metadata, "version", return_value="0.1.0" - ), - patch.object(build_images_module, "_emit") as emit, - ): - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name="wanted", - ) - - image_events = [ - c.args[0] for c in emit.call_args_list if c.args[0]["type"] == "image" - ] - self.assertEqual(len(image_events), 1) - self.assertEqual(image_events[0]["name"], "wanted") - - def test_image_name_filter_emits_error_when_no_match(self): - infos = {"img": _make_image_info(name="other-image")} - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "image_infos", return_value=infos), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.build_images( - application_file_path="my_app.py", - tag=None, - image_name="nonexistent", - ) - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("nonexistent", event["message"]) - - def test_build_images_with_builder_uses_deploy_builder_flow(self): - auth = MagicMock() - builder = MagicMock() - functions = [MagicMock()] - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "get_functions", return_value=functions), - patch.object( - build_images_module.deploy_module, - "_build_context_from_env", - return_value=auth, - ), - patch.object( - build_images_module.deploy_module, "mk_builder", return_value=builder - ) as mk_builder, - patch.object( - build_images_module.deploy_module, - "_prepare_images", - new_callable=AsyncMock, - ) as prepare_images, - ): - build_images_module.build_images_with_builder( - application_file_path="my_app.py", - image_builder_version="v3", - ) - - mk_builder.assert_called_once_with("v3", auth) - prepare_images.assert_called_once_with(builder, functions) - - def test_build_images_with_builder_emits_error_on_exception(self): - auth = MagicMock() - - with ( - patch.object(build_images_module, "load_code"), - patch.object(build_images_module, "get_functions", return_value=[]), - patch.object( - build_images_module.deploy_module, - "_build_context_from_env", - return_value=auth, - ), - patch.object( - build_images_module.deploy_module, - "mk_builder", - return_value=MagicMock(), - ), - patch.object( - build_images_module.deploy_module, - "_prepare_images", - new_callable=AsyncMock, - side_effect=RuntimeError("boom"), - ), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.build_images_with_builder( - application_file_path="my_app.py", - image_builder_version="v3", - ) - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("build-images failed", event["message"]) - self.assertIn("RuntimeError: boom", event["details"]) - - def test_main_entrypoint_uses_builder_mode_when_requested(self): - with ( - patch( - "sys.argv", - [ - "tensorlake-build-images", - "my_app.py", - "--image-builder-version", - "v3", - ], - ), - patch.object( - build_images_module, "build_images_with_builder" - ) as build_with_builder, - ): - build_images_module.main() - - build_with_builder.assert_called_once_with( - application_file_path="my_app.py", - image_builder_version="v3", - ) - - def test_main_entrypoint_emits_error_for_unhandled_exception(self): - with ( - patch("sys.argv", ["tensorlake-build-images", "my_app.py"]), - patch.object( - build_images_module, - "build_images", - side_effect=RuntimeError("unexpected"), - ), - patch.object(build_images_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - build_images_module.main() - - self.assertEqual(exc.exception.code, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertIn("build-images failed", event["message"]) - self.assertIn("RuntimeError: unexpected", event["details"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/cli/test_deploy.py b/tests/cli/test_deploy.py index 4093c3c86..30bbe6e78 100644 --- a/tests/cli/test_deploy.py +++ b/tests/cli/test_deploy.py @@ -1,16 +1,16 @@ -import asyncio -import hashlib -import json import os import unittest from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch -from tensorlake import builder as builder_module from tensorlake.applications import Image, application, function from tensorlake.applications import registry as registry_module +from tensorlake.applications.remote.manifests.function_manifests import ImageRef from tensorlake.cli import deploy as deploy_module +from tensorlake.image.sandbox_builder import ( + SandboxImageBuildError, +) @contextmanager @@ -23,175 +23,7 @@ def isolated_registry(): yield -def fake_create_image_context_file(image, file_path, extra_env_vars=None): - with open(file_path, "wb") as handle: - handle.write(f"context:{image._id}".encode()) - - -def expected_context_sha(image): - return hashlib.sha256(f"context:{image._id}".encode()).hexdigest() - - -class FakeV3CloudClient: - def __init__(self): - self.calls = [] - self.application_build_info_calls = [] - self.stream_calls = [] - self.create_responses = {} - self._responses = {} - - def create_application_build( - self, - build_service_path: str, - request_json: str, - image_contexts: list[tuple[str, bytes]], - ) -> str: - request = json.loads(request_json) - self.calls.append((build_service_path, request_json, image_contexts)) - app_build_id = f"app-build-{len(self.calls)}" - image_builds = [ - { - "id": f"img-build-{app_build_id}-{image['key']}", - "app_version_id": app_build_id, - "key": image["key"], - "name": image.get("name"), - "context_sha256": image["context_sha256"], - "status": "pending", - "created_at": "2026-03-10T10:00:00Z", - "updated_at": "2026-03-10T10:00:00Z", - "function_names": image["function_names"], - } - for image in request["images"] - ] - create_response = { - "id": app_build_id, - "organization_id": "org-1", - "project_id": "proj-1", - "name": request["name"], - "version": request["version"], - "status": "building", - "image_builds": image_builds, - } - self.create_responses[app_build_id] = create_response - self._responses[app_build_id] = { - "id": app_build_id, - "organization_id": "org-1", - "project_id": "proj-1", - "name": request["name"], - "version": request["version"], - "status": "succeeded", - "created_at": "2026-03-10T10:00:00Z", - "updated_at": "2026-03-10T10:01:00Z", - "finished_at": "2026-03-10T10:01:00Z", - "image_builds": [ - { - **image_build, - "status": "succeeded", - "image_uri": f"registry.example.com/{request['name']}/{image_build['key']}:latest", - "image_digest": f"sha256:{image_build['key']}", - "finished_at": "2026-03-10T10:01:00Z", - } - for image_build in image_builds - ], - } - return json.dumps(create_response) - - def application_build_info_json( - self, - build_service_path: str, - application_build_id: str, - ) -> str: - self.application_build_info_calls.append( - (build_service_path, application_build_id) - ) - return json.dumps(self._responses[application_build_id]) - - def stream_build_logs_to_stderr_prefixed( - self, - build_service_path: str, - build_id: str, - prefix: str, - color: str | None = None, - ) -> None: - self.stream_calls.append((build_service_path, build_id, prefix, color)) - return None - - class TestDeployHelpers(unittest.TestCase): - def test_collect_application_build_request_groups_functions_using_default_image( - self, - ): - with isolated_registry(): - custom_image = Image(name="custom-image") - - @function() - def default_helper(payload): - return payload - - @function(image=custom_image) - def custom_helper(payload): - return payload - - @application() - @function() - def default_image_app(payload): - payload = default_helper(payload) - return custom_helper(payload) - - all_functions = [default_helper, custom_helper, default_image_app] - - image_context_calls = [] - - def fake_build_image_context(image, extra_env_vars=None): - image_context_calls.append((image, extra_env_vars)) - return f"context:{image._id}".encode() - - with patch.object( - builder_module, - "build_image_context", - side_effect=fake_build_image_context, - ): - request = builder_module.collect_application_build_request( - default_image_app, - all_functions, - build_env_vars=[("PIP_INDEX_URL", "https://test.pypi.org/simple/")], - ) - - images = {image.key: image for image in request.images} - self.assertEqual( - image_context_calls, - [ - ( - default_helper._function_config.image, - [("PIP_INDEX_URL", "https://test.pypi.org/simple/")], - ), - ( - custom_image, - [("PIP_INDEX_URL", "https://test.pypi.org/simple/")], - ), - ], - ) - self.assertEqual( - set(images), {default_helper._function_config.image._id, custom_image._id} - ) - - default_image_request = images[default_helper._function_config.image._id] - self.assertEqual(default_image_request.name, "default") - self.assertEqual( - set(default_image_request.function_names), - { - default_helper._function_config.function_name, - default_image_app._function_config.function_name, - }, - ) - - custom_image_request = images[custom_image._id] - self.assertEqual(custom_image_request.name, "custom-image") - self.assertEqual( - custom_image_request.function_names, - [custom_helper._function_config.function_name], - ) - def test_format_error_message_does_not_include_exception_payload(self): message = deploy_module._format_error_message( "build failed", RuntimeError("secret") @@ -202,11 +34,11 @@ def test_format_error_message_does_not_include_exception_payload(self): def test_format_build_failure_message_includes_inner_error(self): message = deploy_module._format_build_failure_message( "parser-image", - RuntimeError("404 Not Found: POST /images/v3/applications"), + RuntimeError("snapshot timed out"), ) self.assertEqual( message, - "image 'parser-image' build failed: 404 Not Found: POST /images/v3/applications. check your Image() configuration and try again.", + "image 'parser-image' build failed: snapshot timed out. check your Image() configuration and try again.", ) def test_build_context_from_env_passes_expected_values(self): @@ -265,7 +97,111 @@ def test_error_event_includes_traceback_when_debug_enabled(self): self.assertIn("RuntimeError: boom", event["traceback"]) -class TestDeployEntrypoints(unittest.TestCase): +class TestPrepareImages(unittest.TestCase): + """The sandbox-image path that `tl deploy` takes for every function image.""" + + def test_deduplicates_images_by_id_and_returns_image_refs(self): + image_a = Image(name="image-a") + image_b = Image(name="image-b") + + with isolated_registry(): + + @application() + @function(image=image_a) + def app_one() -> str: + return "a" + + @function(image=image_a) + def child_one(x: str) -> str: + return x + + @application() + @function(image=image_b) + def app_two() -> str: + return "b" + + functions = registry_module.get_functions() + + def fake_build(image, *, emit): + return {"name": f"{image.name}-template"} + + with ( + patch.object(deploy_module, "build_sandbox_image", side_effect=fake_build) as build, + patch.object(deploy_module, "_emit"), + ): + refs = deploy_module._prepare_images(functions) + + self.assertEqual(build.call_count, 2) + # Both function refs from app_one share image_a, so the map has two + # distinct entries keyed by Image._id pointing at the right template. + self.assertEqual( + {ref.id for ref in refs.values()}, + {"image-a-template", "image-b-template"}, + ) + self.assertTrue( + all(ref.kind == "sandbox_template" for ref in refs.values()) + ) + self.assertIn(image_a._id, refs) + self.assertIn(image_b._id, refs) + + def test_falls_back_to_image_name_when_platform_response_lacks_name(self): + image = Image(name="my-fn-image") + + with isolated_registry(): + + @application() + @function(image=image) + def app() -> str: + return "" + + functions = registry_module.get_functions() + + with ( + patch.object( + deploy_module, + "build_sandbox_image", + return_value={}, + ), + patch.object(deploy_module, "_emit"), + ): + refs = deploy_module._prepare_images(functions) + + self.assertEqual(refs[image._id], ImageRef(kind="sandbox_template", id="my-fn-image")) + + def test_sandbox_build_failure_emits_build_failed_and_exits(self): + image = Image(name="broken-image") + + with isolated_registry(): + + @application() + @function(image=image) + def app() -> str: + return "" + + functions = registry_module.get_functions() + + with ( + patch.object( + deploy_module, + "build_sandbox_image", + side_effect=SandboxImageBuildError("snapshot upload timed out"), + ), + patch.object(deploy_module, "_emit") as emit, + ): + with self.assertRaises(SystemExit) as exc: + deploy_module._prepare_images(functions) + + self.assertEqual(exc.exception.code, 1) + failure = next( + call.args[0] + for call in emit.call_args_list + if call.args[0]["type"] == "build_failed" + ) + self.assertEqual(failure["image"], "broken-image") + self.assertIn("snapshot upload timed out", failure["error"]) + + +class TestDeployEntrypoint(unittest.TestCase): def _make_auth_context(self): return SimpleNamespace( api_url="https://api.tensorlake.ai", @@ -276,37 +212,6 @@ def _make_auth_context(self): cloud_client=MagicMock(), ) - @contextmanager - def _successful_deploy_patches( - self, - auth, - functions, - *, - declared_secret_names=None, - missing_secret_names=None, - ): - with ( - patch.object(deploy_module, "_build_context_from_env", return_value=auth), - patch.object(deploy_module, "load_code"), - patch.object( - deploy_module, "validate_loaded_applications", return_value=[] - ), - patch.object(deploy_module, "format_validation_messages", return_value=[]), - patch.object(deploy_module, "has_error_message", return_value=False), - patch.object( - deploy_module, - "list_secret_names", - return_value=declared_secret_names or [], - ), - patch.object( - deploy_module, - "_warning_missing_secrets", - return_value=missing_secret_names or [], - ), - patch.object(deploy_module, "get_functions", return_value=functions), - ): - yield - def test_deploy_emits_user_friendly_import_error(self): with ( patch.object( @@ -314,7 +219,6 @@ def test_deploy_emits_user_friendly_import_error(self): "_build_context_from_env", return_value=self._make_auth_context(), ), - patch.object(deploy_module, "mk_builder", return_value=MagicMock()), patch.object(deploy_module, "load_code", side_effect=ImportError("boom")), patch.object(deploy_module, "_emit") as emit, ): @@ -325,13 +229,9 @@ def test_deploy_emits_user_friendly_import_error(self): ) self.assertEqual(exc.exception.code, 1) - self.assertEqual(emit.call_args_list[0].args[0]["type"], "status") event = emit.call_args_list[-1].args[0] self.assertEqual(event["type"], "error") - self.assertIn( - "failed to import application file", - event["message"], - ) + self.assertIn("failed to import application file", event["message"]) self.assertEqual(event["details"], "ImportError: boom") def test_deploy_emits_validation_failed_when_validation_has_errors(self): @@ -341,7 +241,6 @@ def test_deploy_emits_validation_failed_when_validation_has_errors(self): "_build_context_from_env", return_value=self._make_auth_context(), ), - patch.object(deploy_module, "mk_builder", return_value=MagicMock()), patch.object(deploy_module, "load_code"), patch.object( deploy_module, "validate_loaded_applications", return_value=["x"] @@ -367,631 +266,57 @@ def test_deploy_emits_validation_failed_when_validation_has_errors(self): ) self.assertEqual(exc.exception.code, 1) - event_types = [call.args[0]["type"] for call in emit.call_args_list] - self.assertIn("validation", event_types) - self.assertEqual(event_types[-1], "validation_failed") + types = [call.args[0]["type"] for call in emit.call_args_list] + self.assertIn("validation", types) + self.assertIn("validation_failed", types) - def test_deploy_runs_build_and_deploy_flow(self): - prepare_images = AsyncMock() + def test_deploy_runs_build_and_passes_image_refs_to_deploy_step(self): auth = self._make_auth_context() - application = SimpleNamespace(_name="app-one") - with ( - patch.object( - deploy_module, - "_build_context_from_env", - return_value=auth, - ), - patch.object( - deploy_module, "mk_builder", return_value=MagicMock() - ) as mk_builder, - patch.object(deploy_module, "load_code"), - patch.object( - deploy_module, "validate_loaded_applications", return_value=[] - ), - patch.object(deploy_module, "format_validation_messages", return_value=[]), - patch.object(deploy_module, "has_error_message", return_value=False), - patch.object(deploy_module, "list_secret_names", return_value=[]), - patch.object(deploy_module, "_warning_missing_secrets", return_value=[]), - patch.object(deploy_module, "get_functions", return_value=["fn"]), - patch.object(deploy_module, "_prepare_images", prepare_images), - patch.object(deploy_module, "deploy_applications") as deploy_apps, - patch.object( - deploy_module, - "filter_applications", - return_value=iter([application]), - ), - patch.object( - deploy_module, - "example_application_curl_command", - return_value="curl https://example.test", - ), - patch.object(deploy_module, "_emit") as emit, - ): - deploy_module.deploy( - application_file_path="my_app.py", - upgrade_running_requests=True, - ) - - prepare_images.assert_awaited_once() - mk_builder.assert_called_once() - deploy_apps.assert_called_once_with( - applications_file_path=os.path.abspath("my_app.py"), - upgrade_running_requests=True, - load_source_dir_modules=False, - api_client=auth.cloud_client, - image_refs=None, - ) - deployed_event = next( - call.args[0] - for call in emit.call_args_list - if call.args[0]["type"] == "deployed" - ) - self.assertEqual(deployed_event["type"], "deployed") - self.assertEqual(deployed_event["application"], "app-one") - self.assertEqual(deployed_event["curl_command"], "curl https://example.test") - - def test_deploy_emits_missing_secret_names(self): - prepare_images = AsyncMock() - auth = self._make_auth_context() - with ( - self._successful_deploy_patches( - auth, - ["fn"], - declared_secret_names=["EXISTING", "MISSING_ONE", "MISSING_TWO"], - missing_secret_names=["MISSING_ONE", "MISSING_TWO"], - ), - patch.object(deploy_module, "mk_builder", return_value=MagicMock()), - patch.object(deploy_module, "_prepare_images", prepare_images), - patch.object(deploy_module, "deploy_applications"), - patch.object( - deploy_module, - "filter_applications", - return_value=iter([]), - ), - patch.object(deploy_module, "_emit") as emit, - ): - deploy_module.deploy( - application_file_path="my_app.py", - upgrade_running_requests=False, - ) - - missing_event = next( - call.args[0] - for call in emit.call_args_list - if call.args[0]["type"] == "missing_secrets" - ) - self.assertEqual(missing_event["count"], 2) - self.assertEqual(missing_event["names"], ["MISSING_ONE", "MISSING_TWO"]) - - def test_deploy_v3_path_builds_all_functions_for_each_application(self): - cloud_client = FakeV3CloudClient() - auth = SimpleNamespace( - api_url="https://api.tensorlake.ai", - api_key="api-key", - personal_access_token=None, - organization_id="org-1", - project_id="proj-1", - cloud_client=cloud_client, - ) with isolated_registry(): - parser_image = Image(name="parser-image") - agent_image = Image(name="agent-image") - code_exec_image = Image(name="code-exec-image") - - @function() - def get_extraction_schema(payload): - return payload - - @function() - def get_document_content(payload): - return payload - - @function(image=parser_image) - def upload_and_parse_document(file): - return file - - @function(image=agent_image) - def run_finance_agent(parsed_document): - return parsed_document - - @function(image=agent_image) - def execute_sql_query(query): - return query - - @function(image=code_exec_image) - def execute_code(code): - return code - - @function(image=agent_image) - def run_query_agent(question): - return question + image = Image(name="hello-image") @application() - @function() - def finance_analyzer(file): - parsed_document = upload_and_parse_document(file) - return run_finance_agent(parsed_document) + @function(image=image) + def hello() -> str: + return "hi" - @application() - @function(image=agent_image) - def finance_query(question): - execute_sql_query(question) - return run_query_agent(question) - - functions = [ - get_extraction_schema, - get_document_content, - upload_and_parse_document, - run_finance_agent, - execute_sql_query, - execute_code, - run_query_agent, - finance_analyzer, - finance_query, - ] - - default_image = get_extraction_schema._function_config.image + functions = registry_module.get_functions() + image_refs = { + image._id: ImageRef(kind="sandbox_template", id="hello-image"), + } with ( - self._successful_deploy_patches(auth, functions), + patch.object(deploy_module, "_build_context_from_env", return_value=auth), + patch.object(deploy_module, "load_code"), + patch.object(deploy_module, "validate_loaded_applications", return_value=[]), + patch.object(deploy_module, "format_validation_messages", return_value=[]), + patch.object(deploy_module, "has_error_message", return_value=False), + patch.object(deploy_module, "list_secret_names", return_value=[]), + patch.object(deploy_module, "_warning_missing_secrets", return_value=[]), + patch.object(deploy_module, "get_functions", return_value=functions), + patch.object( + deploy_module, "_prepare_images", return_value=image_refs + ) as prepare, + patch.object(deploy_module, "deploy_applications") as deploy_apps, patch.object( - builder_module, - "create_image_context_file", - side_effect=fake_create_image_context_file, + deploy_module, + "example_application_curl_command", + return_value="curl", ), - patch.object(deploy_module, "_deploy_applications"), patch.object(deploy_module, "_emit"), ): deploy_module.deploy( application_file_path="my_app.py", - upgrade_running_requests=False, - image_builder_version="v3", + upgrade_running_requests=True, ) - self.assertEqual(len(cloud_client.calls), 2) - - analyzer_build_service_path, analyzer_request_json, analyzer_image_contexts = ( - cloud_client.calls[0] - ) - self.assertEqual(analyzer_build_service_path, "/images/v3/applications") - - analyzer_request = json.loads(analyzer_request_json) - self.assertEqual( - analyzer_request["name"], finance_analyzer._function_config.function_name - ) - analyzer_images = { - image["context_tar_part_name"]: image - for image in analyzer_request["images"] - } - self.assertEqual( - set(analyzer_image_contexts), - { - (default_image._id, f"context:{default_image._id}".encode()), - (parser_image._id, f"context:{parser_image._id}".encode()), - (agent_image._id, f"context:{agent_image._id}".encode()), - (code_exec_image._id, f"context:{code_exec_image._id}".encode()), - }, - ) - self.assertEqual( - set(analyzer_images), - { - default_image._id, - parser_image._id, - agent_image._id, - code_exec_image._id, - }, - ) - self.assertEqual( - { - image_key: image["context_sha256"] - for image_key, image in analyzer_images.items() - }, - { - default_image._id: expected_context_sha(default_image), - parser_image._id: expected_context_sha(parser_image), - agent_image._id: expected_context_sha(agent_image), - code_exec_image._id: expected_context_sha(code_exec_image), - }, - ) - self.assertEqual( - set(analyzer_images[default_image._id]["function_names"]), - { - get_extraction_schema._function_config.function_name, - get_document_content._function_config.function_name, - finance_analyzer._function_config.function_name, - }, - ) - self.assertEqual( - analyzer_images[parser_image._id]["function_names"], - [upload_and_parse_document._function_config.function_name], - ) - self.assertEqual( - set(analyzer_images[agent_image._id]["function_names"]), - { - run_finance_agent._function_config.function_name, - execute_sql_query._function_config.function_name, - run_query_agent._function_config.function_name, - finance_query._function_config.function_name, - }, - ) - self.assertEqual( - analyzer_images[code_exec_image._id]["function_names"], - [execute_code._function_config.function_name], - ) - - query_request = json.loads(cloud_client.calls[1][1]) - query_images = { - image["context_tar_part_name"]: image for image in query_request["images"] - } - self.assertEqual( - set(query_images), - { - default_image._id, - parser_image._id, - agent_image._id, - code_exec_image._id, - }, - ) - self.assertEqual( - { - image_key: image["context_sha256"] - for image_key, image in query_images.items() - }, - { - default_image._id: expected_context_sha(default_image), - parser_image._id: expected_context_sha(parser_image), - agent_image._id: expected_context_sha(agent_image), - code_exec_image._id: expected_context_sha(code_exec_image), - }, - ) - self.assertEqual( - set(query_images[default_image._id]["function_names"]), - { - get_extraction_schema._function_config.function_name, - get_document_content._function_config.function_name, - finance_analyzer._function_config.function_name, - }, - ) - self.assertEqual( - query_images[parser_image._id]["function_names"], - [upload_and_parse_document._function_config.function_name], - ) - self.assertEqual( - set(query_images[agent_image._id]["function_names"]), - { - run_finance_agent._function_config.function_name, - execute_sql_query._function_config.function_name, - run_query_agent._function_config.function_name, - finance_query._function_config.function_name, - }, - ) - self.assertEqual( - query_images[code_exec_image._id]["function_names"], - [execute_code._function_config.function_name], - ) - self.assertEqual( - cloud_client.create_responses["app-build-1"]["status"], "building" - ) - self.assertEqual( - cloud_client.create_responses["app-build-2"]["status"], "building" - ) - self.assertEqual( - len(cloud_client.create_responses["app-build-1"]["image_builds"]), 4 - ) - self.assertEqual( - len(cloud_client.create_responses["app-build-2"]["image_builds"]), 4 - ) - self.assertTrue( - all( - image["status"] == "pending" - for image in cloud_client.create_responses["app-build-1"][ - "image_builds" - ] - ) - ) - self.assertTrue( - all( - image["status"] == "pending" - for image in cloud_client.create_responses["app-build-2"][ - "image_builds" - ] - ) - ) - self.assertEqual( - cloud_client.application_build_info_calls, - [ - ("/images/v3/applications", "app-build-1"), - ("/images/v3/applications", "app-build-2"), - ], - ) - self.assertEqual( - { - ( - build_service_path, - build_id, - prefix, - ) - for build_service_path, build_id, prefix, _color in cloud_client.stream_calls - }, - { - ( - "/images/v3", - f"img-build-app-build-1-{default_image._id}", - f"{finance_analyzer._function_config.function_name}/default", - ), - ( - "/images/v3", - f"img-build-app-build-1-{parser_image._id}", - f"{finance_analyzer._function_config.function_name}/parser-image", - ), - ( - "/images/v3", - f"img-build-app-build-1-{agent_image._id}", - f"{finance_analyzer._function_config.function_name}/agent-image", - ), - ( - "/images/v3", - f"img-build-app-build-1-{code_exec_image._id}", - f"{finance_analyzer._function_config.function_name}/code-exec-image", - ), - ( - "/images/v3", - f"img-build-app-build-2-{default_image._id}", - f"{finance_query._function_config.function_name}/default", - ), - ( - "/images/v3", - f"img-build-app-build-2-{parser_image._id}", - f"{finance_query._function_config.function_name}/parser-image", - ), - ( - "/images/v3", - f"img-build-app-build-2-{agent_image._id}", - f"{finance_query._function_config.function_name}/agent-image", - ), - ( - "/images/v3", - f"img-build-app-build-2-{code_exec_image._id}", - f"{finance_query._function_config.function_name}/code-exec-image", - ), - }, - ) - - def test_deploy_v2_path_builds_images_and_then_deploys(self): - class FakeCloudClient: - def __init__(self): - self.start_calls = [] - self.stream_calls = [] - self.info_calls = [] - - def start_image_build( - self, - build_service_path: str, - application_name: str, - application_version: str, - function_name: str, - image_name: str, - image_key: str, - context_tar_gz: bytes, - ) -> str: - self.start_calls.append( - { - "build_service_path": build_service_path, - "application_name": application_name, - "application_version": application_version, - "function_name": function_name, - "image_name": image_name, - "image_key": image_key, - "context_tar_gz": context_tar_gz, - } - ) - return json.dumps( - { - "id": f"build-{function_name}", - "status": "building", - "created_at": "2026-03-10T10:00:00Z", - "updated_at": "2026-03-10T10:00:00Z", - "finished_at": None, - } - ) - - def stream_build_logs_to_stderr( - self, - build_service_path: str, - build_id: str, - ) -> None: - self.stream_calls.append((build_service_path, build_id)) - - def build_info_json( - self, - build_service_path: str, - build_id: str, - ) -> str: - self.info_calls.append((build_service_path, build_id)) - return json.dumps( - { - "id": build_id, - "status": "succeeded", - "created_at": "2026-03-10T10:00:00Z", - "updated_at": "2026-03-10T10:01:00Z", - "finished_at": "2026-03-10T10:01:00Z", - } - ) - - cloud_client = FakeCloudClient() - auth = SimpleNamespace( - api_url="https://api.tensorlake.ai", - api_key="api-key", - personal_access_token=None, - organization_id="org-1", - project_id="proj-1", - cloud_client=cloud_client, - ) - - shared_image = MagicMock() - shared_image._id = "img-shared" - shared_image.name = "shared-image" - - application = SimpleNamespace( - _name="app-one", - _function_config=SimpleNamespace( - function_name="app-one", - image=shared_image, - ), - _application_config=SimpleNamespace(version="v1"), - ) - helper = SimpleNamespace( - _function_config=SimpleNamespace( - function_name="helper-one", - image=shared_image, - ), - _application_config=None, - ) - functions = [application, helper] - - def fake_build_image_context(image, extra_env_vars=None): - return f"context:{image._id}".encode() - - with ( - patch.object(deploy_module, "_build_context_from_env", return_value=auth), - patch.object(deploy_module, "load_code"), - patch.object( - deploy_module, "validate_loaded_applications", return_value=[] - ), - patch.object(deploy_module, "format_validation_messages", return_value=[]), - patch.object(deploy_module, "has_error_message", return_value=False), - patch.object(deploy_module, "list_secret_names", return_value=[]), - patch.object(deploy_module, "_warning_missing_secrets", return_value=[]), - patch.object(deploy_module, "get_functions", return_value=functions), - patch.object( - builder_module, - "build_image_context", - side_effect=fake_build_image_context, - ), - patch.object(deploy_module, "_deploy_applications") as deploy_apps, - patch.object(deploy_module, "_emit") as emit, - ): - deploy_module.deploy( - application_file_path="my_app.py", - upgrade_running_requests=False, - image_builder_version="v2", - ) - - self.assertEqual( - [call["build_service_path"] for call in cloud_client.start_calls], - ["/images/v2", "/images/v2"], - ) - self.assertEqual( - [call["function_name"] for call in cloud_client.start_calls], - ["app-one", "helper-one"], - ) - self.assertEqual( - [call["context_tar_gz"] for call in cloud_client.start_calls], - [b"context:img-shared", b"context:img-shared"], - ) - self.assertEqual( - cloud_client.stream_calls, - [("/images/v2", "build-app-one"), ("/images/v2", "build-helper-one")], - ) - self.assertEqual( - cloud_client.info_calls, - [("/images/v2", "build-app-one"), ("/images/v2", "build-helper-one")], - ) - build_start_events = [ - call.args[0] - for call in emit.call_args_list - if call.args and call.args[0]["type"] == "build_start" - ] - self.assertEqual( - build_start_events, - [ - {"type": "build_start", "image": "shared-image"}, - {"type": "build_start", "image": "shared-image"}, - ], - ) - deploy_apps.assert_called_once() - - def test_deploy_entrypoint_emits_error_for_unhandled_exception(self): - with ( - patch("sys.argv", ["tensorlake-deploy", "my_app.py"]), - patch.object(deploy_module, "deploy", side_effect=RuntimeError("boom")), - patch.object(deploy_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - deploy_module.deploy_entrypoint() - - self.assertEqual(exc.exception.code, 1) - self.assertEqual(emit.call_count, 1) - event = emit.call_args.args[0] - self.assertEqual(event["type"], "error") - self.assertEqual(event["message"], "deploy failed (RuntimeError)") - self.assertEqual(event["details"], "RuntimeError: boom") - - def test_deploy_entrypoint_passes_image_builder_version(self): - with ( - patch( - "sys.argv", - [ - "tensorlake-deploy", - "my_app.py", - "--image-builder-version", - "v3", - "--build-env", - "PIP_INDEX_URL=https://test.pypi.org/simple/", - "--build-env", - "PIP_EXTRA_INDEX_URL=https://pypi.org/simple/", - ], - ), - patch.object(deploy_module, "deploy") as deploy, - ): - deploy_module.deploy_entrypoint() - - self.assertEqual(deploy.call_args.kwargs["image_builder_version"], "v3") - self.assertEqual( - deploy.call_args.kwargs["build_envs"], - [ - ("PIP_INDEX_URL", "https://test.pypi.org/simple/"), - ("PIP_EXTRA_INDEX_URL", "https://pypi.org/simple/"), - ], - ) - - def test_deploy_entrypoint_defaults_to_v3_image_builder(self): - with ( - patch("sys.argv", ["tensorlake-deploy", "my_app.py"]), - patch.object(deploy_module, "deploy") as deploy, - ): - deploy_module.deploy_entrypoint() - - self.assertEqual(deploy.call_args.kwargs["image_builder_version"], "v3") - - def test_prepare_images_emits_inner_build_error_message(self): - builder = MagicMock() - builder.build = AsyncMock( - side_effect=deploy_module.ApplicationImageBuildError( - image_name="parser-image", - error=RuntimeError("404 Not Found: POST /images/v3/applications"), - ) - ) - - with ( - patch.object(deploy_module, "filter_applications", return_value=[object()]), - patch.object( - deploy_module, - "collect_application_build_request", - return_value=MagicMock(), - ), - patch.object(deploy_module, "_emit") as emit, - ): - with self.assertRaises(SystemExit) as exc: - asyncio.run(deploy_module._prepare_images(builder, ["fn"])) - - self.assertEqual(exc.exception.code, 1) - emit.assert_called_once_with( - { - "type": "build_failed", - "image": "parser-image", - "error": "image 'parser-image' build failed: 404 Not Found: POST /images/v3/applications. check your Image() configuration and try again.", - } + prepare.assert_called_once_with(functions) + deploy_apps.assert_called_once_with( + applications_file_path=os.path.abspath("my_app.py"), + upgrade_running_requests=True, + load_source_dir_modules=False, + api_client=auth.cloud_client, + image_refs=image_refs, ) From c271ebb8da2c811209eefadafe5028b68f0f07e4 Mon Sep 17 00:00:00 2001 From: Diptanu Choudhury Date: Fri, 15 May 2026 17:16:25 +0000 Subject: [PATCH 4/4] tl deploy: CI fixes for sandbox-only path - .github/workflows/integration_test.yaml: drop the --build-env flags from the `tl deploy` step. The flag no longer exists on the Python argparse since the v2/v3 paths were removed; `tl build-images` keeps its --build-env (used by the local Docker workflow only). - black + isort: reflow deploy.py and test_deploy.py to match the formatter's expected style. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration_test.yaml | 4 +--- src/tensorlake/cli/deploy.py | 4 +--- tests/cli/test_deploy.py | 28 +++++++++++++++++-------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 5841bab82..ed2142112 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -90,9 +90,7 @@ jobs: --build-env PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ echo "--- 2. Deploy ---" - tl deploy /tmp/test_app.py \ - --build-env PIP_INDEX_URL=https://test.pypi.org/simple/ \ - --build-env PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ + tl deploy /tmp/test_app.py echo "--- All integration tests passed ---" ' diff --git a/src/tensorlake/cli/deploy.py b/src/tensorlake/cli/deploy.py index 074585c02..fc43d2dae 100644 --- a/src/tensorlake/cli/deploy.py +++ b/src/tensorlake/cli/deploy.py @@ -244,9 +244,7 @@ def _prepare_images(functions: list[Function]) -> dict[str, ImageRef]: } ) sys.exit(1) - image_refs[image._id] = ImageRef( - kind="sandbox_template", id=template_name - ) + image_refs[image._id] = ImageRef(kind="sandbox_template", id=template_name) _emit({"type": "build_done"}) return image_refs diff --git a/tests/cli/test_deploy.py b/tests/cli/test_deploy.py index 30bbe6e78..25b9313be 100644 --- a/tests/cli/test_deploy.py +++ b/tests/cli/test_deploy.py @@ -126,7 +126,9 @@ def fake_build(image, *, emit): return {"name": f"{image.name}-template"} with ( - patch.object(deploy_module, "build_sandbox_image", side_effect=fake_build) as build, + patch.object( + deploy_module, "build_sandbox_image", side_effect=fake_build + ) as build, patch.object(deploy_module, "_emit"), ): refs = deploy_module._prepare_images(functions) @@ -138,9 +140,7 @@ def fake_build(image, *, emit): {ref.id for ref in refs.values()}, {"image-a-template", "image-b-template"}, ) - self.assertTrue( - all(ref.kind == "sandbox_template" for ref in refs.values()) - ) + self.assertTrue(all(ref.kind == "sandbox_template" for ref in refs.values())) self.assertIn(image_a._id, refs) self.assertIn(image_b._id, refs) @@ -166,7 +166,9 @@ def app() -> str: ): refs = deploy_module._prepare_images(functions) - self.assertEqual(refs[image._id], ImageRef(kind="sandbox_template", id="my-fn-image")) + self.assertEqual( + refs[image._id], ImageRef(kind="sandbox_template", id="my-fn-image") + ) def test_sandbox_build_failure_emits_build_failed_and_exits(self): image = Image(name="broken-image") @@ -286,13 +288,21 @@ def hello() -> str: } with ( - patch.object(deploy_module, "_build_context_from_env", return_value=auth), + patch.object( + deploy_module, "_build_context_from_env", return_value=auth + ), patch.object(deploy_module, "load_code"), - patch.object(deploy_module, "validate_loaded_applications", return_value=[]), - patch.object(deploy_module, "format_validation_messages", return_value=[]), + patch.object( + deploy_module, "validate_loaded_applications", return_value=[] + ), + patch.object( + deploy_module, "format_validation_messages", return_value=[] + ), patch.object(deploy_module, "has_error_message", return_value=False), patch.object(deploy_module, "list_secret_names", return_value=[]), - patch.object(deploy_module, "_warning_missing_secrets", return_value=[]), + patch.object( + deploy_module, "_warning_missing_secrets", return_value=[] + ), patch.object(deploy_module, "get_functions", return_value=functions), patch.object( deploy_module, "_prepare_images", return_value=image_refs