mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-22 09:35:07 +02:00
feat: implement remote build support and archive creation for deployments
- Added a new `create_archive` function to generate a tarball of the project source, including handling of .dockerignore patterns. - Enhanced the `deploy` function to support remote builds, allowing deployment without local Docker availability. - Introduced a `--remote` option in the CLI to control remote build behavior. - Updated the HostBackendClient with methods for requesting upload URLs and updating deployments with source tarballs.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""Create a tarball of project source for remote builds."""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import click
|
||||
|
||||
|
||||
_WARN_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
_MAX_SIZE = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
|
||||
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
||||
"""Strip symlinks, hardlinks, and traversal paths from archive."""
|
||||
if tarinfo.issym() or tarinfo.islnk():
|
||||
return None
|
||||
if ".." in tarinfo.name.split("/"):
|
||||
return None
|
||||
return tarinfo
|
||||
|
||||
|
||||
def _read_ignore_patterns(context_dir: pathlib.Path) -> list[str]:
|
||||
"""Read .dockerignore patterns if present."""
|
||||
dockerignore = context_dir / ".dockerignore"
|
||||
if dockerignore.is_file():
|
||||
patterns = []
|
||||
for line in dockerignore.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
patterns.append(line)
|
||||
return patterns
|
||||
return []
|
||||
|
||||
|
||||
def _should_ignore(rel_path: str, patterns: list[str]) -> bool:
|
||||
"""Check if a relative path matches any dockerignore pattern."""
|
||||
import fnmatch
|
||||
|
||||
rel_path = rel_path.replace(os.sep, "/")
|
||||
|
||||
for pattern in patterns:
|
||||
negate = pattern.startswith("!")
|
||||
if negate:
|
||||
pattern = pattern[1:]
|
||||
|
||||
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(
|
||||
rel_path, f"**/{pattern}"
|
||||
):
|
||||
if negate:
|
||||
return False
|
||||
return True
|
||||
|
||||
parts = rel_path.split("/")
|
||||
for i in range(len(parts)):
|
||||
partial = "/".join(parts[: i + 1])
|
||||
if fnmatch.fnmatch(partial, pattern):
|
||||
if negate:
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_archive(
|
||||
config_path: pathlib.Path,
|
||||
) -> tuple[str, int]:
|
||||
"""Create a .tar.gz archive of the project source.
|
||||
|
||||
Returns (archive_path, file_size).
|
||||
The archive root is config.parent (the directory containing langgraph.json).
|
||||
"""
|
||||
context_dir = config_path.parent.resolve()
|
||||
config_filename = config_path.name
|
||||
ignore_patterns = _read_ignore_patterns(context_dir)
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="langgraph-deploy-")
|
||||
archive_path = os.path.join(tmp_dir, "source.tar.gz")
|
||||
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
for root, dirs, files in os.walk(context_dir):
|
||||
rel_root = os.path.relpath(root, context_dir)
|
||||
if rel_root == ".":
|
||||
rel_root = ""
|
||||
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not _should_ignore(
|
||||
os.path.join(rel_root, d) if rel_root else d, ignore_patterns
|
||||
)
|
||||
]
|
||||
|
||||
for f in files:
|
||||
rel_path = os.path.join(rel_root, f) if rel_root else f
|
||||
if _should_ignore(rel_path, ignore_patterns):
|
||||
continue
|
||||
full_path = os.path.join(root, f)
|
||||
arcname = rel_path.replace(os.sep, "/")
|
||||
info = tar.gettarinfo(full_path, arcname=arcname)
|
||||
filtered = _tar_filter(info)
|
||||
if filtered is None:
|
||||
continue
|
||||
with open(full_path, "rb") as fobj:
|
||||
tar.addfile(filtered, fobj)
|
||||
|
||||
file_size = os.path.getsize(archive_path)
|
||||
|
||||
# Validate config file is at archive root
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
names = tar.getnames()
|
||||
if config_filename not in names:
|
||||
os.unlink(archive_path)
|
||||
raise click.ClickException(
|
||||
f"Archive validation failed: {config_filename} not found at archive root"
|
||||
)
|
||||
|
||||
if file_size > _MAX_SIZE:
|
||||
os.unlink(archive_path)
|
||||
raise click.ClickException(
|
||||
f"Source archive is {file_size / 1_048_576:.1f} MB, which exceeds the 200 MB limit. "
|
||||
"Check your .dockerignore for large files (model weights, data, node_modules, .venv)."
|
||||
)
|
||||
|
||||
if file_size > _WARN_SIZE:
|
||||
click.secho(
|
||||
f" Warning: source archive is {file_size / 1_048_576:.1f} MB. "
|
||||
"Consider adding large files to .dockerignore.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
return archive_path, file_size
|
||||
Reference in New Issue
Block a user