Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/v6/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ hud serve env.py -p 9000

Build **and** publish to HUD infra in one step. The environment's name comes
from the `Environment(...)` declaration in code; deploying the same name again
rebuilds that environment.
targets that environment. The CLI creates one canonical manifest for the exact
files placed in the build-context archive, and the platform verifies the
uploaded archive against it. An identical in-flight deploy joins the existing
build; an identical completed deploy reuses its artifact while every immutable
component still exists. The upload currently happens before this reuse check.

```bash
hud deploy
Expand All @@ -57,6 +61,7 @@ hud deploy
| `--all`, `-a` | Deploy all environments in the directory. |
| `--env`, `-e` | Env var `KEY=VALUE` (repeatable). |
| `--env-file` | Path to a `.env` file. |
| `--no-cache` | Force a new build version and bypass both whole-build reuse and persistent build cache inputs. |

## Evaluate

Expand Down
117 changes: 117 additions & 0 deletions hud/build_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Canonical filesystem identity for HUD build contexts."""

from __future__ import annotations

import hashlib
import json
import stat
from typing import TYPE_CHECKING, Literal, Self

from pydantic import BaseModel, ConfigDict, Field, model_validator

if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path


BUILD_CONTEXT_MANIFEST_VERSION = 1


class BuildContextEntry(BaseModel):
"""One build-semantic filesystem entry."""

model_config = ConfigDict(frozen=True, extra="forbid")

path: str = Field(min_length=1)
type: Literal["file", "symlink"]
mode: int = Field(ge=0, le=0o7777)
size: int | None = Field(default=None, ge=0)
content_digest: str | None = None
target: str | None = None

@model_validator(mode="after")
def _validate_shape(self) -> Self:
if (
self.path.startswith(("/", "\\"))
or "\\" in self.path
or any(part in {"", ".", ".."} for part in self.path.split("/"))
):
raise ValueError(f"Build context path must be relative and normalized: {self.path!r}")
if self.type == "file":
if self.size is None or self.content_digest is None or self.target is not None:
raise ValueError("File entries require size and content_digest only")
if len(self.content_digest) != 64 or any(
character not in "0123456789abcdef" for character in self.content_digest
):
raise ValueError("File content_digest must be a lowercase SHA-256 digest")
elif self.target is None or self.size is not None or self.content_digest is not None:
raise ValueError("Symlink entries require target only")
return self


class BuildContextManifest(BaseModel):
"""Canonical manifest shared by build-context archiving and identity."""

model_config = ConfigDict(frozen=True, extra="forbid")

version: Literal[1] = BUILD_CONTEXT_MANIFEST_VERSION
entries: tuple[BuildContextEntry, ...]

@model_validator(mode="after")
def _validate_order(self) -> Self:
paths = [entry.path for entry in self.entries]
if paths != sorted(paths) or len(paths) != len(set(paths)):
raise ValueError("Build context entries must have unique paths in sorted order")
return self

@classmethod
def from_paths(cls, root: Path, paths: Iterable[Path]) -> Self:
resolved_root = root.resolve()
entries = tuple(
_entry_from_path(resolved_root, path)
for path in sorted(paths, key=lambda path: path.relative_to(resolved_root).as_posix())
)
return cls(entries=entries)

@classmethod
def from_directory(cls, root: Path) -> Self:
resolved_root = root.resolve()
return cls.from_paths(
resolved_root,
(path for path in resolved_root.rglob("*") if path.is_symlink() or path.is_file()),
)

def digest(self) -> str:
payload = json.dumps(
self.model_dump(mode="json"),
sort_keys=True,
separators=(",", ":"),
).encode()
return hashlib.sha256(payload).hexdigest()


def _entry_from_path(root: Path, path: Path) -> BuildContextEntry:
relative_path = path.relative_to(root).as_posix()
metadata = path.lstat()
mode = stat.S_IMODE(metadata.st_mode)
if path.is_symlink():
return BuildContextEntry(
path=relative_path,
type="symlink",
mode=mode,
target=path.readlink().as_posix(),
)
if not stat.S_ISREG(metadata.st_mode):
raise ValueError(f"Unsupported build context entry: {relative_path}")

digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return BuildContextEntry(
path=relative_path,
type="file",
mode=mode,
size=metadata.st_size,
content_digest=digest.hexdigest(),
)
33 changes: 23 additions & 10 deletions hud/cli/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast

import httpx
import typer
Expand All @@ -18,7 +18,7 @@
from hud.cli.utils.build_display import display_build_summary
from hud.cli.utils.build_logs import poll_build_status, stream_build_logs
from hud.cli.utils.config import parse_env_file, parse_key_value
from hud.cli.utils.context import create_build_context_tarball, format_size
from hud.cli.utils.context import BuildContextArchive, create_build_context_tarball, format_size
from hud.cli.utils.registry import get_registry_environment
from hud.cli.utils.source import EnvironmentSource
from hud.eval.runtime import ComposeProject, RuntimeConfig
Expand All @@ -27,6 +27,9 @@
from hud.utils.naming import normalize_environment_name
from hud.utils.platform import PlatformClient

if TYPE_CHECKING:
from hud.build_context import BuildContextManifest

LOGGER = logging.getLogger(__name__)
_VALID_RUNTIMES = {"hud", "modal"}
_COMPOSE_RECIPE_NAMES = (
Expand Down Expand Up @@ -349,10 +352,15 @@ def _collect_build_secrets(
return secrets


def _create_tarball(env_dir: Path, *, verbose: bool, console: HUDConsole) -> Path:
def _create_tarball(
env_dir: Path,
*,
verbose: bool,
console: HUDConsole,
) -> BuildContextArchive:
console.progress_message("Creating build context tarball...")
try:
tarball_path, tarball_size, file_count, tarball_duration = create_build_context_tarball(
archive = create_build_context_tarball(
env_dir,
verbose=verbose,
)
Expand All @@ -361,10 +369,10 @@ def _create_tarball(env_dir: Path, *, verbose: bool, console: HUDConsole) -> Pat
raise typer.Exit(1) from e

console.success(
f"Created tarball: {format_size(tarball_size)} ({file_count} files) "
f"[{tarball_duration:.1f}s]"
f"Created tarball: {format_size(archive.size_bytes)} ({archive.file_count} files) "
f"[{archive.duration_seconds:.1f}s]"
)
return tarball_path
return archive


def _prepare_deploy_plan(
Expand Down Expand Up @@ -499,11 +507,12 @@ def deploy_environment(
platform=platform,
console=hud_console,
)
tarball_path = _create_tarball(env_dir, verbose=verbose, console=hud_console)
archive = _create_tarball(env_dir, verbose=verbose, console=hud_console)
try:
result = asyncio.run(
_deploy_async(
tarball_path=tarball_path,
tarball_path=archive.path,
context_manifest=archive.manifest,
no_cache=no_cache,
plan=plan,
platform=platform,
Expand All @@ -512,7 +521,7 @@ def deploy_environment(
)
)
finally:
tarball_path.unlink(missing_ok=True)
archive.path.unlink(missing_ok=True)

if not result.success:
raise typer.Exit(1)
Expand Down Expand Up @@ -541,6 +550,7 @@ async def _trigger_build(
platform: PlatformClient,
*,
build_id: str,
context_manifest: BuildContextManifest,
plan: _DeployPlan,
no_cache: bool,
) -> tuple[str, str]:
Expand All @@ -549,6 +559,7 @@ async def _trigger_build(
"build_id": build_id,
"name": plan.name,
"no_cache": no_cache,
"context_manifest": context_manifest.model_dump(mode="json"),
}
payload.update(
{
Expand All @@ -575,6 +586,7 @@ async def _trigger_build(

async def _deploy_async(
tarball_path: Path,
context_manifest: BuildContextManifest,
no_cache: bool,
plan: _DeployPlan,
platform: PlatformClient,
Expand Down Expand Up @@ -619,6 +631,7 @@ async def _deploy_async(
build_id, registry_id = await _trigger_build(
platform,
build_id=reserved_id,
context_manifest=context_manifest,
plan=plan,
no_cache=no_cache,
)
Expand Down
50 changes: 50 additions & 0 deletions hud/cli/tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,52 @@ def test_normalize_runtime_rejects_internal_provider_name() -> None:
_normalize_runtime("ec2", HUDConsole())


@pytest.mark.asyncio
async def test_trigger_sends_canonical_context_manifest() -> None:
from hud.build_context import BuildContextEntry, BuildContextManifest
from hud.cli.deploy import _DeployPlan, _trigger_build

manifest = BuildContextManifest(
entries=(
BuildContextEntry(
path="Dockerfile",
type="file",
mode=0o644,
size=12,
content_digest="a" * 64,
),
),
)
plan = _DeployPlan(
name="test-env",
registry_id=None,
runtime=None,
runtime_config=None,
env_vars={},
build_args={},
build_secrets={},
)
platform = PlatformClient("https://api.example", "key")

with patch.object(
PlatformClient,
"apost",
AsyncMock(return_value={"id": "existing-build", "registry_id": "registry"}),
) as post:
result = await _trigger_build(
platform,
build_id="reserved-upload",
context_manifest=manifest,
plan=plan,
no_cache=False,
)

assert result == ("existing-build", "registry")
call = post.await_args
assert call is not None
assert call.kwargs["json"]["context_manifest"] == manifest.model_dump(mode="json")


class TestResolveEnvironmentName:
"""Tests for code-authoritative environment name resolution."""

Expand Down Expand Up @@ -435,6 +481,7 @@ class TestDeployAsync:
@pytest.mark.asyncio
async def test_upload_url_failure(self) -> None:
"""Test handling of upload URL failure."""
from hud.build_context import BuildContextManifest
from hud.cli.deploy import _deploy_async, _DeployPlan
from hud.utils.exceptions import HudRequestError
from hud.utils.hud_console import HUDConsole
Expand All @@ -446,6 +493,7 @@ async def test_upload_url_failure(self) -> None:
with patch("hud.utils.platform.make_request", AsyncMock(side_effect=error)):
result = await _deploy_async(
tarball_path=Path("test.tar.gz"),
context_manifest=BuildContextManifest(entries=()),
no_cache=False,
plan=_DeployPlan(
name="test-env",
Expand All @@ -465,6 +513,7 @@ async def test_upload_url_failure(self) -> None:
@pytest.mark.asyncio
async def test_upload_url_network_error(self) -> None:
"""Test handling of network error during upload URL fetch."""
from hud.build_context import BuildContextManifest
from hud.cli.deploy import _deploy_async, _DeployPlan
from hud.utils.hud_console import HUDConsole
from hud.utils.platform import PlatformClient
Expand All @@ -477,6 +526,7 @@ async def test_upload_url_network_error(self) -> None:
):
result = await _deploy_async(
tarball_path=Path("test.tar.gz"),
context_manifest=BuildContextManifest(entries=()),
no_cache=False,
plan=_DeployPlan(
name="test-env",
Expand Down
Loading
Loading