From 7e02540504f713a90500e11be0e7f1f69b26bcce Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 26 Sep 2026 12:00:56 -0700 Subject: [PATCH] Fix remote build context identity and preserve hard-linked files --- README.md | 36 ++ hyperbrowser/build_context.py | 8 + .../client/managers/async_manager/sandbox.py | 6 + .../client/managers/sandboxes/image_build.py | 240 ++++++++-- .../client/managers/sync_manager/sandbox.py | 6 + hyperbrowser/models/sandbox.py | 1 + tests/test_build_context_fingerprint.py | 428 ++++++++++++++++++ tests/typecheck/invalid_requests.py | 1 + tests/typecheck/valid_requests.py | 3 + 9 files changed, 686 insertions(+), 43 deletions(-) create mode 100644 hyperbrowser/build_context.py create mode 100644 tests/test_build_context_fingerprint.py diff --git a/README.md b/README.md index 01f3f07a..00134565 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,42 @@ for event in connection.events(): print(event) ``` +### Cache remote Dockerfile builds + +Use the public context fingerprint when deriving a cache name. It uses the same +Dockerfile source selection and `.dockerignore` rules as remote packaging, +including file contents, modes, paths, and symlinks. It ignores timestamps and +does not compress or stage the context on disk. + +```python +from hyperbrowser.build_context import docker_build_context_fingerprint + +fingerprint = docker_build_context_fingerprint("./app") +# Include build options such as platform and image_init in your cache key too. +image_name = f"app-{fingerprint[:32]}" +build = client.sandboxes.build_image_from_dockerfile( + context_path="./app", + image_name=image_name, + expected_context_fingerprint=fingerprint, +) +``` + +If the archived inputs differ from the fingerprint, the SDK raises +`DockerBuildContextChangedError` before creating or uploading a build. Compute a +fresh fingerprint and repeat the lookup/build operation. Use the same `dockerfile` +and `force_full_context` selection when fingerprinting and building (the latter +is named `remote_full_context` on the build method). The expected fingerprint is +supported only for remote builds. + +Fingerprinting streams included files and performs blocking I/O. Async callers +should use `await asyncio.to_thread(docker_build_context_fingerprint, "./app")` +(Python 3.9+) or an executor. It does not resolve mutable base-image tags or +network resources fetched by a Dockerfile; rebuild explicitly when those change. + +Image listings distinguish `ready` from `uploaded`: a completed team image can +be ready to launch before its durability backup is uploaded. `ready` is `None` +when talking to an older server. Keep the returned image ID to pin that revision. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/hyperbrowser/build_context.py b/hyperbrowser/build_context.py new file mode 100644 index 00000000..a3b55e20 --- /dev/null +++ b/hyperbrowser/build_context.py @@ -0,0 +1,8 @@ +"""Public helpers for identifying inputs to remote Dockerfile builds.""" + +from .client.managers.sandboxes.image_build import ( + DockerBuildContextChangedError, + docker_build_context_fingerprint, +) + +__all__ = ["DockerBuildContextChangedError", "docker_build_context_fingerprint"] diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index fa494461..d2669f4e 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -725,6 +725,7 @@ async def _build_image_from_remote_dockerfile( dockerfile, platform: str, remote_full_context: bool, + expected_context_fingerprint: Optional[str], image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]], image_config_user: Optional[str], builder_cpus: Optional[int], @@ -741,6 +742,7 @@ async def _build_image_from_remote_dockerfile( context_path, dockerfile=dockerfile, force_full_context=remote_full_context, + expected_context_fingerprint=expected_context_fingerprint, temp_dir=temp_dir, ) build_id = None @@ -803,6 +805,7 @@ async def build_image_from_dockerfile( dockerfile="Dockerfile", remote: bool = True, remote_full_context: bool = False, + expected_context_fingerprint: Optional[str] = None, docker_tag: Optional[str] = None, platform: str = IMAGE_BUILD_SOURCE_PLATFORM, build_args: Optional[Dict[str, str]] = None, @@ -829,6 +832,7 @@ async def build_image_from_dockerfile( dockerfile=dockerfile, platform=platform, remote_full_context=remote_full_context, + expected_context_fingerprint=expected_context_fingerprint, image_init=image_init, image_config_user=image_config_user, builder_cpus=builder_cpus, @@ -840,6 +844,8 @@ async def build_image_from_dockerfile( temp_dir=temp_dir, upload_timeout=upload_timeout, ) + if expected_context_fingerprint is not None: + raise ValueError("expected_context_fingerprint requires remote=True") tag = docker_tag or make_temp_docker_tag() remove_tag = docker_tag is None try: diff --git a/hyperbrowser/client/managers/sandboxes/image_build.py b/hyperbrowser/client/managers/sandboxes/image_build.py index 3382bba7..8c754e54 100644 --- a/hyperbrowser/client/managers/sandboxes/image_build.py +++ b/hyperbrowser/client/managers/sandboxes/image_build.py @@ -7,6 +7,7 @@ import posixpath import re import shutil +import stat import subprocess import tarfile import tempfile @@ -25,6 +26,7 @@ Sequence, Set, Tuple, + Union, ) import httpx @@ -99,6 +101,7 @@ class PackagedDockerBuildContext: manifest: SandboxBuildContextManifest bundles: Dict[str, DockerImageBuildArtifact] workspace: str + fingerprint: str def cleanup(self) -> None: shutil.rmtree(self.workspace, ignore_errors=True) @@ -155,6 +158,85 @@ def flush(self): return self._fileobj.flush() +class _ContextHashingReader: + def __init__(self, source, hasher): + self._source = source + self._hasher = hasher + + def read(self, size): + data = self._source.read(size) + self._hasher.update(data) + return data + + +class DockerBuildContextChangedError(ValueError): + """The packaged context no longer matches the caller's cache fingerprint.""" + + +@dataclass +class _DockerBuildContextSelection: + root: Path + dockerfile: str + mode: Literal["sparse", "full"] + fallback_reason: Optional[str] + entry_groups: List[Set[str]] + + def fingerprint(self, bundle_hashes: Sequence[str]) -> str: + # Hash entry metadata and contents, not transport encoding: cache + # identity must not depend on tar headers or a compression library. + identity = { + "version": 1, + "dockerfile": self.dockerfile, + "contextMode": self.mode, + "bundles": sorted(set(bundle_hashes)), + } + return hashlib.sha256( + json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def docker_build_context_fingerprint( + context_path: Union[str, Path], + *, + dockerfile: str = "Dockerfile", + force_full_context: bool = False, +) -> str: + """Fingerprint the effective remote context without compressing or staging it. + + Uses the same Dockerfile source selection, ignore rules, and normalized tar + entries as remote packaging. Reads file contents with bounded memory. This + is blocking I/O; async callers should run it in an executor. Pass the result + as ``expected_context_fingerprint`` when building to detect context changes. + Build options outside the context (e.g. platform or image_init) must also be + included in the caller's cache key. Mutable base tags and network resources + are not resolved by this fingerprint. + """ + selection = _select_docker_build_context( + context_path, dockerfile=dockerfile, force_full_context=force_full_context + ) + hashes = [] + for entries in selection.entry_groups: + hasher = hashlib.sha256() + for relative in sorted(entries): + info = _context_entry_info(selection.root, relative) + if info is None: + continue + _hash_context_entry_metadata(hasher, info) + if info.isfile(): + with open(selection.root / relative, "rb") as source: + reader = _ContextHashingReader(source, hasher) + remaining = info.size + while remaining: + data = reader.read(min(64 * 1024, remaining)) + if not data: + raise OSError( + f'build context file "{relative}" was truncated' + ) + remaining -= len(data) + hashes.append(hasher.hexdigest()) + return selection.fingerprint(hashes) + + def build_docker_image_from_dockerfile( *, context_path, @@ -188,13 +270,12 @@ def build_docker_image_from_dockerfile( _run_command(args) -def package_docker_build_context_manifest( +def _select_docker_build_context( context_path, *, dockerfile="Dockerfile", force_full_context: bool = False, - temp_dir: Optional[str] = None, -) -> PackagedDockerBuildContext: +) -> _DockerBuildContextSelection: context_root = Path(context_path).expanduser().resolve(strict=True) if not context_root.is_dir(): raise ValueError("Docker build context must be a directory") @@ -288,28 +369,59 @@ def package_docker_build_context_manifest( ] entry_groups = _remove_subsumed_entry_groups(entry_groups) + return _DockerBuildContextSelection( + context_root, dockerfile_relative, context_mode, fallback_reason, entry_groups + ) + + +def package_docker_build_context_manifest( + context_path, + *, + dockerfile="Dockerfile", + force_full_context: bool = False, + temp_dir: Optional[str] = None, + expected_context_fingerprint: Optional[str] = None, +) -> PackagedDockerBuildContext: + if expected_context_fingerprint is not None and not _SHA256_PATTERN.fullmatch( + expected_context_fingerprint + ): + raise ValueError("expected_context_fingerprint must be a SHA-256 hex digest") + selection = _select_docker_build_context( + context_path, dockerfile=dockerfile, force_full_context=force_full_context + ) workspace = tempfile.mkdtemp(prefix="hb-docker-context-", dir=temp_dir) try: bundles = {} descriptors = [] - for index, entries in enumerate(entry_groups): - artifact, descriptor = _package_context_bundle( - context_root, + bundle_hashes = [] + for index, entries in enumerate(selection.entry_groups): + artifact, descriptor, bundle_hash = _package_context_bundle( + selection.root, sorted(entries), workspace, index, ) + bundle_hashes.append(bundle_hash) if descriptor.sha256 in bundles: artifact.cleanup() continue bundles[descriptor.sha256] = artifact descriptors.append(descriptor) descriptors.sort(key=lambda item: item.sha256) + fingerprint = selection.fingerprint(bundle_hashes) + if ( + expected_context_fingerprint is not None + and fingerprint != expected_context_fingerprint + ): + raise DockerBuildContextChangedError( + "Docker build context changed after its cache fingerprint was " + "computed. Retry the build with a fresh fingerprint." + ) manifest = SandboxBuildContextManifest( version=1, - dockerfile_path=dockerfile_relative, - context_mode=context_mode, - fallback_reason=fallback_reason or None, + dockerfile_path=selection.dockerfile, + context_mode=selection.mode, + fallback_reason=selection.fallback_reason or None, bundles=descriptors, ) manifest_bytes = _canonical_model_json(manifest) @@ -324,6 +436,7 @@ def package_docker_build_context_manifest( manifest=manifest, bundles=bundles, workspace=workspace, + fingerprint=fingerprint, ) except Exception: shutil.rmtree(workspace, ignore_errors=True) @@ -1192,11 +1305,10 @@ def _package_context_bundle( entries: Sequence[str], workspace: str, index: int, -) -> Tuple[DockerImageBuildArtifact, SandboxBuildContextBundle]: +) -> Tuple[DockerImageBuildArtifact, SandboxBuildContextBundle, str]: bundle_path = os.path.join(workspace, f"bundle-{index:04d}.tar.gz") hasher = hashlib.sha256() - uncompressed_size = 0 - entry_count = 0 + context_hasher = hashlib.sha256() with open(bundle_path, "wb") as destination: writer = _HashingCountingWriter(destination, hasher) with gzip.GzipFile( @@ -1206,36 +1318,12 @@ def _package_context_bundle( compresslevel=1, mtime=0, ) as compressed: - with tarfile.open( - fileobj=compressed, - mode="w", - format=tarfile.PAX_FORMAT, - ) as archive: - for relative in entries: - _validate_archive_relative_path(relative) - absolute = context_root / relative - info = archive.gettarinfo(str(absolute), arcname=relative) - if not (info.isfile() or info.isdir() or info.issym()): - continue - info.uid = 0 - info.gid = 0 - info.uname = "" - info.gname = "" - info.mtime = 0 - info.pax_headers = {} - if info.isdir() and not info.name.endswith("/"): - info.name += "/" - if info.issym() and not info.linkname: - raise ValueError( - f'build context symlink "{relative}" has an invalid target' - ) - if info.isfile(): - with open(absolute, "rb") as source: - archive.addfile(info, source) - uncompressed_size += info.size - else: - archive.addfile(info) - entry_count += 1 + uncompressed_size, entry_count = _write_context_archive( + context_root, + entries, + compressed, + context_hasher, + ) size_bytes = os.path.getsize(bundle_path) sha256_hex = hasher.hexdigest() artifact = DockerImageBuildArtifact( @@ -1250,7 +1338,73 @@ def _package_context_bundle( uncompressed_size_bytes=uncompressed_size, entry_count=entry_count, ) - return artifact, descriptor + return artifact, descriptor, context_hasher.hexdigest() + + +def _context_entry_info(context_root: Path, relative: str) -> Optional[tarfile.TarInfo]: + _validate_archive_relative_path(relative) + absolute = context_root / relative + metadata = absolute.lstat() + # Construct normalized metadata directly. gettarinfo() resolves owner names + # that we discard and converts repeated host inodes to hard-link entries. + # Every regular path must instead be present with its own contents; preserve + # symlinks without following them. + info = tarfile.TarInfo(relative) + info.mode = stat.S_IMODE(metadata.st_mode) + if stat.S_ISREG(metadata.st_mode): + info.type = tarfile.REGTYPE + info.size = metadata.st_size + elif stat.S_ISDIR(metadata.st_mode): + info.type = tarfile.DIRTYPE + info.name += "/" + elif stat.S_ISLNK(metadata.st_mode): + info.type = tarfile.SYMTYPE + info.linkname = os.readlink(absolute) + if not info.linkname: + raise ValueError( + f'build context symlink "{relative}" has an invalid target' + ) + else: + return None + return info + + +def _hash_context_entry_metadata(hasher, info: tarfile.TarInfo) -> None: + metadata = json.dumps( + [ + info.name.rstrip("/") if info.isdir() else info.name, + "file" if info.isfile() else "directory" if info.isdir() else "symlink", + info.mode, + info.size, + info.linkname, + ], + separators=(",", ":"), + ).encode() + hasher.update(len(metadata).to_bytes(8, "big")) + hasher.update(metadata) + + +def _write_context_archive( + context_root: Path, entries: Sequence[str], destination, hasher +): + uncompressed_size = 0 + entry_count = 0 + with tarfile.open( + fileobj=destination, mode="w", format=tarfile.PAX_FORMAT + ) as archive: + for relative in entries: + info = _context_entry_info(context_root, relative) + if info is None: + continue + _hash_context_entry_metadata(hasher, info) + if info.isfile(): + with open(context_root / relative, "rb") as source: + archive.addfile(info, _ContextHashingReader(source, hasher)) + uncompressed_size += info.size + else: + archive.addfile(info) + entry_count += 1 + return uncompressed_size, entry_count def _normalize_docker_save_entry_name(raw: str) -> str: diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index b78d7111..a334727d 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -711,6 +711,7 @@ def _build_image_from_remote_dockerfile( dockerfile, platform: str, remote_full_context: bool, + expected_context_fingerprint: Optional[str], image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]], image_config_user: Optional[str], builder_cpus: Optional[int], @@ -726,6 +727,7 @@ def _build_image_from_remote_dockerfile( context_path, dockerfile=dockerfile, force_full_context=remote_full_context, + expected_context_fingerprint=expected_context_fingerprint, temp_dir=temp_dir, ) build_id = None @@ -787,6 +789,7 @@ def build_image_from_dockerfile( dockerfile="Dockerfile", remote: bool = True, remote_full_context: bool = False, + expected_context_fingerprint: Optional[str] = None, docker_tag: Optional[str] = None, platform: str = IMAGE_BUILD_SOURCE_PLATFORM, build_args: Optional[Dict[str, str]] = None, @@ -813,6 +816,7 @@ def build_image_from_dockerfile( dockerfile=dockerfile, platform=platform, remote_full_context=remote_full_context, + expected_context_fingerprint=expected_context_fingerprint, image_init=image_init, image_config_user=image_config_user, builder_cpus=builder_cpus, @@ -824,6 +828,8 @@ def build_image_from_dockerfile( temp_dir=temp_dir, upload_timeout=upload_timeout, ) + if expected_context_fingerprint is not None: + raise ValueError("expected_context_fingerprint requires remote=True") tag = docker_tag or make_temp_docker_tag() remove_tag = docker_tag is None try: diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index 89d0c5ff..394fed35 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -306,6 +306,7 @@ class SandboxImageSummary(SandboxBaseModel): source: Optional[str] = None image_init: Optional[SandboxImageInit] = Field(default=None, alias="imageInit") uploaded: bool + ready: Optional[bool] = None created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") diff --git a/tests/test_build_context_fingerprint.py b/tests/test_build_context_fingerprint.py new file mode 100644 index 00000000..030d0dea --- /dev/null +++ b/tests/test_build_context_fingerprint.py @@ -0,0 +1,428 @@ +import asyncio +import gzip +import hashlib +import json +import os +import shutil +import tarfile +from pathlib import Path + +import httpx +import pytest + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.build_context import ( + DockerBuildContextChangedError, + docker_build_context_fingerprint, +) +from hyperbrowser.client.managers.sandboxes import image_build +from hyperbrowser.models import SandboxImageSummary + + +def context(root, dockerfile="FROM scratch\nCOPY . /app\n", ignore=""): + root.mkdir(exist_ok=True) + (root / "Dockerfile").write_text(dockerfile) + (root / ".dockerignore").write_text(ignore) + (root / "app").mkdir() + (root / "app" / "main.py").write_text("print('hello')\n") + (root / "app" / "debug.log").write_text("diagnostics\n") + (root / "unused").write_text("not copied by sparse builds\n") + return root + + +def test_fingerprint_format_is_stable_across_python_versions(tmp_path): + (tmp_path / "Dockerfile").write_bytes(b"FROM scratch\nCOPY . /app\n") + (tmp_path / "Dockerfile").chmod(0o644) + folder = tmp_path / ("long-path-" + "x" * 110) + folder.mkdir() + folder.chmod(0o755) + (folder / "unicode-ü.txt").write_bytes(b"canonical\x00payload\n") + (folder / "unicode-ü.txt").chmod(0o640) + (tmp_path / "empty").write_bytes(b"") + (tmp_path / "empty").chmod(0o600) + (tmp_path / "link").symlink_to("empty") + assert docker_build_context_fingerprint(tmp_path) == ( + "8d66062b58007e23ed8844b026726ac24e4ea5b32e4d6c3e4590d48b252755f9" + ) + + +@pytest.mark.parametrize("full", [False, True]) +@pytest.mark.parametrize( + "dockerfile,ignore", + [ + ("FROM scratch\nCOPY app /app\n", ""), + ("FROM scratch\nCOPY . /app\n", "**/*.log\n"), + ("FROM scratch\nCOPY . /app\n", "app\n!app/main.py\n"), + ("FROM scratch\nCOPY app /app\nCOPY app/main.py /main\n", ""), + ("FROM scratch\nCOPY app/*.py /app/\n", "unused\n"), + ("FROM scratch\nARG SRC=app\nCOPY $SRC /app\n", ""), + ("FROM scratch\n", "*\n"), + ( + "FROM scratch AS source\nCOPY app /app\nFROM scratch\nCOPY --from=source /app /app\n", + "", + ), + ( + "FROM busybox\nRUN --mount=type=bind,source=app,target=/app cat /app/main.py\n", + "", + ), + ("FROM busybox\nRUN --mount=type=bind,source=$DIR,target=/app true\n", ""), + ("FROM scratch\nCOPY < None: client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", + expected_context_fingerprint=123, # M,P builder_memory_mib="16g", # M,P ) client.sandboxes.build_image_from_docker_image( diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index 3b41d48e..b357b622 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.build_context import docker_build_context_fingerprint from hyperbrowser.models import ( CreateSandboxImageBuildParams as LegacyCreateSandboxImageBuildParams, CreateSessionParams as LegacyCreateSessionParams, @@ -203,6 +204,7 @@ def valid_sync_requests(client: Hyperbrowser) -> None: client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", + expected_context_fingerprint=docker_build_context_fingerprint("."), builder_cpus=8, builder_memory_mib=16384, builder_scratch_mib=65536, @@ -333,6 +335,7 @@ async def valid_async_requests(client: AsyncHyperbrowser) -> None: await client.sandboxes.build_image_from_dockerfile( context_path=".", image_name="custom", + expected_context_fingerprint="a" * 64, builder_cpus=8, builder_memory_mib=16384, builder_scratch_mib=65536,