From 8aaa496b49ad643f22d4e8c1052134e6451cc812 Mon Sep 17 00:00:00 2001 From: Michael Stolarz Date: Thu, 20 Aug 2026 13:29:08 -0700 Subject: [PATCH] feat(image): send stable build context hashes --- src/blaxel/core/image/image.py | 45 ++++++++++++++++++++++------- tests/core/test_image.py | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/blaxel/core/image/image.py b/src/blaxel/core/image/image.py index e9f61959..c516a2ca 100644 --- a/src/blaxel/core/image/image.py +++ b/src/blaxel/core/image/image.py @@ -786,7 +786,7 @@ def _create_zip(self, build_dir: Path) -> bytes: build_dir = build_dir.resolve() zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: - for file_path in build_dir.rglob("*"): + for file_path in sorted(build_dir.rglob("*")): if file_path.is_file(): # Resolve to handle symlinks and get the real path resolved_path = file_path.resolve() @@ -799,11 +799,22 @@ def _create_zip(self, build_dir: Path) -> bytes: f"Path traversal detected: {file_path} resolves outside build directory" ) - arcname = file_path.relative_to(build_dir) - zf.write(resolved_path, arcname) + # ZIP headers normally include source mtimes and traversal order, + # making byte-identical contexts produce different archives. Pin + # those fields so the archive checksum is a stable content key. + arcname = file_path.relative_to(build_dir).as_posix() + info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (resolved_path.stat().st_mode & 0xFFFF) << 16 + with resolved_path.open("rb") as source, zf.open(info, "w") as target: + shutil.copyfileobj(source, target, length=1024 * 1024) zip_buffer.seek(0) return zip_buffer.getvalue() + @staticmethod + def _context_hash(zip_content: bytes) -> str: + return f"sha256-{hashlib.sha256(zip_content).hexdigest()}" + def _create_sandbox_payload(self, name: str, memory: int = 4096) -> Sandbox: """ Create the sandbox payload for deployment. @@ -829,7 +840,7 @@ def _create_sandbox_payload(self, name: str, memory: int = 4096) -> Sandbox: return Sandbox(metadata=metadata, spec=spec) def _create_sandbox_with_upload_sync( - self, sandbox: Sandbox + self, sandbox: Sandbox, context_hash: str | None = None ) -> tuple[Response[Sandbox], str | None]: """ Create or update a sandbox with the upload query parameter. @@ -842,6 +853,9 @@ def _create_sandbox_with_upload_sync( """ name = sandbox.metadata.name if sandbox.metadata else "" body = sandbox.to_dict() + params = {"upload": "true"} + if context_hash: + params["contextHash"] = context_hash # Try PUT first (update), fall back to POST (create) http_client = client.get_httpx_client() @@ -851,7 +865,7 @@ def _create_sandbox_with_upload_sync( method="put", url=f"/sandboxes/{name}", json=body, - params={"upload": "true"}, + params=params, headers={"Content-Type": "application/json"}, ) @@ -861,7 +875,7 @@ def _create_sandbox_with_upload_sync( method="post", url="/sandboxes", json=body, - params={"upload": "true"}, + params=params, headers={"Content-Type": "application/json"}, ) @@ -887,7 +901,7 @@ def _create_sandbox_with_upload_sync( return result, upload_url async def _create_sandbox_with_upload( - self, sandbox: Sandbox + self, sandbox: Sandbox, context_hash: str | None = None ) -> tuple[Response[Sandbox], str | None]: """ Create or update a sandbox with the upload query parameter (async). @@ -900,6 +914,9 @@ async def _create_sandbox_with_upload( """ name = sandbox.metadata.name if sandbox.metadata else "" body = sandbox.to_dict() + params = {"upload": "true"} + if context_hash: + params["contextHash"] = context_hash # Try PUT first (update), fall back to POST (create) http_client = client.get_async_httpx_client() @@ -909,7 +926,7 @@ async def _create_sandbox_with_upload( method="put", url=f"/sandboxes/{name}", json=body, - params={"upload": "true"}, + params=params, headers={"Content-Type": "application/json"}, ) @@ -919,7 +936,7 @@ async def _create_sandbox_with_upload( method="post", url="/sandboxes", json=body, - params={"upload": "true"}, + params=params, headers={"Content-Type": "application/json"}, ) @@ -1175,12 +1192,15 @@ def build_sync( try: # Create zip zip_content = self._create_zip(build_dir) + context_hash = self._context_hash(zip_content) # Create sandbox payload sandbox_payload = self._create_sandbox_payload(name, memory) # Create/update sandbox and get upload URL - response, upload_url = self._create_sandbox_with_upload_sync(sandbox_payload) + response, upload_url = self._create_sandbox_with_upload_sync( + sandbox_payload, context_hash + ) if response.status_code.value >= 400: raise RuntimeError( @@ -1258,12 +1278,15 @@ async def build( try: # Create zip (sync, as it's file I/O) zip_content = self._create_zip(build_dir) + context_hash = self._context_hash(zip_content) # Create sandbox payload sandbox_payload = self._create_sandbox_payload(name, memory) # Create/update sandbox and get upload URL - response, upload_url = await self._create_sandbox_with_upload(sandbox_payload) + response, upload_url = await self._create_sandbox_with_upload( + sandbox_payload, context_hash + ) if response.status_code.value >= 400: raise RuntimeError( diff --git a/tests/core/test_image.py b/tests/core/test_image.py index 963288f0..1f360c1e 100644 --- a/tests/core/test_image.py +++ b/tests/core/test_image.py @@ -1,15 +1,19 @@ """Tests for Image builder functionality.""" +import importlib import json import os import shutil import tempfile from pathlib import Path +from types import SimpleNamespace import pytest from blaxel.core.image import ImageBuildContext, ImageInstance, LocalFile +image_module = importlib.import_module("blaxel.core.image.image") + @pytest.fixture def temp_dir(): @@ -1256,3 +1260,52 @@ def test_sandbox_api_added_at_end_of_dockerfile(self): assert entrypoint_idx > run_idx # Entrypoint should be last assert entrypoint_idx > copy_idx + + +def test_build_archive_and_context_hash_are_content_stable(temp_dir): + first = temp_dir / "first" + second = temp_dir / "second" + first.mkdir() + second.mkdir() + for directory in (first, second): + (directory / "b.txt").write_text("second") + (directory / "a.txt").write_text("first") + + os.utime(first / "a.txt", (1_700_000_000, 1_700_000_000)) + os.utime(second / "a.txt", (1_800_000_000, 1_800_000_000)) + + image = ImageInstance.from_registry("python:3.11") + first_zip = image._create_zip(first) + second_zip = image._create_zip(second) + + assert first_zip == second_zip + assert image._context_hash(first_zip) == image._context_hash(second_zip) + assert image._context_hash(first_zip).startswith("sha256-") + assert len(image._context_hash(first_zip)) == 71 + + +def test_upload_request_carries_context_hash(monkeypatch): + calls = [] + + class FakeClient: + def request(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace( + status_code=200, + headers={"x-blaxel-upload-url": "https://upload.example"}, + content=b"{}", + json=lambda: {}, + ) + + monkeypatch.setattr(type(image_module.client), "get_httpx_client", lambda _self: FakeClient()) + image = ImageInstance.from_registry("python:3.11") + sandbox = image._create_sandbox_payload("cached") + context_hash = f"sha256-{'a' * 64}" + + _, upload_url = image._create_sandbox_with_upload_sync(sandbox, context_hash) + + assert upload_url == "https://upload.example" + assert calls[0]["params"] == { + "upload": "true", + "contextHash": context_hash, + }