Compare commits

...
7 Commits
Author SHA1 Message Date
Will Fu-Hinthorn f9d5b0bc15 Merge branch 'main' into wfh/cli_validate 2026-04-08 05:56:49 -07:00
Will Fu-Hinthorn 998aa88ea1 update 2026-04-08 05:47:23 -07:00
Will Fu-Hinthorn 627cf19464 update 2026-04-08 05:40:59 -07:00
Will Fu-Hinthorn 8e89c8b155 chore: migration 1 2026-04-08 04:46:31 -07:00
Will Fu-Hinthorn 98fd216ce7 release 2026-04-07 18:20:20 -07:00
Will Fu-Hinthorn 247ef5edf7 chore: add validate command 2026-04-07 18:20:10 -07:00
Will Fu-Hinthorn 648f03d715 chore: validate 2026-04-07 17:56:19 -07:00
27 changed files with 11443 additions and 6 deletions
+66
View File
@@ -13,6 +13,7 @@ permissions:
env:
PYTHON_VERSION: "3.11"
GO_VERSION: "1.23"
jobs:
build:
@@ -34,6 +35,70 @@ jobs:
cache-suffix: "release"
working-directory: ${{ inputs.working-directory }}
# -- Go cross-compilation (libs/cli only) --
# When releasing the CLI, we cross-compile the Go binary for each
# platform and build platform-specific wheels that bundle it.
- name: Set up Go
if: inputs.working-directory == 'libs/cli'
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cross-compile Go binaries
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: make build-go-all
- name: Run Go tests
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: go test -count=1 -race ./...
- name: Build CLI platform wheels
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: |
# Each entry: GO_BINARY_KEY WHEEL_PLATFORM_TAG
#
# Since Go builds are statically linked (CGO_ENABLED=0), the same
# linux binary works on both glibc and musl. We produce separate
# manylinux and musllinux wheels so pip installs the right one.
TARGETS=(
# Linux glibc
"linux-amd64 manylinux_2_17_x86_64.manylinux2014_x86_64"
"linux-arm64 manylinux_2_17_aarch64.manylinux2014_aarch64"
"linux-arm manylinux_2_17_armv7l.manylinux2014_armv7l"
"linux-386 manylinux_2_17_i686.manylinux2014_i686"
"linux-ppc64le manylinux_2_17_ppc64le.manylinux2014_ppc64le"
"linux-s390x manylinux_2_17_s390x.manylinux2014_s390x"
# Linux musl (same Go binaries, different wheel tag)
"linux-amd64 musllinux_1_2_x86_64"
"linux-arm64 musllinux_1_2_aarch64"
"linux-arm musllinux_1_2_armv7l"
"linux-386 musllinux_1_2_i686"
# macOS
"darwin-amd64 macosx_11_0_x86_64"
"darwin-arm64 macosx_11_0_arm64"
# Windows
"windows-amd64 win_amd64"
"windows-arm64 win_arm64"
"windows-386 win32"
)
for entry in "${TARGETS[@]}"; do
key=$(echo "$entry" | awk '{print $1}')
plat=$(echo "$entry" | awk '{print $2}')
ext=""
if [[ "$key" == windows-* ]]; then ext=".exe"; fi
binary="langgraph_cli/bin/langgraph-${key}${ext}"
echo "Building wheel for ${key} -> ${plat}..."
LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel
done
# Also build the sdist and pure-Python fallback wheel
uv build
echo "All wheels built:"
ls -lh dist/
# -- Standard build (all other packages) --
# We want to keep this build stage *separate* from the release stage,
# so that there's no sharing of permissions between them.
# The release stage has trusted publishing and GitHub repo contents write access,
@@ -46,6 +111,7 @@ jobs:
# > from the publish job.
# https://github.com/pypa/gh-action-pypi-publish#non-goals
- name: Build project for distribution
if: inputs.working-directory != 'libs/cli'
run: uv build
working-directory: ${{ inputs.working-directory }}
+2
View File
@@ -1 +1,3 @@
.langgraph_api/
# Go cross-compiled binaries (built at release time, bundled into wheels)
langgraph_cli/bin/
+374
View File
@@ -0,0 +1,374 @@
# Go Migration Plan
## Goal
Move the full `langgraph` CLI implementation to Go while preserving existing
Python distribution and invocation flows.
Users should continue to be able to run:
- `langgraph ...`
- `uv run langgraph ...`
- `uvx langgraph ...`
During phase 1, the Go path is gated behind a feature flag. The Python package
remains the public entrypoint and launcher.
## Non-Goals
This phase does not include:
- JS migration
- `langsmith-cli` integration
- user-facing command renames
- intentional CLI behavior changes
- a long-lived dual implementation
## Source Of Truth
There will be one implementation of CLI behavior:
- shared Go implementation lives in the `langgraph` repo
- standalone Go `langgraph` binary uses that implementation
- Python `langgraph-cli` package is a thin launcher around that binary
- legacy Python implementation exists only temporarily as fallback during rollout
## Phase 1 Artifacts
Phase 1 ships these artifacts:
- shared Go package(s) in `langgraph`
- standalone `langgraph` Go binary
- Python wheel `langgraph-cli` that bundles the platform-specific Go binary
- Python launcher entrypoint that can route to legacy Python or Go
JS is explicitly out of scope for phase 1.
## User-Facing Command Scope
Phase 1 scope is the whole `langgraph` CLI, not just deploy.
Target command coverage:
- `langgraph deploy ...`
- `langgraph build ...`
- `langgraph up ...`
- `langgraph dockerfile ...`
- `langgraph dev ...`
- `langgraph new ...`
The goal is full parity with the current Python CLI command surface.
## Compatibility Contract
Behavior must not regress.
Required parity:
- exact JSON output where commands emit JSON
- exact or equivalent error semantics
- same exit codes
- same argument and flag behavior
- same generated artifacts for:
- Dockerfile output
- docker compose / inline compose output
- same API request semantics where mocked in tests
Human-readable output should be same or better, but not worse.
## Repo Ownership
Shared implementation lives in the current `langgraph` repo.
Reasons:
- current CLI spec and tests already live here
- rollout is initially only for `langgraph-cli`
- command compatibility should be driven by existing behavior in this repo
## Architecture
Use process boundaries, not language FFI.
Python should not call a Go shared library directly. Instead:
- Python launcher locates bundled `langgraph` Go binary
- Python launcher `exec`s or subprocesses into the Go binary
- Go handles all command execution
- for `dev`, Go subprocesses back into Python
This keeps the boundary simple and cross-platform.
## Go Package Structure
Recommended structure:
- `pkg/cli/config`
- parse and validate `langgraph.json`
- normalize config model
- `pkg/cli/docker`
- docker capability detection
- compose generation
- Dockerfile/build plan generation
- `pkg/cli/deploy`
- deployment flows
- host backend client
- polling, logs, revision logic
- `pkg/cli/dev`
- `dev` command orchestration
- Python subprocess handoff
- `pkg/cli/cmds`
- command runner functions with typed options/results
- no Cobra-specific code here
- `cmd/langgraph`
- standalone Go binary wrapping shared packages
Business logic should live in shared packages, not directly in CLI adapter code.
## Python Wrapper Model
The Python package remains installed as `langgraph-cli`, with entrypoint
`langgraph`.
During migration, the wrapper decides whether to route to legacy Python or Go.
Wrapper behavior:
1. inspect feature flags
2. resolve Go binary path
3. if Go path is active, `exec` into Go binary
4. otherwise fall back to legacy Python implementation
Long-term target:
- remove fallback
- Python wrapper always launches bundled Go binary
## Feature Flags
Temporary rollout env vars:
- `LANGGRAPH_USE_GO_CLI=1`
- route the Python wrapper to the Go binary instead of legacy Python
- `LANGGRAPH_GO_CLI_PATH=/path/to/langgraph`
- internal/dev/CI override for binary path resolution
- not intended as a long-term public interface
- `LANGGRAPH_CALLING_PYTHON=/path/to/python`
- set by the Python wrapper before invoking Go
- used by Go for `dev`
`LANGGRAPH_GO_CLI_PATH` is mainly for local development and CI and can be
removed later.
## `dev` Invocation Contract
`dev` is the main tricky area.
Design rule:
- Go owns CLI parsing and routing
- Python owns the actual in-process local dev server runtime
Flow for `uv run langgraph dev`:
1. `uv` selects the Python interpreter/environment
2. Python wrapper starts
3. Python wrapper sets `LANGGRAPH_CALLING_PYTHON=sys.executable`
4. Python wrapper launches Go binary
5. Go receives `dev`
6. Go shells out to that exact Python interpreter for the actual Python runtime behavior
This preserves the current selected Python environment.
Go Python resolution order for `dev`:
1. `LANGGRAPH_CALLING_PYTHON`
2. optional explicit override if added later
3. environment-derived interpreter / active venv
4. fallback detection
5. clear failure
The critical constraint is: if the user entered through Python, `dev` should
use that exact Python when possible.
## Why Not FFI
Do not use:
- cgo shared libs
- Python-Go FFI bindings
- embedded Python in Go
- RPC unless absolutely necessary
Reasons:
- packaging complexity
- cross-platform pain
- no advantage for a CLI architecture
- much worse release/debug story
Process-level boundaries are the right choice here.
## Packaging Constraints
The Go binary should be bundled inside Python wheels.
Preferred distribution model:
- build platform-specific `langgraph-cli` wheels
- each wheel includes the matching `langgraph` Go binary
- Python launcher resolves and executes the bundled binary
Do not rely on runtime download of the binary for normal operation.
Support matrix target:
- all OS/arch targets that are currently expected to be supported
- at minimum, align with the practical support matrix desired for the CLI,
using `orjson` support as a rough proxy if needed
If a platform is unsupported, fail clearly rather than silently falling back
forever.
## Release Constraints
Phase 1 versioning applies to:
- shared Go implementation
- standalone Go `langgraph` binary
- PyPI `langgraph-cli` wrapper
They should stay on one version line.
Constraint:
- bundled Go binary version must exactly match the Python wrapper version for
the migrated surface
The wrapper should detect obvious mismatch and fail clearly if it occurs.
## Migration Strategy
Use a big-bang hidden implementation change with gradual activation.
Phase 1 rollout:
1. implement full Go path behind `LANGGRAPH_USE_GO_CLI`
2. keep default behavior on legacy Python
3. run dual CI for legacy and Go-backed paths
4. dogfood with feature flag
5. flip default to Go
6. keep fallback briefly
7. remove fallback in about two weeks
This is a big internal rewrite with gradual external activation.
## CI Strategy
Dual CI is required during migration.
Run both variants:
- legacy Python implementation
- Python wrapper -> Go binary implementation
Required parity checks:
- help output
- exit code
- stdout
- stderr
- generated Dockerfile output
- generated compose output
- mocked deployment API request semantics
- validation errors / usage errors
Goal is not merely "both tests pass". Goal is "both implementations behave
identically enough to swap by default safely".
## Parity Test Philosophy
Use the current Python CLI tests as the behavioral spec.
Priority test areas:
- config validation
- compose/Dockerfile generation
- deployment flows
- error and prompt behavior
- command help / command surface
Where practical, add golden comparisons so regressions are obvious.
## Implementation Order Inside Phase 1
Even though rollout is one hidden phase, implementation should proceed in this
order:
1. wrapper contract and env contract
2. Go command scaffolding and package boundaries
3. config + docker/build/compose logic
4. deploy flows
5. remaining commands
6. `dev` subprocess orchestration
7. parity hardening in CI
This reduces risk because `dev` is the highest-uncertainty area.
## Command Ownership Constraint
All command behavior should live in Go once ported.
Do not allow:
- some flags parsed in Python and others in Go
- duplicated command logic across Python and Go
- separate behavior definitions for legacy and migrated commands
The wrapper should be thin only.
## Fallback Constraint
Fallback is temporary, not a product feature.
Policy:
- use feature flag during migration
- flip default after parity confidence
- remove legacy Python implementation roughly two weeks later
Do not normalize to permanent dual execution paths.
## Documentation Constraint
During migration, documentation should stay conservative:
- existing Python install flow remains primary
- feature flag is acceptable for internal/dogfood docs
- avoid broad external messaging about the Go implementation until default is flipped
## Open Issues To Track
These are not blockers, but they need explicit implementation decisions:
- exact bundled wheel layout for binaries
- exact list of supported OS/arch targets
- whether to expose a public `--python` override for `dev`
- whether some pretty output is allowed to improve while keeping parsed output stable
## Summary
Phase 1 plan:
- move the entire `langgraph` CLI implementation into shared Go code in the
`langgraph` repo
- ship a standalone `langgraph` Go binary
- keep `langgraph-cli` on PyPI as a thin launcher that bundles and executes
that binary
- preserve `uv run` / `uvx` behavior
- handle `dev` by passing the calling Python path through the wrapper and
having Go subprocess back into Python
- gate everything behind `LANGGRAPH_USE_GO_CLI`
- run dual CI until parity is proven
- flip default
- remove legacy fallback quickly
+84 -3
View File
@@ -1,15 +1,21 @@
.PHONY: test lint type format test-integration update-schema bump-version
.PHONY: test-go lint-go format-go
.PHONY: build-go build-go-all clean-go-bin
######################
# TESTING AND COVERAGE
######################
TEST?= "tests/unit_tests"
test:
GO_FILES=$(shell find cmd internal -type f -name '*.go' 2>/dev/null)
test: test-go
uv run pytest $(TEST)
test-integration:
uv run pytest tests/integration_tests
test-go:
[ ! -f go.mod ] || go test ./...
######################
# LINTING AND FORMATTING
######################
@@ -23,19 +29,94 @@ lint_package: PYTHON_FILES=langgraph_cli
lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests:
lint lint_diff lint_package lint_tests: lint-go
uv run ruff check .
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
lint-go:
[ -z "$(GO_FILES)" ] || test -z "$$(gofmt -l $(GO_FILES))"
type:
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff:
format format_diff: format-go
uv run ruff format $(PYTHON_FILES)
uv run ruff check --select I --fix $(PYTHON_FILES)
format-go:
[ -z "$(GO_FILES)" ] || gofmt -w $(GO_FILES)
######################
# GO BINARY CROSS-COMPILATION
######################
GO_MODULE=github.com/langchain-ai/langgraph/libs/cli
GO_BINARY=cmd/langgraph/main.go
GO_VERSION_PKG=$(GO_MODULE)/internal/version
GO_BIN_DIR=langgraph_cli/bin
GO_VERSION=$(shell grep -m 1 '^__version__' langgraph_cli/__init__.py | cut -d '"' -f 2)
GO_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GO_DATE=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GO_LDFLAGS=-s -w \
-X '$(GO_VERSION_PKG).Version=$(GO_VERSION)' \
-X '$(GO_VERSION_PKG).Commit=$(GO_COMMIT)' \
-X '$(GO_VERSION_PKG).Date=$(GO_DATE)'
# Platform matrix — covers every platform orjson ships for. Since the Go
# binary is statically linked (CGO_ENABLED=0), the same linux binary works
# on both glibc and musl. Musllinux wheels reuse the linux binary but are
# tagged differently so pip installs them on Alpine/musl systems.
GO_PLATFORMS = \
linux/amd64 \
linux/arm64 \
linux/arm \
linux/386 \
linux/ppc64le \
linux/s390x \
darwin/amd64 \
darwin/arm64 \
windows/amd64 \
windows/arm64 \
windows/386
# Build for the current platform (development).
build-go:
@mkdir -p $(GO_BIN_DIR)
go build -ldflags "$(GO_LDFLAGS)" -o $(GO_BIN_DIR)/langgraph $(GO_BINARY)
@echo "Built $(GO_BIN_DIR)/langgraph"
# Build for a single target: make build-go-target GOOS=linux GOARCH=amd64
build-go-target:
$(eval EXT=$(if $(filter windows,$(GOOS)),.exe,))
@mkdir -p $(GO_BIN_DIR)
GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \
go build -ldflags "$(GO_LDFLAGS)" \
-o $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT) $(GO_BINARY)
@echo "Built $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT)"
# Build for all platforms.
build-go-all:
@mkdir -p $(GO_BIN_DIR)
@for platform in $(GO_PLATFORMS); do \
os=$${platform%/*}; arch=$${platform#*/}; \
ext=""; \
if [ "$$os" = "windows" ]; then ext=".exe"; fi; \
echo "Building $$os/$$arch..."; \
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 \
go build -ldflags "$(GO_LDFLAGS)" \
-o $(GO_BIN_DIR)/langgraph-$$os-$$arch$$ext $(GO_BINARY) || exit 1; \
done
@echo "All platforms built in $(GO_BIN_DIR)/"
clean-go-bin:
rm -rf $(GO_BIN_DIR)
######################
# SCHEMA AND VERSIONING
######################
update-schema:
uv run python generate_schema.py
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"github.com/langchain-ai/langgraph/libs/cli/internal/root"
)
func main() {
os.Exit(root.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/langchain-ai/langgraph/libs/cli
go 1.23.0
+56
View File
@@ -0,0 +1,56 @@
"""Hatch build hook that bundles the platform-specific Go binary into the wheel.
Usage:
1. Cross-compile: make build-go-target GOOS=linux GOARCH=amd64
2. Set LANGGRAPH_GO_BINARY to the built binary path
3. Build wheel: uv build --wheel
The hook copies the binary into langgraph_cli/bin/ so the entrypoint can find it.
If LANGGRAPH_GO_BINARY is not set, the wheel is built without a binary (pure Python
fallback — fine for development and the legacy code path).
"""
from __future__ import annotations
import os
import shutil
import stat
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class GoBinaryBuildHook(BuildHookInterface):
PLUGIN_NAME = "go-binary"
def initialize(self, version: str, build_data: dict) -> None:
binary_path = os.environ.get("LANGGRAPH_GO_BINARY")
if not binary_path:
return
source = Path(binary_path)
if not source.is_file():
msg = f"LANGGRAPH_GO_BINARY points to missing file: {source}"
raise FileNotFoundError(msg)
bin_dir = Path("langgraph_cli/bin")
bin_dir.mkdir(parents=True, exist_ok=True)
# Determine output name (langgraph or langgraph.exe)
dest_name = "langgraph.exe" if source.suffix == ".exe" else "langgraph"
dest = bin_dir / dest_name
shutil.copy2(str(source), str(dest))
# Ensure executable permission
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# Tell hatch to include the binary in the wheel
build_data["shared_data"] = {}
build_data["force_include"] = {
str(dest): f"langgraph_cli/bin/{dest_name}",
}
# Set the platform tag so pip installs the right wheel
platform_tag = os.environ.get("LANGGRAPH_WHEEL_PLAT")
if platform_tag:
build_data["tag"] = f"py3-none-{platform_tag}"
+672
View File
@@ -0,0 +1,672 @@
// Package config provides validation for langgraph.json configuration files.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
MinNodeVersion = "20"
DefaultNodeVersion = "20"
MinPythonVersion = "3.11"
DefaultPythonVersion = "3.11"
DefaultImageDistro = "debian"
)
var validDistros = []string{"debian", "wolfi", "bookworm"}
var knownConfigKeys = map[string]bool{
"python_version": true,
"node_version": true,
"api_version": true,
"base_image": true,
"image_distro": true,
"pip_config_file": true,
"pip_installer": true,
"source": true,
"dependencies": true,
"dockerfile_lines": true,
"graphs": true,
"env": true,
"store": true,
"auth": true,
"encryption": true,
"http": true,
"webhooks": true,
"checkpointer": true,
"ui": true,
"ui_config": true,
"keep_pkg_tools": true,
"_INTERNAL_docker_tag": true,
"project_root": true,
"package": true,
}
var nodeExtensions = map[string]bool{
".ts": true, ".mts": true, ".cts": true,
".js": true, ".mjs": true, ".cjs": true,
}
// isNodeGraph checks whether a graph spec refers to a Node.js file.
func isNodeGraph(spec any) bool {
var filePath string
switch v := spec.(type) {
case string:
filePath = strings.SplitN(v, ":", 2)[0]
case map[string]any:
if p, _ := v["path"].(string); p != "" {
filePath = strings.SplitN(p, ":", 2)[0]
}
}
return nodeExtensions[filepath.Ext(filePath)]
}
// getSourceKind extracts source.kind from a raw config.
func getSourceKind(raw map[string]any) string {
source, ok := raw["source"]
if !ok {
return ""
}
m, ok := source.(map[string]any)
if !ok {
return ""
}
kind, _ := m["kind"].(string)
return kind
}
// getString returns the string value for key, or "" if missing/wrong type.
func getString(raw map[string]any, key string) string {
v, _ := raw[key].(string)
return v
}
// parseVersion parses "3.11" or "0.8.1" into integer parts.
func parseVersion(s string) ([]int, error) {
s = strings.SplitN(s, "-", 2)[0]
parts := strings.Split(s, ".")
result := make([]int, len(parts))
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil, fmt.Errorf("invalid version part: %s", p)
}
result[i] = n
}
return result, nil
}
// versionLessThan returns true if a < b (component-wise).
func versionLessThan(a, b []int) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] < b[i] {
return true
}
if a[i] > b[i] {
return false
}
}
return len(a) < len(b)
}
// ValidateConfig validates a raw config map and returns a normalised copy.
// Errors match the Python CLI's click.UsageError messages exactly.
func ValidateConfig(raw map[string]any) (map[string]any, error) {
// --- detect graph types ---
graphs, _ := raw["graphs"].(map[string]any)
hasNode, hasPython := false, false
for _, spec := range graphs {
if isNodeGraph(spec) {
hasNode = true
} else {
hasPython = true
}
}
// --- version defaults ---
nodeVersion := getString(raw, "node_version")
pythonVersion := getString(raw, "python_version")
if hasNode && nodeVersion == "" {
nodeVersion = DefaultNodeVersion
}
if hasPython && pythonVersion == "" {
pythonVersion = DefaultPythonVersion
}
imageDistro := getString(raw, "image_distro")
if imageDistro == "" {
imageDistro = DefaultImageDistro
}
// --- mutual exclusion: _INTERNAL_docker_tag vs api_version ---
_, hasInternalTag := raw["_INTERNAL_docker_tag"]
_, hasAPIVersion := raw["api_version"]
if hasInternalTag && hasAPIVersion {
return nil, fmt.Errorf("Cannot specify both _INTERNAL_docker_tag and api_version.")
}
// --- api_version format ---
if apiVersion := getString(raw, "api_version"); apiVersion != "" {
base := strings.SplitN(apiVersion, "-", 2)[0]
parts := strings.Split(base, ".")
if len(parts) > 3 {
return nil, fmt.Errorf("Version must be major or major.minor or major.minor.patch.")
}
for _, p := range parts {
if _, err := strconv.Atoi(p); err != nil {
return nil, fmt.Errorf(
"Invalid version format: %s.\n\n"+
"Pin to a minor version, e.g.:\n"+
" \"api_version\": \"0.8\"", apiVersion)
}
}
}
// --- build result config with defaults ---
config := map[string]any{
"node_version": nodeVersion,
"python_version": pythonVersion,
"pip_config_file": raw["pip_config_file"],
"pip_installer": "auto",
"source": raw["source"],
"base_image": raw["base_image"],
"image_distro": imageDistro,
"dependencies": raw["dependencies"],
"dockerfile_lines": raw["dockerfile_lines"],
"graphs": raw["graphs"],
"env": raw["env"],
"store": raw["store"],
"auth": raw["auth"],
"encryption": raw["encryption"],
"http": raw["http"],
"webhooks": raw["webhooks"],
"checkpointer": raw["checkpointer"],
"ui": raw["ui"],
"ui_config": raw["ui_config"],
"keep_pkg_tools": raw["keep_pkg_tools"],
}
if raw["pip_installer"] != nil {
config["pip_installer"] = raw["pip_installer"]
}
if hasInternalTag {
config["_INTERNAL_docker_tag"] = raw["_INTERNAL_docker_tag"]
}
if hasAPIVersion {
config["api_version"] = raw["api_version"]
}
// Apply list defaults.
if config["dependencies"] == nil {
config["dependencies"] = []any{}
}
if config["dockerfile_lines"] == nil {
config["dockerfile_lines"] = []any{}
}
if config["graphs"] == nil {
config["graphs"] = map[string]any{}
}
if config["env"] == nil {
config["env"] = map[string]any{}
}
// --- node_version validation ---
if nodeVersion != "" {
if strings.Contains(nodeVersion, ".") {
return nil, fmt.Errorf("Node.js version must be major version only")
}
major, err := strconv.Atoi(nodeVersion)
if err != nil {
return nil, fmt.Errorf(
"Invalid Node.js version format: %s. Use major version only (e.g., '20').",
nodeVersion)
}
minMajor, _ := strconv.Atoi(MinNodeVersion)
if major < minMajor {
return nil, fmt.Errorf(
"Node.js version %s is not supported. "+
"Minimum required version is %s.\n\n"+
"Set node_version to %s or higher:\n"+
" \"node_version\": \"%s\"",
nodeVersion, MinNodeVersion, MinNodeVersion, MinNodeVersion)
}
}
// --- pip_installer validation ---
if pi, ok := raw["pip_installer"].(string); ok {
switch pi {
case "auto", "pip", "uv":
// valid
default:
return nil, fmt.Errorf(
"Invalid pip_installer: '%s'. "+
"Consider using uv-based source management instead:\n\n"+
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}",
pi)
}
}
// --- source validation ---
sourceKind := getSourceKind(raw)
if source := raw["source"]; source != nil {
if _, ok := source.(map[string]any); !ok {
return nil, fmt.Errorf(
"`source` must be an object, e.g.:\n" +
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
}
if sourceKind != "uv" {
return nil, fmt.Errorf(
"Invalid source.kind. The only supported value is 'uv':\n" +
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
}
}
// --- python_version validation ---
if pythonVersion != "" {
base := strings.SplitN(pythonVersion, "-", 2)[0]
dotParts := strings.Split(base, ".")
allDigits := true
for _, p := range dotParts {
if _, err := strconv.Atoi(p); err != nil {
allDigits = false
break
}
}
if len(dotParts) != 2 || !allDigits {
fix := MinPythonVersion
if len(dotParts) >= 2 {
fix = dotParts[0] + "." + dotParts[1]
}
return nil, fmt.Errorf(
"Invalid Python version format: %s. "+
"Use 'major.minor' format — patch version cannot be specified.\n\n"+
" \"python_version\": \"%s\"",
pythonVersion, fix)
}
pyParsed, _ := parseVersion(pythonVersion)
minParsed, _ := parseVersion(MinPythonVersion)
if versionLessThan(pyParsed, minParsed) {
return nil, fmt.Errorf(
"Python version %s is not supported. "+
"Minimum required version is %s.\n\n"+
" \"python_version\": \"%s\"",
pythonVersion, MinPythonVersion, MinPythonVersion)
}
if strings.Contains(pythonVersion, "bullseye") {
return nil, fmt.Errorf(
"Bullseye images were deprecated in version 0.4.13. " +
"Please use 'bookworm' or 'debian' instead.")
}
// dependencies required when not uv
deps, _ := config["dependencies"].([]any)
if sourceKind != "uv" && len(deps) == 0 {
return nil, fmt.Errorf(
"No dependencies found in config. " +
"Consider using uv-based source management:\n\n" +
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
}
}
// --- graphs required ---
graphMap, _ := config["graphs"].(map[string]any)
if len(graphMap) == 0 {
return nil, fmt.Errorf(
"No graphs found in config. Add at least one graph, e.g.:\n" +
" \"graphs\": {\n" +
" \"agent\": \"./my_agent/graph.py:graph\"\n" +
" }")
}
// --- image_distro validation ---
if imageDistro == "bullseye" {
return nil, fmt.Errorf(
"Bullseye images were deprecated in version 0.4.13. " +
"Please use 'bookworm' or 'debian' instead.")
}
validDistro := false
for _, d := range validDistros {
if imageDistro == d {
validDistro = true
break
}
}
if !validDistro {
quoted := make([]string, len(validDistros))
for i, d := range validDistros {
quoted[i] = fmt.Sprintf("'%s'", d)
}
return nil, fmt.Errorf(
"Invalid image_distro: '%s'. "+
"Must be one of: %s.\n\n"+
" \"image_distro\": \"wolfi\" (recommended)",
imageDistro, strings.Join(quoted, ", "))
}
// --- uv source mode validation ---
if sourceKind == "uv" {
var errs []string
if pythonVersion == "" {
errs = append(errs, "source.kind 'uv' requires `python_version` — it is a Python-only deployment mode. Node.js-only graphs are not supported.")
}
deps, _ := raw["dependencies"].([]any)
if deps != nil && len(deps) > 0 {
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
}
// Also check if dependencies key exists even if empty array.
if deps == nil {
if rawDeps, exists := raw["dependencies"]; exists && rawDeps != nil {
// dependencies key present but not an array — still flag it
if depsArr, ok := rawDeps.([]any); ok && len(depsArr) > 0 {
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
}
}
}
sourceMap, _ := raw["source"].(map[string]any)
if root, exists := sourceMap["root"]; exists {
rootStr, ok := root.(string)
if !ok {
errs = append(errs, fmt.Sprintf("`source.root` must be a string, got %T.", root))
} else if rootStr == "" {
errs = append(errs, "`source.root` must be a non-empty string. Use `\".\"`.")
}
}
if pkg, exists := sourceMap["package"]; exists {
if pkg != nil {
pkgStr, ok := pkg.(string)
if !ok {
errs = append(errs, "`source.package` must be a non-empty string.")
} else if pkgStr == "" {
errs = append(errs, "`source.package` must be a non-empty string.")
}
}
}
if len(errs) > 0 {
formatted := ""
for i, e := range errs {
formatted += fmt.Sprintf("\n %d. %s", i+1, e)
}
return nil, fmt.Errorf(
"source.kind 'uv' requires a different config shape than dependency-based installs:%s",
formatted)
}
}
// --- legacy project_root / package ---
_, hasProjectRoot := raw["project_root"]
_, hasPackage := raw["package"]
if hasProjectRoot || hasPackage {
return nil, fmt.Errorf(
"Top-level `project_root` and `package` are no longer supported. " +
"Use `source.root` and `source.package` instead.")
}
// --- auth path validation ---
if auth, ok := raw["auth"].(map[string]any); ok {
if authPath, _ := auth["path"].(string); authPath != "" {
if !strings.Contains(authPath, ":") {
return nil, fmt.Errorf(
"Invalid auth.path format: '%s'. "+
"Must be in format './path/to/file.py:attribute_name'",
authPath)
}
}
}
// --- encryption path validation ---
if enc, ok := raw["encryption"].(map[string]any); ok {
if encPath, _ := enc["path"].(string); encPath != "" {
if !strings.Contains(encPath, ":") {
return nil, fmt.Errorf(
"Invalid encryption.path format: '%s'. "+
"Must be in format './path/to/file.py:attribute_name'",
encPath)
}
}
}
// --- http.app path validation ---
if httpConf, ok := raw["http"].(map[string]any); ok {
if app, _ := httpConf["app"].(string); app != "" {
if !strings.Contains(app, ":") {
return nil, fmt.Errorf(
"Invalid http.app format: '%s'. "+
"Must be in format './path/to/file.py:attribute_name'",
app)
}
}
}
// --- keep_pkg_tools validation ---
if kpt := raw["keep_pkg_tools"]; kpt != nil {
validBuildTools := map[string]bool{"pip": true, "setuptools": true, "wheel": true}
switch v := kpt.(type) {
case bool:
// ok
case []any:
for _, item := range v {
tool, ok := item.(string)
if !ok || !validBuildTools[tool] {
return nil, fmt.Errorf(
"Invalid keep_pkg_tools: '%v'. "+
"Must be one of 'pip', 'setuptools', 'wheel'.",
item)
}
}
default:
return nil, fmt.Errorf(
"Invalid keep_pkg_tools: '%v'. "+
"Must be bool or list[str] (with values 'pip', 'setuptools', and/or 'wheel').",
kpt)
}
}
return config, nil
}
// ValidateConfigFile loads a config file, validates it, and returns the result.
func ValidateConfigFile(configPath string) (map[string]any, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("could not read config file: %w", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error())
}
validated, err := ValidateConfig(raw)
if err != nil {
return nil, err
}
// Check package.json node version if node_version is set.
if nv, _ := validated["node_version"].(string); nv != "" {
dir := filepath.Dir(configPath)
pkgJSONPath := filepath.Join(dir, "package.json")
if info, statErr := os.Stat(pkgJSONPath); statErr == nil && !info.IsDir() {
if pkgErr := validatePackageJSON(pkgJSONPath); pkgErr != nil {
return nil, pkgErr
}
}
}
return validated, nil
}
func validatePackageJSON(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return fmt.Errorf("Invalid package.json: %s", err.Error())
}
enginesRaw, ok := pkg["engines"]
if !ok {
return nil
}
engines, ok := enginesRaw.(map[string]any)
if !ok {
return nil
}
for k := range engines {
if k != "node" {
keys := make([]string, 0, len(engines))
for ek := range engines {
keys = append(keys, ek)
}
return fmt.Errorf(
"Only 'node' engine is supported in package.json engines. Got engines: %v",
keys)
}
}
if nodeVer, ok := engines["node"].(string); ok && nodeVer != "" {
if strings.Contains(nodeVer, ".") {
return fmt.Errorf(
"Node.js version in package.json engines must be >= %s "+
"(major version only), got '%s'. "+
"Minor/patch versions (like '20.x.y') are not supported to "+
"prevent deployment issues when new Node.js versions are released.",
MinNodeVersion, nodeVer)
}
major, err := strconv.Atoi(nodeVer)
if err == nil {
minMajor, _ := strconv.Atoi(MinNodeVersion)
if major < minMajor {
return fmt.Errorf(
"Node.js version in package.json engines must be >= %s "+
"(major version only), got '%s'. "+
"Minor/patch versions (like '20.x.y') are not supported to "+
"prevent deployment issues when new Node.js versions are released.",
MinNodeVersion, nodeVer)
}
}
}
return nil
}
// GetUnknownKeys returns warnings for unrecognised top-level keys.
func GetUnknownKeys(raw map[string]any) []string {
var unknown []string
for k := range raw {
if !knownConfigKeys[k] {
unknown = append(unknown, k)
}
}
sortStrings(unknown)
var warnings []string
knownList := make([]string, 0, len(knownConfigKeys))
for k := range knownConfigKeys {
knownList = append(knownList, k)
}
for _, key := range unknown {
if close := closestMatch(key, knownList); close != "" {
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' — did you mean '%s'?", key, close))
} else {
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' is not a recognized config field.", key))
}
}
return warnings
}
// closestMatch finds the best match for word among candidates using edit distance.
// Returns "" if no match is close enough (ratio >= 0.6).
func closestMatch(word string, candidates []string) string {
best := ""
bestRatio := 0.6 // minimum threshold
for _, c := range candidates {
ratio := similarity(word, c)
if ratio > bestRatio {
bestRatio = ratio
best = c
}
}
return best
}
// similarity returns a ratio in [0,1] based on Levenshtein distance.
func similarity(a, b string) float64 {
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
if maxLen == 0 {
return 1.0
}
dist := editDistance(a, b)
return 1.0 - float64(dist)/float64(maxLen)
}
// editDistance computes Levenshtein distance between two strings.
func editDistance(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
ins := curr[j-1] + 1
del := prev[j] + 1
sub := prev[j-1] + cost
curr[j] = min3(ins, del, sub)
}
prev, curr = curr, prev
}
return prev[lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// sortStrings sorts a slice of strings in place (simple insertion sort, fine for small n).
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
+568
View File
@@ -0,0 +1,568 @@
package config
import (
"strings"
"testing"
)
// baseConfig returns a minimal valid config map. Tests should copy and modify it.
func baseConfig() map[string]any {
return map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph"},
}
}
// copyMap returns a shallow copy of m.
func copyMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// mustSucceed is a test helper that fails if err is non-nil.
func mustSucceed(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("expected success but got error: %v", err)
}
}
// mustFail is a test helper that fails if err is nil.
func mustFail(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected error but got nil")
}
}
// mustContain checks that err is non-nil and its message contains substr.
func mustContain(t *testing.T, err error, substr string) {
t.Helper()
if err == nil {
t.Fatalf("expected error containing %q but got nil", substr)
}
if !strings.Contains(err.Error(), substr) {
t.Fatalf("expected error to contain %q, got: %s", substr, err.Error())
}
}
func TestValidateConfigValid(t *testing.T) {
t.Run("minimal config", func(t *testing.T) {
raw := baseConfig()
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if pv, _ := result["python_version"].(string); pv != "3.11" {
t.Fatalf("expected python_version '3.11', got %q", pv)
}
if id, _ := result["image_distro"].(string); id != "debian" {
t.Fatalf("expected image_distro 'debian', got %q", id)
}
})
t.Run("full config with all optional fields", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"image_distro": "wolfi",
"pip_installer": "uv",
"dependencies": []any{"langchain", "langgraph"},
"graphs": map[string]any{"agent": "./agent.py:graph"},
"env": map[string]any{"FOO": "bar"},
"dockerfile_lines": []any{"RUN apt-get update"},
"auth": map[string]any{"path": "./auth.py:handler"},
"encryption": map[string]any{"path": "./enc.py:enc"},
"http": map[string]any{"app": "./app.py:app"},
"keep_pkg_tools": true,
"api_version": "0.8",
}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
func TestValidateConfigPythonVersion(t *testing.T) {
validVersions := []string{"3.11", "3.12", "3.13"}
for _, v := range validVersions {
t.Run("valid "+v, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = v
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
t.Run("valid 3.12-slim suffix stripped", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = "3.12-slim"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
tooOld := []string{"3.10", "3.9"}
for _, v := range tooOld {
t.Run("too old "+v, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = v
_, err := ValidateConfig(raw)
mustContain(t, err, "Minimum required version")
})
}
badFormat := []struct {
version string
}{
{"3.11.0"},
{"3"},
{"abc.def"},
}
for _, tc := range badFormat {
t.Run("bad format "+tc.version, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = tc.version
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid Python version format")
})
}
}
func TestValidateConfigNodeVersion(t *testing.T) {
// Need a node graph to trigger node_version validation
nodeBase := func() map[string]any {
return map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph"},
}
}
t.Run("valid 20", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "20"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("valid 22", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "22"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("too old 18", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "18"
_, err := ValidateConfig(raw)
mustContain(t, err, "Minimum required version is 20")
})
t.Run("minor version 20.1", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "20.1"
_, err := ValidateConfig(raw)
mustContain(t, err, "major version only")
})
}
func TestValidateConfigGraphs(t *testing.T) {
t.Run("empty graphs", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["graphs"] = map[string]any{}
_, err := ValidateConfig(raw)
mustContain(t, err, "No graphs found")
})
t.Run("missing graphs key", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "No graphs found")
})
}
func TestValidateConfigImageDistro(t *testing.T) {
validDistroTests := []string{"debian", "wolfi", "bookworm"}
for _, d := range validDistroTests {
t.Run("valid "+d, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = d
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
t.Run("bullseye deprecated", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = "bullseye"
_, err := ValidateConfig(raw)
mustContain(t, err, "deprecated")
})
t.Run("invalid ubuntu", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = "ubuntu"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid image_distro")
})
t.Run("invalid alpine", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = "alpine"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid image_distro")
})
t.Run("default is debian", func(t *testing.T) {
raw := baseConfig()
// no image_distro key
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if id, _ := result["image_distro"].(string); id != "debian" {
t.Fatalf("expected default image_distro 'debian', got %q", id)
}
})
}
func TestValidateConfigPipInstaller(t *testing.T) {
valid := []string{"auto", "pip", "uv"}
for _, pi := range valid {
t.Run("valid "+pi, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["pip_installer"] = pi
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
invalid := []string{"conda", "uv_lock"}
for _, pi := range invalid {
t.Run("invalid "+pi, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["pip_installer"] = pi
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid pip_installer")
})
}
}
func TestValidateConfigSource(t *testing.T) {
t.Run("valid uv source with root", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../.."},
}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid source kind poetry", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "poetry"},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid source.kind")
})
t.Run("source as string not object", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["source"] = "not-an-object"
_, err := ValidateConfig(raw)
mustContain(t, err, "`source` must be an object")
})
t.Run("uv source with dependencies", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": ".."},
"dependencies": []any{"langchain"},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "Remove `dependencies`")
})
t.Run("uv source with root as number", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": 123},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "source.root` must be a string")
})
t.Run("uv source with package as number", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": "..", "package": 123},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "source.package` must be a non-empty string")
})
}
func TestValidateConfigAPIVersion(t *testing.T) {
t.Run("valid 0.8", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "0.8"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("valid 0.8.1", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "0.8.1"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid abc", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "abc"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid version format")
})
t.Run("invalid 1.2.3.4 too many parts", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "1.2.3.4"
_, err := ValidateConfig(raw)
mustContain(t, err, "major or major.minor")
})
}
func TestValidateConfigMutualExclusion(t *testing.T) {
t.Run("both _INTERNAL_docker_tag and api_version", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["_INTERNAL_docker_tag"] = "some-tag"
raw["api_version"] = "0.8"
_, err := ValidateConfig(raw)
mustContain(t, err, "Cannot specify both")
})
}
func TestValidateConfigAuthPath(t *testing.T) {
t.Run("valid auth path with colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["auth"] = map[string]any{"path": "./auth.py:handler"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid auth path without colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["auth"] = map[string]any{"path": "../../examples/my_app.py"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid auth.path format")
})
}
func TestValidateConfigEncryptionPath(t *testing.T) {
t.Run("valid encryption path with colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["encryption"] = map[string]any{"path": "./enc.py:enc"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid encryption path without colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["encryption"] = map[string]any{"path": "./enc.py"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid encryption.path format")
})
}
func TestValidateConfigHTTPApp(t *testing.T) {
t.Run("valid http app with colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["http"] = map[string]any{"app": "./app.py:app"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid http app without colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["http"] = map[string]any{"app": "./app.py"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid http.app format")
})
}
func TestValidateConfigKeepPkgTools(t *testing.T) {
t.Run("bool true", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = true
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("valid list", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = []any{"pip", "wheel"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid list item", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = []any{"invalid"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid keep_pkg_tools")
})
t.Run("invalid string type", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = "string"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid keep_pkg_tools")
})
}
func TestValidateConfigLegacyKeys(t *testing.T) {
t.Run("project_root legacy", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["project_root"] = ".."
_, err := ValidateConfig(raw)
mustContain(t, err, "no longer supported")
})
t.Run("package legacy", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["package"] = "foo"
_, err := ValidateConfig(raw)
mustContain(t, err, "no longer supported")
})
}
func TestValidateConfigNodeGraphDetection(t *testing.T) {
t.Run("ts extension auto-sets node_version", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
})
t.Run("js extension auto-sets node_version", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.js:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
})
t.Run("py extension does not set node_version", func(t *testing.T) {
raw := baseConfig()
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "" {
t.Fatalf("expected node_version '', got %q", nv)
}
})
}
func TestGetUnknownKeys(t *testing.T) {
t.Run("typo suggests correction", func(t *testing.T) {
raw := map[string]any{
"grpahs": map[string]any{"agent": "./agent.py:graph"},
"dependencies": []any{"langchain"},
}
warnings := GetUnknownKeys(raw)
found := false
for _, w := range warnings {
if strings.Contains(w, "did you mean 'graphs'") {
found = true
break
}
}
if !found {
t.Fatalf("expected warning suggesting 'graphs', got: %v", warnings)
}
})
t.Run("totally unknown key", func(t *testing.T) {
raw := map[string]any{
"totally_unknown": "value",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"dependencies": []any{"langchain"},
}
warnings := GetUnknownKeys(raw)
found := false
for _, w := range warnings {
if strings.Contains(w, "not a recognized config field") {
found = true
break
}
}
if !found {
t.Fatalf("expected 'not a recognized config field' warning, got: %v", warnings)
}
})
t.Run("only known keys gives no warnings", func(t *testing.T) {
raw := baseConfig()
warnings := GetUnknownKeys(raw)
if len(warnings) != 0 {
t.Fatalf("expected no warnings, got: %v", warnings)
}
})
}
func TestValidateConfigMultiplatform(t *testing.T) {
t.Run("only JS graphs", func(t *testing.T) {
raw := map[string]any{
"graphs": map[string]any{"bot": "./bot.ts:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
if pv, _ := result["python_version"].(string); pv != "" {
t.Fatalf("expected python_version '', got %q", pv)
}
})
t.Run("only Python graphs", func(t *testing.T) {
raw := baseConfig()
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if pv, _ := result["python_version"].(string); pv != "3.11" {
t.Fatalf("expected python_version '3.11', got %q", pv)
}
if nv, _ := result["node_version"].(string); nv != "" {
t.Fatalf("expected node_version '', got %q", nv)
}
})
t.Run("mixed graphs", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if pv, _ := result["python_version"].(string); pv != "3.11" {
t.Fatalf("expected python_version '3.11', got %q", pv)
}
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+804
View File
@@ -0,0 +1,804 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Helper: write a uv-lock workspace fixture
// ---------------------------------------------------------------------------
type workspaceOpts struct {
// Relative directory (within project_root) that holds langgraph.json.
// Default: "deploy/agent"
configRelativeDir string
// Extra TOML appended to the root pyproject.toml (e.g. [tool.uv.sources]).
rootSources string
// Extra TOML appended to the agent pyproject.toml (e.g. [tool.uv.sources]).
agentSources string
// Extra TOML appended to the shared lib pyproject.toml (e.g. [tool.uv] section).
sharedUvConfig string
// Custom agent dependencies list. When nil the default is used.
agentDependencies []string
// Additional files to create: relative-path (from project_root) -> content.
extraFiles map[string]string
}
// writeUvLockWorkspace creates the standard multi-package uv workspace used
// by the Python test_config_to_docker_uv_lock* test suite and returns
// (projectRoot, configPath).
//
// Layout:
//
// workspace/
// pyproject.toml [project] name="workspace-root" + [tool.uv.workspace]
// uv.lock
// apps/agent/ package "agent"
// pyproject.toml
// src/agent/graph.py
// libs/shared/ package "shared"
// pyproject.toml
// src/shared/auth.py
// libs/extra/ package "extra" (not a dep of agent)
// pyproject.toml
// src/extra/graph.py
// deploy/agent/ config directory (configRelativeDir)
// langgraph.json
func writeUvLockWorkspace(t *testing.T, opts workspaceOpts) (string, string) {
t.Helper()
base := t.TempDir()
projectRoot := filepath.Join(base, "workspace")
configRelDir := opts.configRelativeDir
if configRelDir == "" {
configRelDir = "deploy/agent"
}
configDir := filepath.Join(projectRoot, configRelDir)
sharedDir := filepath.Join(projectRoot, "libs", "shared")
extraDir := filepath.Join(projectRoot, "libs", "extra")
deployDir := filepath.Join(projectRoot, "deploy", "agent")
for _, d := range []string{configDir, sharedDir, extraDir, deployDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", d, err)
}
}
// -- root pyproject.toml -------------------------------------------------
rootSources := opts.rootSources
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
"[project]\n"+
"name = \"workspace-root\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[tool.uv.workspace]\n"+
"members = [\"apps/*\", \"libs/*\"]\n"+
"\n"+
rootSources+"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- uv.lock -------------------------------------------------------------
writeFile(t, filepath.Join(projectRoot, "uv.lock"), "# uv lock file\n")
// -- agent pyproject.toml ------------------------------------------------
agentDir := filepath.Join(projectRoot, "apps", "agent")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
agentDeps := opts.agentDependencies
if agentDeps == nil {
agentDeps = []string{"shared", "httpx>=0.28"}
}
depsList := "[\"" + strings.Join(agentDeps, "\", \"") + "\"]"
agentSources := opts.agentSources
writeFile(t, filepath.Join(agentDir, "pyproject.toml"),
"[project]\n"+
"name = \"agent\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = "+depsList+"\n"+
"\n"+
agentSources+"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- shared pyproject.toml -----------------------------------------------
sharedUvConfig := opts.sharedUvConfig
writeFile(t, filepath.Join(sharedDir, "pyproject.toml"),
"[project]\n"+
"name = \"shared\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = [\"anyio>=4\"]\n"+
"\n"+
sharedUvConfig+"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- extra pyproject.toml ------------------------------------------------
writeFile(t, filepath.Join(extraDir, "pyproject.toml"),
"[project]\n"+
"name = \"extra\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- source files --------------------------------------------------------
writeFile(t, filepath.Join(agentDir, "src", "agent", "graph.py"), "")
writeFile(t, filepath.Join(sharedDir, "src", "shared", "auth.py"), "")
writeFile(t, filepath.Join(extraDir, "src", "extra", "graph.py"), "")
// -- config file ---------------------------------------------------------
configPath := filepath.Join(deployDir, "langgraph.json")
writeFile(t, configPath, "{}\n")
// If configRelativeDir is non-default, also place langgraph.json there.
if configRelDir != "deploy/agent" {
altConfigPath := filepath.Join(configDir, "langgraph.json")
writeFile(t, altConfigPath, "{}\n")
configPath = altConfigPath
}
// -- extra files ---------------------------------------------------------
for relPath, content := range opts.extraFiles {
writeFile(t, filepath.Join(projectRoot, relPath), content)
}
return projectRoot, configPath
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestUvLockBasic(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
// Core commands.
assertContains(t, docker, "uv pip install --system")
assertContains(t, docker,
"uv export --package 'agent' --frozen --no-hashes --no-emit-project --no-emit-workspace")
// Copy project metadata for uv export.
assertContains(t, docker,
"COPY --from=uv-workspace-root pyproject.toml /tmp/uv_export/project/pyproject.toml")
assertContains(t, docker,
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock")
// Additional build contexts.
if _, ok := contexts["uv-workspace-root"]; !ok {
t.Fatal("expected 'uv-workspace-root' in additional contexts")
}
// Workspace packages copied.
assertContains(t, docker,
"COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent")
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
// Unrelated member NOT copied.
assertNotContains(t, docker,
"libs/extra /deps/workspace/libs/extra")
// Install order: shared before agent.
assertContains(t, docker, "WORKDIR /deps/workspace/libs/shared")
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent")
sharedInstall := "uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e ."
assertContains(t, docker, sharedInstall)
// Ordering: shared COPY < shared WORKDIR < agent COPY < agent WORKDIR.
sharedCopy := "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
sharedWD := "WORKDIR /deps/workspace/libs/shared"
agentCopy := "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
agentWD := "WORKDIR /deps/workspace/apps/agent"
if strings.Index(docker, sharedCopy) >= strings.Index(docker, sharedWD) {
t.Error("shared COPY should appear before shared WORKDIR")
}
if strings.Index(docker, sharedWD) >= strings.Index(docker, agentCopy) {
t.Error("shared WORKDIR should appear before agent COPY")
}
if strings.Index(docker, sharedWD) >= strings.Index(docker, agentWD) {
t.Error("shared WORKDIR should appear before agent WORKDIR")
}
// No legacy dep-loop patterns.
assertNotContains(t, docker, "for dep in /deps/*")
assertNotContains(t, docker, "# -- Installing workspace packages --")
assertContains(t, docker, "WORKDIR /tmp/uv_export/project")
assertNotContains(t, docker, "RUN cd ")
// Rewritten paths in env vars (Go JSON uses compact format without spaces).
assertContains(t, docker,
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
assertContains(t, docker,
`"/deps/workspace/apps/agent/src/agent/graph.py:graph"`)
// Cleanup.
assertContains(t, docker, "rm /usr/bin/uv /usr/bin/uvx")
}
func TestUvLockHonorsRootWorkspaceSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertContains(t, docker,
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
}
func TestUvLockIgnoresUnrelatedWorkspacePackageSources(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
// Add badlib with [tool.uv.sources] pointing outside (an unrelated member).
badlibDir := filepath.Join(projectRoot, "libs", "badlib")
writeFile(t, filepath.Join(badlibDir, "pyproject.toml"),
"[project]\n"+
"name = \"badlib\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[tool.uv.sources]\n"+
"outside = { path = \"../outside\" }\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertNotContains(t, docker,
"COPY --from=uv-workspace-root libs/badlib /deps/workspace/libs/badlib")
}
func TestUvLockIgnoresUnrelatedRootSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nunused = { path = \"libs/extra\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertNotContains(t, docker,
"COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra")
}
func TestUvLockValidatesRootPathSourcesRelativeToProjectRoot(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { path = \"../outside\", editable = true }",
})
// Create the outside directory the source points to.
outsideDir := filepath.Join(filepath.Dir(projectRoot), "outside")
writeFile(t, filepath.Join(outsideDir, "pyproject.toml"),
"[project]\n"+
"name = \"shared\"\n"+
"version = \"0.1.0\"\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for path outside project_root")
}
assertContains(t, err.Error(), "outside project_root")
}
func TestUvLockRequiresExplicitWorkspaceSources(t *testing.T) {
// No agent_sources or root_sources => shared is NOT a workspace dep.
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error because shared is not an explicit workspace source")
}
assertContains(t, err.Error(), "not inside the target package 'agent'")
}
func TestUvLockAcceptsPathWorkspaceSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { path = \"../../libs/shared\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
}
func TestUvLockRejectsPackageFalseWorkspaceDependency(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
sharedUvConfig: "[tool.uv]\npackage = false",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for package = false workspace dependency")
}
assertContains(t, err.Error(), "tool.uv.package = false")
}
func TestUvLockAcceptsRootPathWorkspaceSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { path = \"libs/shared\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertContains(t, docker,
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
}
func TestUvLockRejectsMismatchedPathWorkspaceSources(t *testing.T) {
// agent sources point to libs/extra with the name "shared" -> mismatch.
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { path = \"../../libs/extra\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for mismatched path workspace sources")
}
assertContains(t, err.Error(), "dependency name and the workspace package name must match")
}
func TestUvLockDetectsJsPmFromTargetPackageRoot(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
agentDir := filepath.Join(projectRoot, "apps", "agent")
writeFile(t, filepath.Join(agentDir, "package.json"), "{\"packageManager\":\"pnpm@9.0.0\"}\n")
writeFile(t, filepath.Join(agentDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
writeFile(t, filepath.Join(agentDir, "ui.tsx"), "export const ui = null;\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"ui": map[string]any{"agent": "../../apps/agent/ui.tsx"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent")
assertContains(t, docker,
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts")
assertContains(t, docker, `/deps/workspace/apps/agent/ui.tsx`)
}
func TestUvLockUsesWorkdirForJsInstallWithSpecialChars(t *testing.T) {
projectRoot, _ := writeUvLockWorkspace(t, workspaceOpts{
configRelativeDir: "apps/agent;echo pwned",
})
// The custom configRelativeDir creates its own langgraph.json.
// We need to place the agent pyproject.toml at this directory.
agentDir := filepath.Join(projectRoot, "apps", "agent;echo pwned")
writeFile(t, filepath.Join(agentDir, "package.json"), "{\"packageManager\":\"pnpm@9.0.0\"}\n")
writeFile(t, filepath.Join(agentDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
writeFile(t, filepath.Join(agentDir, "ui.tsx"), "export const ui = null;\n")
writeFile(t, filepath.Join(agentDir, "pyproject.toml"),
"[project]\n"+
"name = \"agent\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = [\"shared\", \"httpx>=0.28\"]\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
writeFile(t, filepath.Join(agentDir, "src", "agent", "graph.py"), "")
// Remove the default apps/agent so there's no duplicate package name.
if err := os.RemoveAll(filepath.Join(projectRoot, "apps", "agent")); err != nil {
t.Fatal(err)
}
// Update workspace members to include the special-char directory.
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
"[project]\n"+
"name = \"workspace-root\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[tool.uv.workspace]\n"+
"members = [\"apps/*\", \"libs/*\"]\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
configPath := filepath.Join(agentDir, "langgraph.json")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "./src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"ui": map[string]any{"agent": "./ui.tsx"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent;echo pwned")
assertContains(t, docker,
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts")
assertNotContains(t, docker, "RUN cd /deps/workspace/apps/agent;echo pwned")
}
func TestUvLockSupportsSingleUvProjectRoot(t *testing.T) {
base := t.TempDir()
projectRoot := filepath.Join(base, "single")
if err := os.MkdirAll(projectRoot, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(projectRoot, "uv.lock"), "# uv lock file\n")
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
"[project]\n"+
"name = \"single-app\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = [\"httpx>=0.28\"]\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
configPath := filepath.Join(projectRoot, "langgraph.json")
writeFile(t, configPath, "{}\n")
writeFile(t, filepath.Join(projectRoot, "src", "agent.py"), "graph = object()\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "./src/agent.py:graph"},
"source": map[string]any{"kind": "uv"},
})
docker, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"uv export --package 'single-app' --frozen --no-hashes --no-emit-project --no-emit-workspace")
assertContains(t, docker, `"/deps/workspace/src/agent.py:graph"`)
if len(contexts) != 0 {
t.Fatalf("expected no additional contexts for single-project root, got %d: %v",
len(contexts), contexts)
}
}
func TestUvLockRejectsInvalidSourcePackageType(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
// Override the validated source.package to an integer.
cfg["source"].(map[string]any)["package"] = 123
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for non-string source.package")
}
assertContains(t, err.Error(), "`source.package` must be a non-empty string")
}
func TestUvLockRejectsPathsOutsideTargetClosure(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
// Graph path points into libs/extra which is NOT a dependency of agent.
"agent": "../../libs/extra/src/extra/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for graph path outside target closure")
}
assertContains(t, err.Error(), "not inside the target package 'agent'")
}
func TestUvLockRejectsUnrelatedMemberWhenRootInClosure(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentDependencies: []string{"workspace-root", "shared", "httpx>=0.28"},
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
agentSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
// extra is a workspace member but NOT a dependency of agent.
"agent": "../../libs/extra/src/extra/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for unrelated member when root is in closure")
}
assertContains(t, err.Error(), "not inside the target package 'agent'")
}
func TestUvLockRootPackageCopySkipsUnrelatedMembers(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentDependencies: []string{"workspace-root", "shared", "httpx>=0.28"},
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
agentSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
})
// Add files in the workspace root package itself.
writeFile(t, filepath.Join(projectRoot, "src", "workspace_root", "__init__.py"), "__all__ = []\n")
writeFile(t, filepath.Join(projectRoot, "README.md"), "workspace root package\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
// Should NOT do a blanket COPY of the whole workspace root.
assertNotContains(t, docker, "COPY --from=uv-workspace-root . /deps/workspace")
// Should copy specific entries.
assertContains(t, docker, "COPY --from=uv-workspace-root src /deps/workspace/src")
assertContains(t, docker, "COPY --from=uv-workspace-root README.md /deps/workspace/README.md")
// Should NOT copy unrelated member dirs.
assertNotContains(t, docker,
"COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra")
// Should install workspace root from /deps/workspace.
assertContains(t, docker, "WORKDIR /deps/workspace")
assertContains(t, docker,
"uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e .")
}
func TestUvLockMissingLockfile(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{})
// Remove uv.lock.
if err := os.Remove(filepath.Join(projectRoot, "uv.lock")); err != nil {
t.Fatal(err)
}
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for missing uv.lock")
}
assertContains(t, err.Error(), "No uv.lock found")
}
func TestUvLockMissingPyproject(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{})
// Remove root pyproject.toml.
if err := os.Remove(filepath.Join(projectRoot, "pyproject.toml")); err != nil {
t.Fatal(err)
}
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for missing pyproject.toml")
}
assertContains(t, err.Error(), "No pyproject.toml found")
}
func TestUvLockOldImage(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.46",
})
if err == nil {
t.Fatal("expected error for old image without uv support")
}
assertContains(t, err.Error(), "requires a base image with uv support")
}
+272
View File
@@ -0,0 +1,272 @@
// Package deploy provides an HTTP client for the LangGraph host backend
// deployment service.
package deploy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Secret represents a name/value pair sent as a deployment secret.
type Secret struct {
Name string `json:"name"`
Value string `json:"value"`
}
// HostBackendClient is a minimal JSON HTTP client for the host backend
// deployment service.
type HostBackendClient struct {
BaseURL string
APIKey string
TenantID string
client *http.Client
}
// retryTransport wraps an http.RoundTripper and retries failed requests.
type retryTransport struct {
base http.RoundTripper
retries int
}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
// We need to buffer the body so we can replay it on retries.
var bodyBytes []byte
if req.Body != nil {
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body.Close()
}
for attempt := 0; attempt <= t.retries; attempt++ {
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
resp, err = t.base.RoundTrip(req)
if err == nil {
return resp, nil
}
// Only retry on transport-level errors; do not retry on HTTP error
// status codes (the caller handles those).
}
return resp, err
}
// NewClient creates a new HostBackendClient. The baseURL is stripped of any
// trailing slash. The underlying http.Client uses a 30-second timeout and
// retries transport-level failures up to 3 times.
func NewClient(baseURL, apiKey string) *HostBackendClient {
return &HostBackendClient{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
TenantID: "",
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &retryTransport{
base: http.DefaultTransport,
retries: 3,
},
},
}
}
// request executes an HTTP request against the host backend and returns the
// parsed JSON response. It attaches required headers and handles errors.
func (c *HostBackendClient) request(method, path string, payload map[string]any, params map[string]string) (map[string]any, error) {
fullURL := c.BaseURL + path
// Append query parameters.
if len(params) > 0 {
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
fullURL += "?" + q.Encode()
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshalling request payload: %w", err)
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, body)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.TenantID != "" {
req.Header.Set("X-Tenant-ID", c.TenantID)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response from %s: %w", path, err)
}
if resp.StatusCode >= 400 {
detail := string(respBody)
if detail == "" {
detail = fmt.Sprintf("%d", resp.StatusCode)
}
return nil, fmt.Errorf("%s %s failed with status %d: %s", method, path, resp.StatusCode, detail)
}
if len(respBody) == 0 {
return nil, nil
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to decode response from %s: %w", path, err)
}
return result, nil
}
// requestNoBody is a convenience wrapper for requests that return no parsed body.
func (c *HostBackendClient) requestNoBody(method, path string, payload map[string]any, params map[string]string) error {
_, err := c.request(method, path, payload, params)
return err
}
// CreateDeployment creates a new deployment.
func (c *HostBackendClient) CreateDeployment(name, deploymentType, source string, configPath string, secrets []Secret) (map[string]any, error) {
sourceRevisionConfig := map[string]any{}
if source == "internal_source" && configPath != "" {
sourceRevisionConfig["langgraph_config_path"] = configPath
}
payload := map[string]any{
"name": name,
"source": source,
"source_config": map[string]any{"deployment_type": deploymentType},
"source_revision_config": sourceRevisionConfig,
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("POST", "/v2/deployments", payload, nil)
}
// ListDeployments lists deployments, optionally filtering by name.
func (c *HostBackendClient) ListDeployments(nameContains string) (map[string]any, error) {
params := map[string]string{"name_contains": nameContains}
return c.request("GET", "/v2/deployments", nil, params)
}
// GetDeployment retrieves a single deployment by ID.
func (c *HostBackendClient) GetDeployment(deploymentID string) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
}
// DeleteDeployment deletes a deployment by ID.
func (c *HostBackendClient) DeleteDeployment(deploymentID string) error {
return c.requestNoBody("DELETE", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
}
// RequestPushToken requests a push token for a deployment.
func (c *HostBackendClient) RequestPushToken(deploymentID string) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/push-token", deploymentID), nil, nil)
}
// RequestUploadURL gets a signed URL for uploading the source tarball.
func (c *HostBackendClient) RequestUploadURL(deploymentID string) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/upload-url", deploymentID), nil, nil)
}
// UpdateDeployment triggers a new revision using a pre-pushed Docker image.
func (c *HostBackendClient) UpdateDeployment(deploymentID, imageURI string, secrets []Secret) (map[string]any, error) {
payload := map[string]any{
"revision_source": "internal_docker",
"source_revision_config": map[string]any{"image_uri": imageURI},
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
}
// UpdateDeploymentInternalSource triggers a remote-build revision using an
// uploaded source tarball.
func (c *HostBackendClient) UpdateDeploymentInternalSource(
deploymentID, sourceTarballPath, configPath string,
secrets []Secret,
installCommand, buildCommand string,
) (map[string]any, error) {
payload := map[string]any{
"revision_source": "internal_source",
"source_revision_config": map[string]any{
"source_tarball_path": sourceTarballPath,
"langgraph_config_path": configPath,
},
}
sourceConfig := map[string]any{}
if installCommand != "" {
sourceConfig["install_command"] = installCommand
}
if buildCommand != "" {
sourceConfig["build_command"] = buildCommand
}
if len(sourceConfig) > 0 {
payload["source_config"] = sourceConfig
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
}
// ListRevisions lists revisions for a deployment.
func (c *HostBackendClient) ListRevisions(deploymentID string, limit int) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions", deploymentID), nil, map[string]string{
"limit": fmt.Sprintf("%d", limit),
})
}
// GetRevision retrieves a single revision.
func (c *HostBackendClient) GetRevision(deploymentID, revisionID string) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions/%s", deploymentID, revisionID), nil, nil)
}
// GetBuildLogs retrieves build logs for a revision.
func (c *HostBackendClient) GetBuildLogs(projectID, revisionID string, payload map[string]any) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v1/projects/%s/revisions/%s/build_logs", projectID, revisionID), payload, nil)
}
// GetDeployLogs retrieves deploy logs. If revisionID is non-empty, it is
// included in the path to scope the logs.
func (c *HostBackendClient) GetDeployLogs(projectID string, payload map[string]any, revisionID string) (map[string]any, error) {
var path string
if revisionID != "" {
path = fmt.Sprintf("/v1/projects/%s/revisions/%s/deploy_logs", projectID, revisionID)
} else {
path = fmt.Sprintf("/v1/projects/%s/deploy_logs", projectID)
}
return c.request("POST", path, payload, nil)
}
+306
View File
@@ -0,0 +1,306 @@
package deploy
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// APIKeyEnvNames lists the environment variable names checked (in order) when
// resolving a LangSmith / LangGraph API key.
var APIKeyEnvNames = []string{
"LANGGRAPH_HOST_API_KEY",
"LANGSMITH_API_KEY",
"LANGCHAIN_API_KEY",
}
// DefaultHostURL is the default host backend URL.
const DefaultHostURL = "https://api.host.langchain.com"
// reservedEnvVars contains environment variable names that must not be sent as
// deployment secrets. The set mirrors the Python CLI's RESERVED_ENV_VARS.
var reservedEnvVars = map[string]bool{
// LANGCHAIN_RESERVED_ENV_VARS from host-backend
"LANGCHAIN_TRACING_V2": true,
"LANGSMITH_TRACING_V2": true,
"LANGCHAIN_ENDPOINT": true,
"LANGCHAIN_PROJECT": true,
"LANGSMITH_PROJECT": true,
"LANGSMITH_LANGGRAPH_GIT_REPO": true,
"LANGGRAPH_GIT_REPO_PATH": true,
"LANGCHAIN_API_KEY": true,
"LANGSMITH_CONTROL_PLANE_API_KEY": true,
"POSTGRES_URI": true,
"POSTGRES_PASSWORD": true,
"DATABASE_URI": true,
"LANGSMITH_LANGGRAPH_GIT_REF": true,
"LANGSMITH_LANGGRAPH_GIT_REF_SHA": true,
"LANGGRAPH_AUTH_TYPE": true,
"LANGSMITH_AUTH_ENDPOINT": true,
"LANGSMITH_TENANT_ID": true,
"LANGSMITH_AUTH_VERIFY_TENANT_ID": true,
"LANGSMITH_HOST_PROJECT_ID": true,
"LANGSMITH_HOST_PROJECT_NAME": true,
"LANGSMITH_HOST_REVISION_ID": true,
"LOG_JSON": true,
"LOG_DICT_TRACEBACKS": true,
"REDIS_URI": true,
"LANGCHAIN_CALLBACKS_BACKGROUND": true,
"DD_TRACE_PSYCOPG_ENABLED": true,
"DD_TRACE_REDIS_ENABLED": true,
"LANGSMITH_DEPLOYMENT_NAME": true,
"LANGGRAPH_CLOUD_LICENSE_KEY": true,
// ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
"LANGSMITH_API_KEY": true,
"LANGSMITH_ENDPOINT": true,
"POSTGRES_URI_CUSTOM": true,
"REDIS_URI_CUSTOM": true,
"PATH": true,
"PORT": true,
"MOUNT_PREFIX": true,
"LSD_ENV": true,
"LSD_DD_API_KEY": true,
"LSD_DD_ENDPOINT": true,
"LSD_DEPLOYMENT_TYPE": true,
}
var (
invalidImageNameChars = regexp.MustCompile(`[^a-z0-9._-]+`)
validImageTag = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
)
// NormalizeImageName sanitizes a deployment/directory name into a valid Docker
// repository name. Invalid characters are replaced with hyphens and the result
// is lowercased. Returns "app" if the result would be empty.
func NormalizeImageName(name string) string {
if name == "" {
return "app"
}
slug := invalidImageNameChars.ReplaceAllString(strings.ToLower(name), "-")
slug = strings.TrimLeft(slug, "-.")
slug = strings.TrimRight(slug, "-.")
if slug == "" {
return "app"
}
return slug
}
// NormalizeImageTag validates and returns a Docker image tag. Tags may only
// contain [A-Za-z0-9_.-]. Defaults to "latest" when empty.
func NormalizeImageTag(tag string) (string, error) {
if tag == "" {
return "latest", nil
}
if !validImageTag.MatchString(tag) {
return "", fmt.Errorf("image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'")
}
return tag, nil
}
// ResolveAPIKey resolves an API key by checking (in order): the explicit flag
// value, the provided envVars map, and the process environment. Returns an
// empty string if no key is found (the caller should prompt the user).
func ResolveAPIKey(flagValue string, envVars map[string]string) string {
if flagValue != "" {
return flagValue
}
for _, keyName := range APIKeyEnvNames {
if envVars != nil {
if v, ok := envVars[keyName]; ok && v != "" {
return v
}
}
if v := os.Getenv(keyName); v != "" {
return v
}
}
return ""
}
// ParseEnvFromConfig resolves environment variables from the langgraph.json
// config. If the "env" field is a dict (map), those values are used directly.
// If it is a string, it is treated as a path to a .env file (resolved relative
// to the config file's directory). Otherwise, a .env file in the config
// directory is attempted as a fallback.
func ParseEnvFromConfig(configJSON map[string]any, configPath string) map[string]string {
envField, ok := configJSON["env"]
if !ok {
// Fallback: try .env in config dir.
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
}
// If env is a dict (map[string]any), convert to map[string]string.
if envMap, ok := envField.(map[string]any); ok && len(envMap) > 0 {
result := make(map[string]string, len(envMap))
for k, v := range envMap {
result[k] = fmt.Sprintf("%v", v)
}
return result
}
// If env is a string path, parse that .env file.
if envStr, ok := envField.(string); ok && envStr != "" {
envPath := filepath.Join(filepath.Dir(configPath), envStr)
absPath, err := filepath.Abs(envPath)
if err != nil {
return map[string]string{}
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return map[string]string{}
}
return parseDotEnvFile(absPath)
}
// Fallback: try .env in config dir.
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
}
// parseDotEnvFile reads a .env file and returns its key-value pairs. Lines
// starting with # are treated as comments. Empty values are skipped.
func parseDotEnvFile(path string) map[string]string {
f, err := os.Open(path)
if err != nil {
return map[string]string{}
}
defer f.Close()
result := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
// Strip surrounding quotes if present.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
if key != "" && value != "" {
result[key] = value
}
}
return result
}
// FindDeploymentIDByName lists deployments matching the given name and returns
// the ID of the first exact match. Returns an empty string (and no error) if
// no exact match is found.
func FindDeploymentIDByName(client *HostBackendClient, name string) (string, error) {
if name == "" {
return "", nil
}
existing, err := client.ListDeployments(name)
if err != nil {
return "", err
}
resources, ok := existing["resources"]
if !ok {
return "", nil
}
resourceList, ok := resources.([]any)
if !ok {
return "", nil
}
for _, item := range resourceList {
dep, ok := item.(map[string]any)
if !ok {
continue
}
depName, _ := dep["name"].(string)
if depName == name {
if id, ok := dep["id"]; ok {
return fmt.Sprintf("%v", id), nil
}
}
}
return "", nil
}
// ValidateDeploymentSelector ensures at least one of deploymentID or name is
// provided.
func ValidateDeploymentSelector(deploymentID, name string) error {
if deploymentID != "" {
return nil
}
if name == "" {
return fmt.Errorf("either --deployment-id or --name is required")
}
return nil
}
// ResolvedReservedEnvVars returns the set of reserved environment variable
// names that must not be sent as deployment secrets.
func ResolvedReservedEnvVars() map[string]bool {
// Return a copy to prevent callers from mutating the package-level map.
result := make(map[string]bool, len(reservedEnvVars))
for k, v := range reservedEnvVars {
result[k] = v
}
return result
}
// TerminalStatuses are deployment statuses that indicate completion.
var TerminalStatuses = map[string]bool{
"DEPLOYED": true,
"CREATE_FAILED": true,
"BUILD_FAILED": true,
"DEPLOY_FAILED": true,
"SKIPPED": true,
}
// PollDeploymentStatus polls a deployment until it reaches a terminal status or times out.
func PollDeploymentStatus(client *HostBackendClient, deploymentID string, timeoutSeconds, pollIntervalSeconds int, onStatus func(string)) (string, error) {
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
interval := time.Duration(pollIntervalSeconds) * time.Second
for time.Now().Before(deadline) {
resp, err := client.ListRevisions(deploymentID, 1)
if err != nil {
return "", err
}
revisions, _ := resp["revisions"].([]any)
if len(revisions) == 0 {
time.Sleep(interval)
continue
}
rev, _ := revisions[0].(map[string]any)
status, _ := rev["status"].(string)
if onStatus != nil {
onStatus(status)
}
if TerminalStatuses[status] {
return status, nil
}
time.Sleep(interval)
}
return "", fmt.Errorf("deployment timed out after %d seconds", timeoutSeconds)
}
// SecretsFromEnv converts an env var map into a Secret slice, filtering out
// reserved variable names and empty values.
func SecretsFromEnv(envVars map[string]string) []Secret {
var secrets []Secret
for name, value := range envVars {
if reservedEnvVars[name] {
continue
}
if value == "" {
continue
}
secrets = append(secrets, Secret{Name: name, Value: value})
}
return secrets
}
+154
View File
@@ -0,0 +1,154 @@
package deploy
import (
"os"
"sort"
"testing"
)
func TestNormalizeImageName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"MyApp", "myapp"},
{"", "app"},
{"!!!", "app"},
{"my-app", "my-app"},
{"My Cool App", "my-cool-app"},
{"..leading-dots", "leading-dots"},
{"trailing-dots..", "trailing-dots"},
{"hello_world.v2", "hello_world.v2"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got := NormalizeImageName(tc.input)
if got != tc.want {
t.Errorf("NormalizeImageName(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestNormalizeImageTag(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{"v1.2.3", "v1.2.3", false},
{"", "latest", false},
{"has space", "", true},
{"valid_tag-1.0", "valid_tag-1.0", false},
{"tag/slash", "", true},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := NormalizeImageTag(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("NormalizeImageTag(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Errorf("NormalizeImageTag(%q) unexpected error: %v", tc.input, err)
return
}
if got != tc.want {
t.Errorf("NormalizeImageTag(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestValidateDeploymentSelector(t *testing.T) {
tests := []struct {
name string
deploymentID string
depName string
wantErr bool
}{
{"both empty", "", "", true},
{"id set", "abc-123", "", false},
{"name set", "", "my-deploy", false},
{"both set", "abc-123", "my-deploy", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateDeploymentSelector(tc.deploymentID, tc.depName)
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestSecretsFromEnv(t *testing.T) {
envVars := map[string]string{
"MY_SECRET": "value1",
"ANOTHER_SECRET": "value2",
"LANGCHAIN_API_KEY": "should-be-filtered",
"POSTGRES_URI": "should-be-filtered",
"EMPTY_VAR": "",
}
secrets := SecretsFromEnv(envVars)
// Sort for deterministic comparison
sort.Slice(secrets, func(i, j int) bool {
return secrets[i].Name < secrets[j].Name
})
if len(secrets) != 2 {
t.Fatalf("expected 2 secrets, got %d: %+v", len(secrets), secrets)
}
if secrets[0].Name != "ANOTHER_SECRET" || secrets[0].Value != "value2" {
t.Errorf("unexpected secret[0]: %+v", secrets[0])
}
if secrets[1].Name != "MY_SECRET" || secrets[1].Value != "value1" {
t.Errorf("unexpected secret[1]: %+v", secrets[1])
}
}
func TestResolveAPIKey(t *testing.T) {
// Flag value takes precedence
got := ResolveAPIKey("flag-key", map[string]string{"LANGSMITH_API_KEY": "env-key"})
if got != "flag-key" {
t.Errorf("expected flag-key, got %q", got)
}
// envVars map is checked next
got = ResolveAPIKey("", map[string]string{"LANGSMITH_API_KEY": "env-key"})
if got != "env-key" {
t.Errorf("expected env-key, got %q", got)
}
// os.Getenv fallback
os.Setenv("LANGSMITH_API_KEY", "os-env-key")
defer os.Unsetenv("LANGSMITH_API_KEY")
got = ResolveAPIKey("", nil)
if got != "os-env-key" {
t.Errorf("expected os-env-key, got %q", got)
}
// Flag still takes precedence over os env
got = ResolveAPIKey("flag-value", nil)
if got != "flag-value" {
t.Errorf("expected flag-value, got %q", got)
}
// Empty everything returns empty
os.Unsetenv("LANGSMITH_API_KEY")
os.Unsetenv("LANGGRAPH_HOST_API_KEY")
os.Unsetenv("LANGCHAIN_API_KEY")
got = ResolveAPIKey("", nil)
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
+529
View File
@@ -0,0 +1,529 @@
// Package docker provides Docker compose generation, capability detection,
// and image building for the LangGraph CLI.
package docker
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/langchain-ai/langgraph/libs/cli/internal/config"
)
// DefaultPostgresURI is the default connection string used when no custom
// Postgres URI is provided.
const DefaultPostgresURI = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
// Version represents a semantic version with major, minor, and patch components.
type Version struct {
Major, Minor, Patch int
}
// GreaterOrEqual returns true if v >= other.
func (v Version) GreaterOrEqual(other Version) bool {
if v.Major != other.Major {
return v.Major > other.Major
}
if v.Minor != other.Minor {
return v.Minor > other.Minor
}
return v.Patch >= other.Patch
}
// DockerCapabilities describes the Docker environment available on the host.
type DockerCapabilities struct {
VersionDocker Version
VersionCompose Version
HealthcheckStartInterval bool
ComposeType string // "plugin" or "standalone"
}
// ComposeOpts configures the generated docker-compose YAML.
type ComposeOpts struct {
Port int
DebuggerPort int // 0 means no debugger
DebuggerBaseURL string // optional base URL for the debugger
PostgresURI string // empty means use DefaultPostgresURI
Image string // pre-built image name
BaseImage string
APIVersion string
EngineRuntimeMode string // "combined_queue_worker" or "distributed"
}
// BuildImageOpts configures docker image building.
type BuildImageOpts struct {
ConfigPath string
ConfigJSON map[string]any
BaseImage string
APIVersion string
Pull bool
Tag string
Passthrough []string
InstallCommand string
BuildCommand string
DockerCommand []string // default: ["docker", "build"]
ExtraFlags []string
Verbose bool
}
// OrderedMap preserves insertion order for map keys.
type OrderedMap struct {
Keys []string
Values map[string]any
}
// NewOrderedMap creates an empty OrderedMap.
func NewOrderedMap() *OrderedMap {
return &OrderedMap{
Values: make(map[string]any),
}
}
// Set adds or updates a key-value pair, preserving insertion order.
func (om *OrderedMap) Set(key string, value any) {
if _, exists := om.Values[key]; !exists {
om.Keys = append(om.Keys, key)
}
om.Values[key] = value
}
// Get retrieves the value for a key.
func (om *OrderedMap) Get(key string) (any, bool) {
v, ok := om.Values[key]
return v, ok
}
// ---------------------------------------------------------------------------
// ParseVersion
// ---------------------------------------------------------------------------
// ParseVersion parses a version string like "1.2.3", "v1.2.3-alpha+build",
// "1.2", or "1" into a Version.
func ParseVersion(version string) Version {
parts := strings.SplitN(version, ".", 3)
major := "0"
minor := "0"
patch := "0"
switch len(parts) {
case 1:
major = parts[0]
case 2:
major = parts[0]
minor = parts[1]
default:
major = parts[0]
minor = parts[1]
patch = parts[2]
}
// Strip "v" prefix from major
major = strings.TrimPrefix(major, "v")
// Strip "-" and "+" suffixes from patch
if idx := strings.IndexAny(patch, "-+"); idx >= 0 {
patch = patch[:idx]
}
majorInt, _ := strconv.Atoi(major)
minorInt, _ := strconv.Atoi(minor)
patchInt, _ := strconv.Atoi(patch)
return Version{Major: majorInt, Minor: minorInt, Patch: patchInt}
}
// ---------------------------------------------------------------------------
// CanBuildLocally
// ---------------------------------------------------------------------------
// CanBuildLocally checks whether local deployment builds can run on this machine.
// It returns (ok, errorMessage). If ok is true, errorMessage is empty.
func CanBuildLocally() (bool, string) {
if _, err := exec.LookPath("docker"); err != nil {
return false, "Docker is required but not installed.\n" +
"Install Docker Desktop: https://docs.docker.com/get-docker/"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "info")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
return false, "Docker is installed but not running.\nStart Docker and try again."
}
if runtime.GOARCH != "amd64" {
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
buildx := exec.CommandContext(ctx2, "docker", "buildx", "version")
buildx.Stdout = nil
buildx.Stderr = nil
if err := buildx.Run(); err != nil {
arch := runtime.GOARCH
// Try to match Python's platform.machine() naming for the error message
if arch == "arm64" {
arch = "aarch64"
}
return false, "Docker Buildx is required but not installed.\n" +
"Your machine architecture (" + arch + ") requires Buildx to cross-compile images for linux/amd64.\n" +
"Install Buildx: https://docs.docker.com/build/install-buildx/"
}
}
return true, ""
}
// ---------------------------------------------------------------------------
// CheckCapabilities
// ---------------------------------------------------------------------------
// CheckCapabilities detects the Docker and Docker Compose versions available
// on the host and returns a DockerCapabilities describing them.
func CheckCapabilities() (*DockerCapabilities, error) {
if _, err := exec.LookPath("docker"); err != nil {
return nil, fmt.Errorf("Docker not installed")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "info", "-f", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("Docker not installed or not running")
}
var info map[string]any
if err := json.Unmarshal(out, &info); err != nil {
return nil, fmt.Errorf("Docker not installed or not running")
}
serverVersion, _ := info["ServerVersion"].(string)
if serverVersion == "" {
return nil, fmt.Errorf("Docker not running")
}
// Try to find compose plugin
var composeVersionStr string
composeType := "plugin"
found := false
if clientInfo, ok := info["ClientInfo"].(map[string]any); ok {
if plugins, ok := clientInfo["Plugins"].([]any); ok {
for _, p := range plugins {
pm, ok := p.(map[string]any)
if !ok {
continue
}
name, _ := pm["Name"].(string)
if name == "compose" {
composeVersionStr, _ = pm["Version"].(string)
found = true
break
}
}
}
}
if !found {
// Fall back to standalone docker-compose
if _, err := exec.LookPath("docker-compose"); err != nil {
return nil, fmt.Errorf("Docker Compose not installed")
}
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
out2, err := exec.CommandContext(ctx2, "docker-compose", "--version", "--short").Output()
if err != nil {
return nil, fmt.Errorf("Docker Compose not installed")
}
composeVersionStr = strings.TrimSpace(string(out2))
composeType = "standalone"
}
dockerVersion := ParseVersion(serverVersion)
composeVersion := ParseVersion(composeVersionStr)
return &DockerCapabilities{
VersionDocker: dockerVersion,
VersionCompose: composeVersion,
HealthcheckStartInterval: dockerVersion.GreaterOrEqual(Version{25, 0, 0}),
ComposeType: composeType,
}, nil
}
// ---------------------------------------------------------------------------
// DebuggerCompose
// ---------------------------------------------------------------------------
// DebuggerCompose returns a service config map for the langgraph-debugger
// container, or nil if port is 0 (no debugger requested).
func DebuggerCompose(port int, baseURL string) *OrderedMap {
if port == 0 {
return nil
}
dependsOn := NewOrderedMap()
postgresCondition := NewOrderedMap()
postgresCondition.Set("condition", "service_healthy")
dependsOn.Set("langgraph-postgres", postgresCondition)
service := NewOrderedMap()
service.Set("image", "langchain/langgraph-debugger")
service.Set("restart", "on-failure")
service.Set("depends_on", dependsOn)
service.Set("ports", []any{fmt.Sprintf(`"%d:3968"`, port)})
if baseURL != "" {
env := NewOrderedMap()
env.Set("VITE_STUDIO_LOCAL_GRAPH_URL", baseURL)
service.Set("environment", env)
}
result := NewOrderedMap()
result.Set("langgraph-debugger", service)
return result
}
// ---------------------------------------------------------------------------
// DictToYAML
// ---------------------------------------------------------------------------
// DictToYAML converts an OrderedMap to a YAML string. For top-level keys
// (indent < 2) it adds a blank line between entries (except the first).
func DictToYAML(d *OrderedMap, indent int) string {
var b strings.Builder
for idx, key := range d.Keys {
// Add blank line between top-level entries (except first)
if idx >= 1 && indent < 2 {
b.WriteString("\n")
}
space := strings.Repeat(" ", indent)
value := d.Values[key]
switch v := value.(type) {
case *OrderedMap:
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
b.WriteString(DictToYAML(v, indent+1))
case []any:
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
for _, item := range v {
b.WriteString(fmt.Sprintf("%s - %v\n", space, item))
}
default:
b.WriteString(fmt.Sprintf("%s%s: %v\n", space, key, value))
}
}
return b.String()
}
// ---------------------------------------------------------------------------
// ComposeAsDict
// ---------------------------------------------------------------------------
// ComposeAsDict builds the docker-compose configuration as an OrderedMap.
func ComposeAsDict(caps *DockerCapabilities, opts ComposeOpts) *OrderedMap {
postgresURI := opts.PostgresURI
includeDB := false
if postgresURI == "" {
includeDB = true
postgresURI = DefaultPostgresURI
}
services := NewOrderedMap()
// --- Redis service ---
redisHealthcheck := NewOrderedMap()
redisHealthcheck.Set("test", "redis-cli ping")
redisHealthcheck.Set("interval", "5s")
redisHealthcheck.Set("timeout", "1s")
redisHealthcheck.Set("retries", 5)
redisService := NewOrderedMap()
redisService.Set("image", "redis:6")
redisService.Set("healthcheck", redisHealthcheck)
services.Set("langgraph-redis", redisService)
// --- Postgres service (if no custom URI) ---
if includeDB {
pgEnv := NewOrderedMap()
pgEnv.Set("POSTGRES_DB", "postgres")
pgEnv.Set("POSTGRES_USER", "postgres")
pgEnv.Set("POSTGRES_PASSWORD", "postgres")
pgHealthcheck := NewOrderedMap()
pgHealthcheck.Set("test", "pg_isready -U postgres")
pgHealthcheck.Set("start_period", "10s")
pgHealthcheck.Set("timeout", "1s")
pgHealthcheck.Set("retries", 5)
if caps.HealthcheckStartInterval {
pgHealthcheck.Set("interval", "60s")
pgHealthcheck.Set("start_interval", "1s")
} else {
pgHealthcheck.Set("interval", "5s")
}
pgService := NewOrderedMap()
pgService.Set("image", "pgvector/pgvector:pg16")
pgService.Set("ports", []any{`"5433:5432"`})
pgService.Set("environment", pgEnv)
pgService.Set("command", []any{"postgres", "-c", "shared_preload_libraries=vector"})
pgService.Set("volumes", []any{"langgraph-data:/var/lib/postgresql/data"})
pgService.Set("healthcheck", pgHealthcheck)
services.Set("langgraph-postgres", pgService)
}
// --- Debugger service (optional) ---
if opts.DebuggerPort != 0 {
debuggerMap := DebuggerCompose(opts.DebuggerPort, opts.DebuggerBaseURL)
if debuggerMap != nil {
debuggerService, _ := debuggerMap.Get("langgraph-debugger")
services.Set("langgraph-debugger", debuggerService)
}
}
// --- langgraph-api service ---
apiEnv := NewOrderedMap()
apiEnv.Set("REDIS_URI", "redis://langgraph-redis:6379")
apiEnv.Set("POSTGRES_URI", postgresURI)
if opts.EngineRuntimeMode == "distributed" {
apiEnv.Set("N_JOBS_PER_WORKER", `"0"`)
}
apiDependsOn := NewOrderedMap()
redisCondition := NewOrderedMap()
redisCondition.Set("condition", "service_healthy")
apiDependsOn.Set("langgraph-redis", redisCondition)
if includeDB {
pgCondition := NewOrderedMap()
pgCondition.Set("condition", "service_healthy")
apiDependsOn.Set("langgraph-postgres", pgCondition)
}
apiService := NewOrderedMap()
apiService.Set("ports", []any{fmt.Sprintf(`"%d:8000"`, opts.Port)})
apiService.Set("depends_on", apiDependsOn)
apiService.Set("environment", apiEnv)
if opts.Image != "" {
apiService.Set("image", opts.Image)
}
if caps.HealthcheckStartInterval {
apiHealthcheck := NewOrderedMap()
apiHealthcheck.Set("test", "python /api/healthcheck.py")
apiHealthcheck.Set("interval", "60s")
apiHealthcheck.Set("start_interval", "1s")
apiHealthcheck.Set("start_period", "10s")
apiService.Set("healthcheck", apiHealthcheck)
}
services.Set("langgraph-api", apiService)
// --- Build final compose dict ---
composeDict := NewOrderedMap()
if includeDB {
volumes := NewOrderedMap()
volumeDriver := NewOrderedMap()
volumeDriver.Set("driver", "local")
volumes.Set("langgraph-data", volumeDriver)
composeDict.Set("volumes", volumes)
}
composeDict.Set("services", services)
return composeDict
}
// ---------------------------------------------------------------------------
// Compose
// ---------------------------------------------------------------------------
// Compose generates a docker-compose YAML string from the given capabilities
// and options.
func Compose(caps *DockerCapabilities, opts ComposeOpts) string {
d := ComposeAsDict(caps, opts)
return DictToYAML(d, 0)
}
// ---------------------------------------------------------------------------
// BuildDockerImage
// ---------------------------------------------------------------------------
// BuildDockerImage builds a Docker image from a LangGraph configuration.
// It shells out to docker build (or a custom docker command) with the
// generated Dockerfile piped via stdin.
func BuildDockerImage(opts BuildImageOpts) error {
dockerCmd := opts.DockerCommand
if len(dockerCmd) == 0 {
dockerCmd = []string{"docker", "build"}
}
// Pull the base image first if requested.
if opts.Pull {
pullCmd := exec.Command("docker", "pull", opts.Tag)
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
if err := pullCmd.Run(); err != nil {
return fmt.Errorf("failed to pull image %s: %w", opts.Tag, err)
}
}
// Build the docker build arguments.
args := []string{
"-f", "-", // read Dockerfile from stdin
"-t", opts.Tag,
}
// Determine build context.
buildContext := "."
if opts.ConfigPath != "" {
// Use the parent directory of the config file by default.
idx := strings.LastIndex(opts.ConfigPath, "/")
if idx >= 0 {
buildContext = opts.ConfigPath[:idx]
}
}
// Generate the Dockerfile using the real config.ConfigToDocker.
dockerfile, additionalContexts, err := config.ConfigToDocker(opts.ConfigPath, opts.ConfigJSON, config.DockerOpts{
BaseImage: opts.BaseImage,
APIVersion: opts.APIVersion,
InstallCommand: opts.InstallCommand,
BuildCommand: opts.BuildCommand,
})
if err != nil {
return fmt.Errorf("generating Dockerfile: %w", err)
}
// Add additional build contexts (for dependencies outside the main context).
for name, path := range additionalContexts {
args = append(args, "--build-context", fmt.Sprintf("%s=%s", name, path))
}
// Assemble the full command.
fullArgs := append(dockerCmd[1:], args...)
fullArgs = append(fullArgs, opts.ExtraFlags...)
fullArgs = append(fullArgs, opts.Passthrough...)
fullArgs = append(fullArgs, buildContext)
cmd := exec.Command(dockerCmd[0], fullArgs...)
cmd.Stdin = strings.NewReader(dockerfile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+290
View File
@@ -0,0 +1,290 @@
package docker
import (
"fmt"
"strings"
"testing"
)
func cleanEmptyLines(s string) string {
lines := strings.Split(s, "\n")
var result []string
for _, line := range lines {
if strings.TrimSpace(line) != "" {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
var defaultCaps = &DockerCapabilities{
VersionDocker: Version{Major: 26, Minor: 1, Patch: 1},
VersionCompose: Version{Major: 2, Minor: 27, Patch: 0},
HealthcheckStartInterval: false,
}
func TestComposeCustomDBNoDebugger(t *testing.T) {
port := 8123
actual := Compose(defaultCaps, ComposeOpts{
Port: port,
PostgresURI: "custom_postgres_uri",
})
expected := fmt.Sprintf(`services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: custom_postgres_uri`, port)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeCustomDBWithHealthcheck(t *testing.T) {
port := 8123
capsHC := &DockerCapabilities{
VersionDocker: Version{Major: 26, Minor: 1, Patch: 1},
VersionCompose: Version{Major: 2, Minor: 27, Patch: 0},
HealthcheckStartInterval: true,
}
actual := Compose(capsHC, ComposeOpts{
Port: port,
PostgresURI: "custom_postgres_uri",
})
expected := fmt.Sprintf(`services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: custom_postgres_uri
healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s`, port)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeDefaultDB(t *testing.T) {
port := 8123
actual := Compose(defaultCaps, ComposeOpts{Port: port})
expected := fmt.Sprintf(`volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: %s`, port, DefaultPostgresURI)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeDistributedMode(t *testing.T) {
port := 8123
actual := Compose(defaultCaps, ComposeOpts{
Port: port,
PostgresURI: "custom_postgres_uri",
EngineRuntimeMode: "distributed",
})
expected := fmt.Sprintf(`services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: custom_postgres_uri
N_JOBS_PER_WORKER: "0"`, port)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeCombinedModeNoNJobs(t *testing.T) {
actual := Compose(defaultCaps, ComposeOpts{
Port: 8123,
EngineRuntimeMode: "combined_queue_worker",
})
if strings.Contains(actual, "N_JOBS_PER_WORKER") {
t.Error("combined mode should not contain N_JOBS_PER_WORKER")
}
}
func TestComposeDebuggerDefaultDB(t *testing.T) {
port := 8123
debuggerPort := 8001
actual := Compose(defaultCaps, ComposeOpts{
Port: port,
DebuggerPort: debuggerPort,
})
expected := fmt.Sprintf(`volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "%d:3968"
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: %s`, debuggerPort, port, DefaultPostgresURI)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestParseVersion(t *testing.T) {
tests := []struct {
input string
expected Version
}{
{"1.2.3", Version{1, 2, 3}},
{"v1.2.3", Version{1, 2, 3}},
{"1.2.3-alpha", Version{1, 2, 3}},
{"1.2.3+1", Version{1, 2, 3}},
{"1.2.3-alpha+build", Version{1, 2, 3}},
{"1.2", Version{1, 2, 0}},
{"1", Version{1, 0, 0}},
{"v28.1.1+1", Version{28, 1, 1}},
{"2.0.0-beta.1+exp.sha.5114f85", Version{2, 0, 0}},
{"v3.4.5-rc1+build.123", Version{3, 4, 5}},
}
for _, tc := range tests {
result := ParseVersion(tc.input)
if result != tc.expected {
t.Errorf("ParseVersion(%q) = %v, want %v", tc.input, result, tc.expected)
}
}
}
func TestVersionGreaterOrEqual(t *testing.T) {
tests := []struct {
v, other Version
want bool
}{
{Version{25, 0, 0}, Version{25, 0, 0}, true},
{Version{26, 1, 1}, Version{25, 0, 0}, true},
{Version{24, 9, 9}, Version{25, 0, 0}, false},
}
for _, tc := range tests {
got := tc.v.GreaterOrEqual(tc.other)
if got != tc.want {
t.Errorf("%v.GreaterOrEqual(%v) = %v, want %v", tc.v, tc.other, got, tc.want)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
// Package lgexec provides subprocess execution helpers for the LangGraph CLI.
//
// The package name is lgexec (rather than exec) to avoid shadowing the
// standard library os/exec package.
package lgexec
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// RunOpts configures how a subprocess is executed.
type RunOpts struct {
Stdin string // input to pass via stdin
Verbose bool // pipe stdout/stderr to os.Stdout/os.Stderr
Dir string // working directory
Env []string // environment variables (KEY=VALUE)
}
// Run executes the named program with the given arguments.
//
// When Verbose is true stdout and stderr are forwarded to the process's
// os.Stdout / os.Stderr. Otherwise output is silently discarded.
// A non-zero exit code is returned as an *ExitError.
func Run(name string, args []string, opts RunOpts) error {
cmd := exec.Command(name, args...)
if opts.Dir != "" {
cmd.Dir = opts.Dir
}
if len(opts.Env) > 0 {
cmd.Env = append(os.Environ(), opts.Env...)
}
if opts.Stdin != "" {
cmd.Stdin = strings.NewReader(opts.Stdin)
}
if opts.Verbose {
if opts.Stdin != "" {
cmdStr := fmt.Sprintf("+ %s %s", name, strings.Join(args, " "))
fmt.Printf("%s <\n%s\n", cmdStr, strings.Join(
nonEmptyLines(opts.Stdin), "\n"))
} else {
fmt.Printf("+ %s %s\n", name, strings.Join(args, " "))
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
}
return cmd.Run()
}
// RunCollect executes the named program and collects stdout and stderr.
// Both are returned as strings. A non-zero exit code results in a non-nil error.
func RunCollect(name string, args []string) (stdout, stderr string, err error) {
cmd := exec.Command(name, args...)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
// RunWithCallback executes the named program and invokes onStdout for each
// line of stdout output. If onStdout returns true the callback is no longer
// called and remaining stdout is forwarded directly to os.Stdout (matching
// the Python CLI's monitor_stream behaviour).
// Stderr is always forwarded to os.Stderr.
func RunWithCallback(name string, args []string, onStdout func(string) bool) error {
cmd := exec.Command(name, args...)
cmd.Stderr = os.Stderr
pipe, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("cannot create stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("cannot start command: %w", err)
}
scanner := bufio.NewScanner(pipe)
stopped := false
for scanner.Scan() {
line := scanner.Text()
if stopped {
// After callback signalled stop, forward remaining output.
fmt.Fprintln(os.Stdout, line)
continue
}
if onStdout(line) {
stopped = true
}
}
if scanErr := scanner.Err(); scanErr != nil {
// Drain but ignore read errors on stdout — the exit code matters.
_ = scanErr
}
return cmd.Wait()
}
// nonEmptyLines splits s on newlines and returns lines that are not empty.
func nonEmptyLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if line != "" {
out = append(out, line)
}
}
return out
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
package root
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run(nil, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if stderr.Len() != 0 {
t.Fatalf("expected no stderr output, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "validate") {
t.Fatalf("expected help text to contain 'validate', got %q", stdout.String())
}
}
func TestRunVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"version"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if stderr.Len() != 0 {
t.Fatalf("expected no stderr output, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "langgraph") {
t.Fatalf("unexpected stdout: %q", stdout.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"nonexistent-cmd"}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "is not a langgraph command") {
t.Fatalf("unexpected stderr: %q", stderr.String())
}
}
func writeTempConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "langgraph.json")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
return path
}
func TestRunValidateWithValidConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is valid") {
t.Fatalf("expected stdout to contain 'is valid', got %q", stdout.String())
}
if !strings.Contains(stdout.String(), "1 graph") {
t.Fatalf("expected stdout to contain '1 graph', got %q", stdout.String())
}
}
func TestRunValidateWithInvalidConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"graphs": {}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "No graphs found") {
t.Fatalf("expected stderr to contain 'No graphs found', got %q", stderr.String())
}
}
func TestRunValidateWithInvalidJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{invalid json`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "Invalid JSON") {
t.Fatalf("expected stderr to contain 'Invalid JSON', got %q", stderr.String())
}
}
func TestRunValidateDefaultConfigMissing(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
// Use a path that definitely does not exist.
nonexistent := filepath.Join(t.TempDir(), "langgraph.json")
exitCode := Run([]string{"validate", "-c", nonexistent}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "does not exist") {
t.Fatalf("expected stderr to contain 'does not exist', got %q", stderr.String())
}
}
func TestRunValidateWithUnknownKeys(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}, "grpahs": {}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
out := stdout.String()
if !strings.Contains(strings.ToLower(out), "warning") {
t.Fatalf("expected stdout to contain 'warning', got %q", out)
}
if !strings.Contains(strings.ToLower(out), "did you mean") {
t.Fatalf("expected stdout to contain 'did you mean', got %q", out)
}
}
func TestRunValidateHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"validate", "--help"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(stdout.String(), "Validate the LangGraph configuration file") {
t.Fatalf("expected stdout to contain validate help text, got %q", stdout.String())
}
}
func TestRunValidateMultipleGraphs(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./a.py:g", "bot": "./b.py:g"}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "2 graphs found") {
t.Fatalf("expected stdout to contain '2 graphs found', got %q", stdout.String())
}
}
func TestRunValidateWithWarningsAndErrors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"graphs": {}, "grpahs": {}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
errOut := stderr.String()
if !strings.Contains(errOut, "No graphs found") {
t.Fatalf("expected stderr to contain 'No graphs found', got %q", errOut)
}
if !strings.Contains(strings.ToLower(errOut), "warning") {
t.Fatalf("expected stderr to contain 'warning', got %q", errOut)
}
}
+258
View File
@@ -0,0 +1,258 @@
// Package templates provides template definitions and project scaffolding
// for the LangGraph CLI `new` command.
package templates
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
// Template describes a project template with language-specific download URLs.
type Template struct {
Name string
Description string
Languages map[string]string // lang -> download URL
}
// Templates is the ordered list of available project templates.
var Templates = []Template{
{
Name: "Deep Agent",
Description: "An opinionated deployment template for a Deep Agent.",
Languages: map[string]string{
"python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
"js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
},
},
{
Name: "Agent",
Description: "A simple agent that can be flexibly extended to many tools.",
Languages: map[string]string{
"python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
},
},
{
Name: "New LangGraph Project",
Description: "A simple, minimal chatbot with memory.",
Languages: map[string]string{
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
},
},
}
// templateIDEntry maps a template ID to its download URL, template name, and language.
type templateIDEntry struct {
URL string
Name string
Language string
}
// templateIDMap is built once at init time from the Templates slice.
var templateIDMap map[string]templateIDEntry
func init() {
templateIDMap = make(map[string]templateIDEntry)
for _, t := range Templates {
for lang, url := range t.Languages {
if lang != "python" && lang != "js" {
continue
}
id := toTemplateID(t.Name, lang)
templateIDMap[id] = templateIDEntry{
URL: url,
Name: t.Name,
Language: lang,
}
}
}
}
// toTemplateID converts a template name and language into a slug like "deep-agent-python".
func toTemplateID(name, lang string) string {
return strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "-" + lang
}
// ListTemplateIDs returns a sorted list of all available template IDs.
func ListTemplateIDs() []string {
ids := make([]string, 0, len(templateIDMap))
for id := range templateIDMap {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// TemplateHelp returns a formatted help string listing available templates.
func TemplateHelp() string {
var b strings.Builder
b.WriteString("The name of the template to use. Available options:\n")
for _, id := range ListTemplateIDs() {
entry := templateIDMap[id]
// Find the description from the Templates slice.
var desc string
for _, t := range Templates {
if t.Name == entry.Name {
desc = t.Description
break
}
}
fmt.Fprintf(&b, " %s: %s\n", id, desc)
}
return b.String()
}
// CreateNew creates a new LangGraph project at path using the given templateID.
//
// If templateID is empty an error listing available templates is returned (the
// Go CLI is non-interactive, so we cannot prompt). If path is empty an error
// is returned.
func CreateNew(path, templateID string) error {
if path == "" {
return fmt.Errorf("path is required: specify the directory for the new project")
}
// Resolve to absolute path.
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("cannot resolve path: %w", err)
}
path = absPath
// Check if path exists and is not empty.
entries, err := os.ReadDir(path)
if err == nil && len(entries) > 0 {
return fmt.Errorf(
"the specified directory already exists and is not empty: %s. "+
"Aborting to prevent overwriting files", path)
}
if templateID == "" {
return fmt.Errorf(
"template is required. Use one of the following template IDs:\n%s",
TemplateHelp())
}
entry, ok := templateIDMap[templateID]
if !ok {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("template %q not found.\n", templateID))
sb.WriteString("Please select from the available options:\n")
for _, id := range ListTemplateIDs() {
e := templateIDMap[id]
var desc string
for _, t := range Templates {
if t.Name == e.Name {
desc = t.Description
break
}
}
fmt.Fprintf(&sb, " - %s: %s\n", id, desc)
}
return fmt.Errorf("%s", sb.String())
}
if err := DownloadAndExtract(entry.URL, path); err != nil {
return fmt.Errorf("failed to download template: %w", err)
}
return nil
}
// DownloadAndExtract downloads a ZIP archive from url and extracts it to
// destPath, stripping the top-level wrapper directory that GitHub includes
// in repository archives.
func DownloadAndExtract(url, destPath string) error {
// Ensure destination directory exists.
if err := os.MkdirAll(destPath, 0o755); err != nil {
return fmt.Errorf("cannot create destination directory: %w", err)
}
// Download to a temporary file.
tmpFile, err := os.CreateTemp("", "langgraph-template-*.zip")
if err != nil {
return fmt.Errorf("cannot create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
resp, err := http.Get(url) //nolint:gosec
if err != nil {
tmpFile.Close()
return fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tmpFile.Close()
return fmt.Errorf("HTTP %d: failed to download %s", resp.StatusCode, url)
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write ZIP data: %w", err)
}
tmpFile.Close()
// Open the ZIP archive.
zr, err := zip.OpenReader(tmpPath)
if err != nil {
return fmt.Errorf("failed to open ZIP archive: %w", err)
}
defer zr.Close()
for _, f := range zr.File {
// Strip the first path component (GitHub's wrapper directory).
parts := strings.SplitN(f.Name, "/", 2)
if len(parts) < 2 || parts[1] == "" {
continue // skip the wrapper directory entry itself
}
relPath := parts[1]
outPath := filepath.Join(destPath, relPath)
// Ensure the output path is within destPath (zip-slip protection).
if !strings.HasPrefix(filepath.Clean(outPath), filepath.Clean(destPath)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(outPath, f.Mode()); err != nil {
return fmt.Errorf("cannot create directory %s: %w", outPath, err)
}
continue
}
// Create parent directories.
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return fmt.Errorf("cannot create parent directory: %w", err)
}
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
if err != nil {
return fmt.Errorf("cannot create file %s: %w", outPath, err)
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return fmt.Errorf("cannot read ZIP entry %s: %w", f.Name, err)
}
if _, err := io.Copy(outFile, rc); err != nil {
rc.Close()
outFile.Close()
return fmt.Errorf("failed writing %s: %w", outPath, err)
}
rc.Close()
outFile.Close()
}
return nil
}
+7
View File
@@ -0,0 +1,7 @@
package version
var (
Version = "dev"
Commit = "unknown"
Date = "unknown"
)
+2 -2
View File
@@ -1,4 +1,4 @@
from .cli import cli
from .entrypoint import main
if __name__ == "__main__":
cli()
main()
+74
View File
@@ -0,0 +1,74 @@
"""User-facing entrypoint for the LangGraph CLI."""
from __future__ import annotations
import os
import pathlib
import sys
from collections.abc import Sequence
import click
from .cli import cli
_GO_CLI_FLAG = "LANGGRAPH_USE_GO_CLI"
_GO_CLI_PATH_ENV = "LANGGRAPH_GO_CLI_PATH"
_CALLING_PYTHON_ENV = "LANGGRAPH_CALLING_PYTHON"
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
def _legacy_cli(argv: Sequence[str] | None = None) -> None:
cli.main(args=list(argv) if argv is not None else None, prog_name="langgraph")
def _should_use_go_cli() -> bool:
value = os.environ.get(_GO_CLI_FLAG, "")
return value.strip().lower() in _TRUE_VALUES
def _bundled_go_cli_path() -> pathlib.Path:
binary_name = "langgraph.exe" if os.name == "nt" else "langgraph"
return pathlib.Path(__file__).resolve().parent / "bin" / binary_name
def _resolve_go_cli_path() -> pathlib.Path | None:
override = os.environ.get(_GO_CLI_PATH_ENV)
if override:
path = pathlib.Path(override).expanduser()
return path.resolve()
bundled = _bundled_go_cli_path()
if bundled.is_file():
return bundled
return None
def _exec_go_cli(argv: Sequence[str]) -> None:
path = _resolve_go_cli_path()
if path is None:
raise click.ClickException(
"Go CLI requested via LANGGRAPH_USE_GO_CLI, but no langgraph binary was "
"found. Set LANGGRAPH_GO_CLI_PATH or install a wheel that bundles the "
"binary."
)
if not path.is_file():
raise click.ClickException(
f"LANGGRAPH_GO_CLI_PATH points to a missing file: {path}"
)
env = os.environ.copy()
env.setdefault(_CALLING_PYTHON_ENV, sys.executable)
os.execvpe(str(path), [str(path), *argv], env)
def main(argv: Sequence[str] | None = None) -> None:
args = list(sys.argv[1:] if argv is None else argv)
try:
if _should_use_go_cli():
_exec_go_cli(args)
_legacy_cli(args)
except click.ClickException as exc:
exc.show()
raise SystemExit(exc.exit_code) from exc
+4 -1
View File
@@ -34,7 +34,7 @@ Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
[project.scripts]
langgraph = "langgraph_cli.cli:cli"
langgraph = "langgraph_cli.entrypoint:main"
[dependency-groups]
test = [
@@ -61,6 +61,9 @@ default-groups = ['dev']
[tool.hatch.build.targets.wheel]
include = ["langgraph_cli"]
[tool.hatch.build.targets.wheel.hooks.custom]
path = "hatch_build.py"
[tool.pytest.ini_options]
addopts = "--strict-markers --strict-config --durations=5 -vv"
asyncio_mode = "auto"
@@ -0,0 +1,116 @@
import pathlib
import sys
import pytest
from langgraph_cli import entrypoint
def test_main_uses_legacy_cli_when_go_flag_disabled(monkeypatch):
captured = {}
def fake_legacy(argv):
captured["argv"] = list(argv)
monkeypatch.delenv("LANGGRAPH_USE_GO_CLI", raising=False)
monkeypatch.setattr(entrypoint, "_legacy_cli", fake_legacy)
entrypoint.main(["build", "-t", "demo"])
assert captured == {"argv": ["build", "-t", "demo"]}
def test_main_execs_go_cli_when_flag_enabled(monkeypatch, tmp_path):
binary_path = tmp_path / "langgraph"
binary_path.write_text("")
captured = {}
def fake_execvpe(file, args, env):
captured["file"] = file
captured["args"] = args
captured["env"] = env.copy()
raise SystemExit(0)
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path))
monkeypatch.delenv("LANGGRAPH_CALLING_PYTHON", raising=False)
monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe)
with pytest.raises(SystemExit, match="0"):
entrypoint.main(["dev", "--port", "8000"])
assert captured["file"] == str(binary_path)
assert captured["args"] == [str(binary_path), "dev", "--port", "8000"]
assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == sys.executable
def test_main_preserves_existing_calling_python(monkeypatch, tmp_path):
binary_path = tmp_path / "langgraph"
binary_path.write_text("")
captured = {}
def fake_execvpe(file, args, env):
captured["env"] = env.copy()
raise SystemExit(0)
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "true")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path))
monkeypatch.setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python")
monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe)
with pytest.raises(SystemExit, match="0"):
entrypoint.main(["dev"])
assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == "/custom/python"
def test_main_errors_when_go_cli_requested_but_binary_missing(
monkeypatch, capsys, tmp_path
):
missing_path = tmp_path / "missing-langgraph"
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(missing_path))
with pytest.raises(SystemExit, match="1"):
entrypoint.main(["build"])
err = capsys.readouterr().err
assert "LANGGRAPH_GO_CLI_PATH points to a missing file" in err
def test_resolve_go_cli_path_prefers_override(monkeypatch, tmp_path):
override = tmp_path / "custom-langgraph"
override.write_text("")
bundled = tmp_path / "bin" / "langgraph"
bundled.parent.mkdir()
bundled.write_text("")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(override))
monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled)
assert entrypoint._resolve_go_cli_path() == override.resolve()
def test_resolve_go_cli_path_uses_bundled_binary(monkeypatch, tmp_path):
bundled = tmp_path / "bin" / "langgraph"
bundled.parent.mkdir()
bundled.write_text("")
monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False)
monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled)
assert entrypoint._resolve_go_cli_path() == bundled
def test_resolve_go_cli_path_returns_none_when_nothing_available(monkeypatch):
monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False)
monkeypatch.setattr(
entrypoint,
"_bundled_go_cli_path",
lambda: pathlib.Path("/definitely/not/present/langgraph"),
)
assert entrypoint._resolve_go_cli_path() is None