mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-08 18:57:52 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9d5b0bc15 | ||
|
|
998aa88ea1 | ||
|
|
627cf19464 | ||
|
|
8e89c8b155 | ||
|
|
98fd216ce7 | ||
|
|
247ef5edf7 | ||
|
|
648f03d715 |
@@ -13,6 +13,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: "3.11"
|
PYTHON_VERSION: "3.11"
|
||||||
|
GO_VERSION: "1.23"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -34,6 +35,70 @@ jobs:
|
|||||||
cache-suffix: "release"
|
cache-suffix: "release"
|
||||||
working-directory: ${{ inputs.working-directory }}
|
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,
|
# We want to keep this build stage *separate* from the release stage,
|
||||||
# so that there's no sharing of permissions between them.
|
# so that there's no sharing of permissions between them.
|
||||||
# The release stage has trusted publishing and GitHub repo contents write access,
|
# The release stage has trusted publishing and GitHub repo contents write access,
|
||||||
@@ -46,6 +111,7 @@ jobs:
|
|||||||
# > from the publish job.
|
# > from the publish job.
|
||||||
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
||||||
- name: Build project for distribution
|
- name: Build project for distribution
|
||||||
|
if: inputs.working-directory != 'libs/cli'
|
||||||
run: uv build
|
run: uv build
|
||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
.langgraph_api/
|
.langgraph_api/
|
||||||
|
# Go cross-compiled binaries (built at release time, bundled into wheels)
|
||||||
|
langgraph_cli/bin/
|
||||||
|
|||||||
@@ -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
@@ -1,15 +1,21 @@
|
|||||||
.PHONY: test lint type format test-integration update-schema bump-version
|
.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
|
# TESTING AND COVERAGE
|
||||||
######################
|
######################
|
||||||
|
|
||||||
TEST?= "tests/unit_tests"
|
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)
|
uv run pytest $(TEST)
|
||||||
test-integration:
|
test-integration:
|
||||||
uv run pytest tests/integration_tests
|
uv run pytest tests/integration_tests
|
||||||
|
|
||||||
|
test-go:
|
||||||
|
[ ! -f go.mod ] || go test ./...
|
||||||
|
|
||||||
######################
|
######################
|
||||||
# LINTING AND FORMATTING
|
# LINTING AND FORMATTING
|
||||||
######################
|
######################
|
||||||
@@ -23,19 +29,94 @@ lint_package: PYTHON_FILES=langgraph_cli
|
|||||||
lint_tests: PYTHON_FILES=tests
|
lint_tests: PYTHON_FILES=tests
|
||||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
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 .
|
uv run ruff check .
|
||||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
|
[ "$(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)
|
[ "$(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:
|
type:
|
||||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
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 format $(PYTHON_FILES)
|
||||||
uv run ruff check --select I --fix $(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:
|
update-schema:
|
||||||
uv run python generate_schema.py
|
uv run python generate_schema.py
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/langchain-ai/langgraph/libs/cli
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
@@ -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}"
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = "unknown"
|
||||||
|
Date = "unknown"
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from .cli import cli
|
from .entrypoint import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
main()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,7 +34,7 @@ Slack = "https://www.langchain.com/join-community"
|
|||||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
langgraph = "langgraph_cli.cli:cli"
|
langgraph = "langgraph_cli.entrypoint:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
test = [
|
test = [
|
||||||
@@ -61,6 +61,9 @@ default-groups = ['dev']
|
|||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
include = ["langgraph_cli"]
|
include = ["langgraph_cli"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.hooks.custom]
|
||||||
|
path = "hatch_build.py"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||||
asyncio_mode = "auto"
|
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
|
||||||
Reference in New Issue
Block a user