-
Notifications
You must be signed in to change notification settings - Fork 2
ENG-4847: send stable image build context hashes #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Context hash still follows file mtimesMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 8aaa496. Configure here. |
||
|
|
||
| # 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( | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Large files lose ZIP64 sizing
Low Severity
Manual
ZipInfoentries leavefile_sizeat 0 beforeZipFile.open(..., "w"). Unlike the previouszf.write()path, ZIP64 is not selected up front, so a context file larger than 4 GiB can fail when the zip member is closed.Reviewed by Cursor Bugbot for commit 8aaa496. Configure here.