Files
langgraph/libs/cli/langgraph_cli/archive.py
T
David Asamu 0c850a5baa 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.
2026-03-11 16:08:13 +00:00

134 lines
4.1 KiB
Python

"""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