From 627cf19464ff41d57cc07167d0825578ab88470c Mon Sep 17 00:00:00 2001 From: Will Fu-Hinthorn Date: Wed, 8 Apr 2026 05:40:59 -0700 Subject: [PATCH] update --- .github/workflows/cli_go_release.yml | 284 ++++ libs/cli/.gitignore | 2 + libs/cli/Makefile | 61 + libs/cli/hatch_build.py | 56 + libs/cli/internal/config/docker.go | 7 +- libs/cli/internal/config/docker_test.go | 1844 ++++++++++++++++++++++ libs/cli/internal/config/uvlock.go | 1633 +++++++++++++++++++ libs/cli/internal/config/uvlock_test.go | 804 ++++++++++ libs/cli/internal/deploy/helpers.go | 38 + libs/cli/internal/deploy/helpers_test.go | 154 ++ libs/cli/internal/docker/docker.go | 31 +- libs/cli/internal/root/root.go | 105 +- libs/cli/pyproject.toml | 3 + 13 files changed, 4995 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/cli_go_release.yml create mode 100644 libs/cli/hatch_build.py create mode 100644 libs/cli/internal/config/docker_test.go create mode 100644 libs/cli/internal/config/uvlock.go create mode 100644 libs/cli/internal/config/uvlock_test.go create mode 100644 libs/cli/internal/deploy/helpers_test.go diff --git a/.github/workflows/cli_go_release.yml b/.github/workflows/cli_go_release.yml new file mode 100644 index 000000000..d0d93d52d --- /dev/null +++ b/.github/workflows/cli_go_release.yml @@ -0,0 +1,284 @@ +name: "CLI: Build Go binaries and platform wheels" +run-name: "CLI Go release ${{ inputs.version || 'dev' }} by @${{ github.actor }}" + +on: + workflow_dispatch: + inputs: + version: + description: "Version to build (reads from __init__.py if empty)" + required: false + type: string + publish: + description: "Publish wheels to PyPI" + required: false + type: boolean + default: false + + # Also triggered by the main release workflow for libs/cli + workflow_call: + inputs: + version: + required: false + type: string + publish: + required: false + type: boolean + +permissions: + contents: read + +env: + GO_VERSION: "1.23" + PYTHON_VERSION: "3.11" + WORKING_DIR: "libs/cli" + +jobs: + # ----------------------------------------------------------------------- + # Stage 1: Cross-compile Go binaries for all platforms + # ----------------------------------------------------------------------- + build-go: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + plat: manylinux_2_17_x86_64.manylinux2014_x86_64 + - goos: linux + goarch: arm64 + plat: manylinux_2_17_aarch64.manylinux2014_aarch64 + - goos: darwin + goarch: amd64 + plat: macosx_11_0_x86_64 + - goos: darwin + goarch: arm64 + plat: macosx_11_0_arm64 + - goos: windows + goarch: amd64 + plat: win_amd64 + + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Determine version + id: version + working-directory: ${{ env.WORKING_DIR }} + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + VERSION=$(grep -m 1 '^__version__' langgraph_cli/__init__.py | cut -d '"' -f 2) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + fi + COMMIT=$(git rev-parse --short HEAD) + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + + - name: Build Go binary + working-directory: ${{ env.WORKING_DIR }} + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + EXT="" + if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi + OUTNAME="langgraph-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}" + + go build \ + -ldflags "-s -w \ + -X 'github.com/langchain-ai/langgraph/libs/cli/internal/version.Version=${{ steps.version.outputs.version }}' \ + -X 'github.com/langchain-ai/langgraph/libs/cli/internal/version.Commit=${{ steps.version.outputs.commit }}' \ + -X 'github.com/langchain-ai/langgraph/libs/cli/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \ + -o "$OUTNAME" \ + cmd/langgraph/main.go + + echo "binary=$OUTNAME" >> "$GITHUB_OUTPUT" + echo "Built $OUTNAME ($(stat -c%s "$OUTNAME" 2>/dev/null || stat -f%z "$OUTNAME") bytes)" + + - name: Upload binary artifact + uses: actions/upload-artifact@v7 + with: + name: go-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ env.WORKING_DIR }}/langgraph-${{ matrix.goos }}-${{ matrix.goarch }}* + retention-days: 7 + + # ----------------------------------------------------------------------- + # Stage 2: Run Go tests + # ----------------------------------------------------------------------- + test-go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + - name: Run tests + working-directory: ${{ env.WORKING_DIR }} + run: go test -count=1 -race ./... + + # ----------------------------------------------------------------------- + # Stage 3: Build platform-specific wheels (one per Go target) + # ----------------------------------------------------------------------- + build-wheels: + needs: [build-go, test-go] + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + plat: manylinux_2_17_x86_64.manylinux2014_x86_64 + - goos: linux + goarch: arm64 + plat: manylinux_2_17_aarch64.manylinux2014_aarch64 + - goos: darwin + goarch: amd64 + plat: macosx_11_0_x86_64 + - goos: darwin + goarch: arm64 + plat: macosx_11_0_arm64 + - goos: windows + goarch: amd64 + plat: win_amd64 + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + cache-suffix: "cli-wheel" + working-directory: ${{ env.WORKING_DIR }} + + - name: Download Go binary + uses: actions/download-artifact@v7 + with: + name: go-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ env.WORKING_DIR }}/ + + - name: Build platform wheel + working-directory: ${{ env.WORKING_DIR }} + env: + LANGGRAPH_GO_BINARY: langgraph-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + LANGGRAPH_WHEEL_PLAT: ${{ matrix.plat }} + run: | + chmod +x "$LANGGRAPH_GO_BINARY" + uv build --wheel + echo "Built wheel:" + ls -lh dist/*.whl + + - name: Upload wheel + uses: actions/upload-artifact@v7 + with: + name: wheel-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ env.WORKING_DIR }}/dist/*.whl + retention-days: 7 + + # ----------------------------------------------------------------------- + # Stage 4: Build pure-Python fallback wheel (no Go binary) + # ----------------------------------------------------------------------- + build-sdist: + needs: [test-go] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: ./.github/actions/uv_setup + with: + python-version: ${{ env.PYTHON_VERSION }} + cache-suffix: "cli-sdist" + working-directory: ${{ env.WORKING_DIR }} + + - name: Build sdist and fallback wheel + working-directory: ${{ env.WORKING_DIR }} + run: | + uv build + echo "Built:" + ls -lh dist/ + + - name: Upload sdist and fallback wheel + uses: actions/upload-artifact@v7 + with: + name: sdist-and-fallback + path: ${{ env.WORKING_DIR }}/dist/ + retention-days: 7 + + # ----------------------------------------------------------------------- + # Stage 5: Validate wheels install correctly + # ----------------------------------------------------------------------- + validate-wheels: + needs: [build-wheels, build-sdist] + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11", "3.12"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Download all wheels + uses: actions/download-artifact@v7 + with: + pattern: wheel-* + path: wheels/ + merge-multiple: true + + - name: Download fallback + uses: actions/download-artifact@v7 + with: + name: sdist-and-fallback + path: wheels/ + + - name: Install and test + shell: bash + run: | + pip install wheels/*.whl 2>/dev/null || pip install wheels/langgraph_cli-*.tar.gz + langgraph --help + langgraph --version + echo "Wheel installs and runs correctly on ${{ matrix.os }} / Python ${{ matrix.python-version }}" + + # ----------------------------------------------------------------------- + # Stage 6: Publish to PyPI (only when publish=true) + # ----------------------------------------------------------------------- + publish: + if: ${{ inputs.publish }} + needs: [validate-wheels] + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # required for trusted publishing + steps: + - name: Download platform wheels + uses: actions/download-artifact@v7 + with: + pattern: wheel-* + path: dist/ + merge-multiple: true + + - name: Download sdist + uses: actions/download-artifact@v7 + with: + name: sdist-and-fallback + path: dist/ + + - name: List all artifacts + run: ls -lh dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + skip-existing: true diff --git a/libs/cli/.gitignore b/libs/cli/.gitignore index 132fa2e54..f7bfe27c5 100644 --- a/libs/cli/.gitignore +++ b/libs/cli/.gitignore @@ -1 +1,3 @@ .langgraph_api/ +# Go cross-compiled binaries (built at release time, bundled into wheels) +langgraph_cli/bin/ diff --git a/libs/cli/Makefile b/libs/cli/Makefile index 197797dd8..97d2a105e 100644 --- a/libs/cli/Makefile +++ b/libs/cli/Makefile @@ -1,5 +1,6 @@ .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 @@ -47,6 +48,66 @@ format format_diff: format-go 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 — matches orjson support as proxy for practical CLI targets. +GO_PLATFORMS = \ + linux/amd64 \ + linux/arm64 \ + darwin/amd64 \ + darwin/arm64 \ + windows/amd64 + +# Build for the current platform (development). +build-go: + @mkdir -p $(GO_BIN_DIR) + go build -ldflags "$(GO_LDFLAGS)" -o $(GO_BIN_DIR)/langgraph $(GO_BINARY) + @echo "Built $(GO_BIN_DIR)/langgraph" + +# Build for a single target: make build-go-target GOOS=linux GOARCH=amd64 +build-go-target: + $(eval EXT=$(if $(filter windows,$(GOOS)),.exe,)) + @mkdir -p $(GO_BIN_DIR) + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \ + go build -ldflags "$(GO_LDFLAGS)" \ + -o $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT) $(GO_BINARY) + @echo "Built $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT)" + +# Build for all platforms. +build-go-all: + @mkdir -p $(GO_BIN_DIR) + @for platform in $(GO_PLATFORMS); do \ + os=$${platform%/*}; arch=$${platform#*/}; \ + ext=""; \ + if [ "$$os" = "windows" ]; then ext=".exe"; fi; \ + echo "Building $$os/$$arch..."; \ + GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 \ + go build -ldflags "$(GO_LDFLAGS)" \ + -o $(GO_BIN_DIR)/langgraph-$$os-$$arch$$ext $(GO_BINARY) || exit 1; \ + done + @echo "All platforms built in $(GO_BIN_DIR)/" + +clean-go-bin: + rm -rf $(GO_BIN_DIR) + +###################### +# SCHEMA AND VERSIONING +###################### + update-schema: uv run python generate_schema.py diff --git a/libs/cli/hatch_build.py b/libs/cli/hatch_build.py new file mode 100644 index 000000000..1cc126988 --- /dev/null +++ b/libs/cli/hatch_build.py @@ -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}" diff --git a/libs/cli/internal/config/docker.go b/libs/cli/internal/config/docker.go index 8f5ea9fd6..50a129f72 100644 --- a/libs/cli/internal/config/docker.go +++ b/libs/cli/internal/config/docker.go @@ -765,15 +765,16 @@ func PythonConfigToDocker( escapeVariables bool, ) (string, map[string]string, error) { sourceKind := getSourceKind(config) - if sourceKind == "uv" { - return "", nil, fmt.Errorf("UV lock mode not yet supported in Go CLI") - } buildToolsToUninstall, err := GetBuildToolsToUninstall(config) if err != nil { return "", nil, err } + if sourceKind == "uv" { + return PythonConfigToDockerUVLock(configPath, config, baseImage, apiVersion, buildToolsToUninstall) + } + pipInstaller, _ := config["pip_installer"].(string) if pipInstaller == "" { pipInstaller = "auto" diff --git a/libs/cli/internal/config/docker_test.go b/libs/cli/internal/config/docker_test.go new file mode 100644 index 000000000..2833cbd8f --- /dev/null +++ b/libs/cli/internal/config/docker_test.go @@ -0,0 +1,1844 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// mustValidate validates a config map and fatals on error. +func mustValidate(t *testing.T, raw map[string]any) map[string]any { + t.Helper() + cfg, err := ValidateConfig(raw) + if err != nil { + t.Fatalf("ValidateConfig failed: %v", err) + } + return cfg +} + +// writeFile is a test helper to create a file with the given content. +func writeFile(t *testing.T, path, content string) { + t.Helper() + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("MkdirAll(%q): %v", dir, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } +} + +// assertContains checks that got contains the substring want. +func assertContains(t *testing.T, got, want string) { + t.Helper() + if !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, but it does not.\nGot:\n%s", want, got) + } +} + +// assertNotContains checks that got does NOT contain the substring want. +func assertNotContains(t *testing.T, got, want string) { + t.Helper() + if strings.Contains(got, want) { + t.Errorf("expected output to NOT contain %q, but it does.\nGot:\n%s", want, got) + } +} + +// extractEnvJSON extracts and parses a JSON value from an ENV line in a Dockerfile. +func extractEnvJSON(t *testing.T, dockerfile, varName string) map[string]any { + t.Helper() + prefix := "ENV " + varName + "='" + for _, line := range strings.Split(dockerfile, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) && strings.HasSuffix(line, "'") { + jsonStr := line[len(prefix) : len(line)-1] + var result map[string]any + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + t.Fatalf("failed to parse JSON from %s: %v\njsonStr=%s", varName, err, jsonStr) + } + return result + } + } + t.Fatalf("%s not found in Dockerfile env lines", varName) + return nil +} + +// setupSimplePythonProject creates a minimal Python project directory with a graph file +// and returns the config path. +func setupSimplePythonProject(t *testing.T, dir string) string { + t.Helper() + writeFile(t, filepath.Join(dir, "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + return configPath +} + +// setupPythonProjectWithGraphs creates a project with a graphs subdirectory. +func setupPythonProjectWithGraphs(t *testing.T, dir string) string { + t.Helper() + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + return configPath +} + +// setupNodeProject creates a minimal Node.js project directory. +func setupNodeProject(t *testing.T, dir string) string { + t.Helper() + writeFile(t, filepath.Join(dir, "graphs", "agent.js"), "export const graph = {};\n") + writeFile(t, filepath.Join(dir, "graphs", "auth.mts"), "export const auth = {};\n") + writeFile(t, filepath.Join(dir, "package.json"), `{"name":"test"}`+"\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + return configPath +} + +// --------------------------------------------------------------------------- +// DefaultBaseImage tests +// --------------------------------------------------------------------------- + +func TestDefaultBaseImage(t *testing.T) { + t.Run("python_combined_mode", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + got := DefaultBaseImage(cfg, "combined_queue_worker") + if got != "langchain/langgraph-api" { + t.Fatalf("expected langchain/langgraph-api, got %q", got) + } + }) + + t.Run("python_default_mode", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + got := DefaultBaseImage(cfg, "") + if got != "langchain/langgraph-api" { + t.Fatalf("expected langchain/langgraph-api, got %q", got) + } + }) + + t.Run("python_distributed_mode", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + got := DefaultBaseImage(cfg, "distributed") + if got != "langchain/langgraph-executor" { + t.Fatalf("expected langchain/langgraph-executor, got %q", got) + } + }) + + t.Run("distributed_with_explicit_base_image", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "base_image": "my-custom-image:latest", + }) + got := DefaultBaseImage(cfg, "distributed") + if got != "my-custom-image:latest" { + t.Fatalf("expected my-custom-image:latest, got %q", got) + } + }) + + t.Run("nodejs", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./agent.js:graph"}, + }) + got := DefaultBaseImage(cfg, "") + if got != "langchain/langgraphjs-api" { + t.Fatalf("expected langchain/langgraphjs-api, got %q", got) + } + }) +} + +// --------------------------------------------------------------------------- +// DockerTag tests +// --------------------------------------------------------------------------- + +func TestDockerTag(t *testing.T) { + t.Run("python_debian", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + got := DockerTag(cfg, "langchain/langgraph-api", "") + if got != "langchain/langgraph-api:3.11" { + t.Fatalf("expected langchain/langgraph-api:3.11, got %q", got) + } + }) + + t.Run("python_explicit_debian", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "image_distro": "debian", + }) + got := DockerTag(cfg, "langchain/langgraph-api", "") + if got != "langchain/langgraph-api:3.11" { + t.Fatalf("expected langchain/langgraph-api:3.11, got %q", got) + } + }) + + t.Run("python_wolfi", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "image_distro": "wolfi", + }) + got := DockerTag(cfg, "langchain/langgraph-api", "") + if got != "langchain/langgraph-api:3.11-wolfi" { + t.Fatalf("expected langchain/langgraph-api:3.11-wolfi, got %q", got) + } + }) + + t.Run("node_debian", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./agent.js:graph"}, + }) + got := DockerTag(cfg, "langchain/langgraphjs-api", "") + if got != "langchain/langgraphjs-api:20" { + t.Fatalf("expected langchain/langgraphjs-api:20, got %q", got) + } + }) + + t.Run("node_wolfi", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./agent.js:graph"}, + "image_distro": "wolfi", + }) + got := DockerTag(cfg, "langchain/langgraphjs-api", "") + if got != "langchain/langgraphjs-api:20-wolfi" { + t.Fatalf("expected langchain/langgraphjs-api:20-wolfi, got %q", got) + } + }) + + t.Run("custom_base_image_wolfi", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.12", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "image_distro": "wolfi", + "base_image": "my-registry/custom-image", + }) + got := DockerTag(cfg, "my-registry/custom-image", "") + if got != "my-registry/custom-image:3.12-wolfi" { + t.Fatalf("expected my-registry/custom-image:3.12-wolfi, got %q", got) + } + }) + + t.Run("multiplatform_python_node_wolfi", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "node_version": "20", + "dependencies": []any{"."}, + "graphs": map[string]any{"python": "./agent.py:graph", "js": "./agent.js:graph"}, + "image_distro": "wolfi", + }) + got := DockerTag(cfg, "", "") + // Should default to Python when both are present + if got != "langchain/langgraph-api:3.11-wolfi" { + t.Fatalf("expected langchain/langgraph-api:3.11-wolfi, got %q", got) + } + }) + + t.Run("python_versions_with_wolfi", func(t *testing.T) { + for _, version := range []string{"3.11", "3.12", "3.13"} { + cfg := mustValidate(t, map[string]any{ + "python_version": version, + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "image_distro": "wolfi", + }) + expected := "langchain/langgraph-api:" + version + "-wolfi" + got := DockerTag(cfg, "", "") + if got != expected { + t.Fatalf("Python %s: expected %q, got %q", version, expected, got) + } + } + }) + + t.Run("node_versions_with_wolfi", func(t *testing.T) { + for _, version := range []string{"20", "21", "22"} { + cfg := mustValidate(t, map[string]any{ + "node_version": version, + "graphs": map[string]any{"agent": "./agent.js:graph"}, + "image_distro": "wolfi", + }) + expected := "langchain/langgraphjs-api:" + version + "-wolfi" + got := DockerTag(cfg, "", "") + if got != expected { + t.Fatalf("Node %s: expected %q, got %q", version, expected, got) + } + } + }) + + t.Run("internal_docker_tag_overrides", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "_INTERNAL_docker_tag": "internal-tag", + }) + got := DockerTag(cfg, "", "0.2.74") + if got != "langchain/langgraph-api:internal-tag" { + t.Fatalf("expected langchain/langgraph-api:internal-tag, got %q", got) + } + }) +} + +func TestDockerTagWithAPIVersion(t *testing.T) { + apiVersion := "0.2.74" + + for _, inConfig := range []bool{false, true} { + label := "param" + if inConfig { + label = "in_config" + } + + t.Run("python_default_distro_"+label, func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "", passedVersion) + if got != "langchain/langgraph-api:0.2.74-py3.11" { + t.Fatalf("expected langchain/langgraph-api:0.2.74-py3.11, got %q", got) + } + }) + + t.Run("python_wolfi_distro_"+label, func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "image_distro": "wolfi", + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "", passedVersion) + if got != "langchain/langgraph-api:0.2.74-py3.12-wolfi" { + t.Fatalf("expected langchain/langgraph-api:0.2.74-py3.12-wolfi, got %q", got) + } + }) + + t.Run("node_default_distro_"+label, func(t *testing.T) { + raw := map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./agent.js:graph"}, + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "", passedVersion) + if got != "langchain/langgraphjs-api:0.2.74-node20" { + t.Fatalf("expected langchain/langgraphjs-api:0.2.74-node20, got %q", got) + } + }) + + t.Run("node_wolfi_distro_"+label, func(t *testing.T) { + raw := map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./agent.js:graph"}, + "image_distro": "wolfi", + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "", passedVersion) + if got != "langchain/langgraphjs-api:0.2.74-node20-wolfi" { + t.Fatalf("expected langchain/langgraphjs-api:0.2.74-node20-wolfi, got %q", got) + } + }) + + t.Run("custom_base_image_"+label, func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "base_image": "my-registry/custom-image", + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "my-registry/custom-image", passedVersion) + if got != "my-registry/custom-image:0.2.74-py3.11" { + t.Fatalf("expected my-registry/custom-image:0.2.74-py3.11, got %q", got) + } + }) + + t.Run("python_versions_"+label, func(t *testing.T) { + for _, pyVer := range []string{"3.11", "3.12", "3.13"} { + raw := map[string]any{ + "python_version": pyVer, + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + expected := "langchain/langgraph-api:" + apiVersion + "-py" + pyVer + got := DockerTag(cfg, "", passedVersion) + if got != expected { + t.Fatalf("Python %s: expected %q, got %q", pyVer, expected, got) + } + } + }) + + t.Run("multiplatform_"+label, func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.11", + "node_version": "20", + "dependencies": []any{"."}, + "graphs": map[string]any{"python": "./agent.py:graph", "js": "./agent.js:graph"}, + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "", passedVersion) + if got != "langchain/langgraph-api:0.2.74-py3.11" { + t.Fatalf("expected langchain/langgraph-api:0.2.74-py3.11, got %q", got) + } + }) + + t.Run("langgraph_server_base_"+label, func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + passedVersion := apiVersion + if inConfig { + raw["api_version"] = apiVersion + passedVersion = "" + } + cfg := mustValidate(t, raw) + got := DockerTag(cfg, "langchain/langgraph-server", passedVersion) + if got != "langchain/langgraph-server:0.2.74-py3.11" { + t.Fatalf("expected langchain/langgraph-server:0.2.74-py3.11, got %q", got) + } + }) + } + + t.Run("without_api_version", func(t *testing.T) { + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + got := DockerTag(cfg, "", "") + if got != "langchain/langgraph-api:3.11" { + t.Fatalf("expected langchain/langgraph-api:3.11, got %q", got) + } + }) +} + +// --------------------------------------------------------------------------- +// ImageSupportsUV tests +// --------------------------------------------------------------------------- + +func TestImageSupportsUV(t *testing.T) { + t.Run("modern_image", func(t *testing.T) { + if !ImageSupportsUV("langchain/langgraph-api:0.2.47") { + t.Fatal("expected ImageSupportsUV to return true for 0.2.47") + } + }) + + t.Run("old_image", func(t *testing.T) { + if ImageSupportsUV("langchain/langgraph-api:0.2.46") { + t.Fatal("expected ImageSupportsUV to return false for 0.2.46") + } + }) + + t.Run("trial_image", func(t *testing.T) { + if ImageSupportsUV("langchain/langgraph-trial") { + t.Fatal("expected ImageSupportsUV to return false for trial image") + } + }) + + t.Run("no_version_tag", func(t *testing.T) { + if !ImageSupportsUV("langchain/langgraph-api") { + t.Fatal("expected ImageSupportsUV to return true for image without version") + } + }) + + t.Run("version_3.11", func(t *testing.T) { + if !ImageSupportsUV("langchain/langgraph-api:3.11") { + t.Fatal("expected ImageSupportsUV to return true for 3.11") + } + }) +} + +// --------------------------------------------------------------------------- +// GetBuildToolsToUninstall tests +// --------------------------------------------------------------------------- + +func TestGetBuildToolsToUninstall(t *testing.T) { + t.Run("nil_keep_pkg_tools", func(t *testing.T) { + cfg := map[string]any{} + tools, err := GetBuildToolsToUninstall(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tools) != 3 { + t.Fatalf("expected 3 tools, got %d: %v", len(tools), tools) + } + }) + + t.Run("keep_pkg_tools_true", func(t *testing.T) { + cfg := map[string]any{"keep_pkg_tools": true} + tools, err := GetBuildToolsToUninstall(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tools != nil { + t.Fatalf("expected nil, got %v", tools) + } + }) + + t.Run("keep_pkg_tools_false", func(t *testing.T) { + cfg := map[string]any{"keep_pkg_tools": false} + tools, err := GetBuildToolsToUninstall(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tools) != 3 { + t.Fatalf("expected 3 tools, got %d", len(tools)) + } + }) + + t.Run("keep_pkg_tools_list", func(t *testing.T) { + cfg := map[string]any{"keep_pkg_tools": []any{"pip", "setuptools"}} + tools, err := GetBuildToolsToUninstall(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tools) != 1 || tools[0] != "wheel" { + t.Fatalf("expected [wheel], got %v", tools) + } + }) +} + +// --------------------------------------------------------------------------- +// BuildRuntimeEnvVars tests +// --------------------------------------------------------------------------- + +func TestBuildRuntimeEnvVars(t *testing.T) { + t.Run("graphs_only", func(t *testing.T) { + cfg := map[string]any{ + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + vars := BuildRuntimeEnvVars(cfg) + found := false + for _, v := range vars { + if strings.Contains(v, "LANGSERVE_GRAPHS=") { + found = true + } + } + if !found { + t.Fatal("expected LANGSERVE_GRAPHS in env vars") + } + }) + + t.Run("with_webhooks", func(t *testing.T) { + cfg := map[string]any{ + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "webhooks": map[string]any{"env_prefix": "LG_"}, + } + vars := BuildRuntimeEnvVars(cfg) + found := false + for _, v := range vars { + if strings.Contains(v, "LANGGRAPH_WEBHOOKS=") { + found = true + } + } + if !found { + t.Fatal("expected LANGGRAPH_WEBHOOKS in env vars") + } + }) + + t.Run("without_webhooks", func(t *testing.T) { + cfg := map[string]any{ + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + vars := BuildRuntimeEnvVars(cfg) + for _, v := range vars { + if strings.Contains(v, "LANGGRAPH_WEBHOOKS=") { + t.Fatal("did not expect LANGGRAPH_WEBHOOKS in env vars") + } + } + }) + + t.Run("with_encryption", func(t *testing.T) { + cfg := map[string]any{ + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "encryption": map[string]any{"path": "./enc.py:enc"}, + } + vars := BuildRuntimeEnvVars(cfg) + found := false + for _, v := range vars { + if strings.Contains(v, "LANGGRAPH_ENCRYPTION=") { + found = true + } + } + if !found { + t.Fatal("expected LANGGRAPH_ENCRYPTION in env vars") + } + }) +} + +// --------------------------------------------------------------------------- +// GetNodePMInstallCmd tests +// --------------------------------------------------------------------------- + +func TestGetNodePMInstallCmd(t *testing.T) { + t.Run("npm_default", func(t *testing.T) { + dir := t.TempDir() + got := GetNodePMInstallCmd(dir) + if got != "npm i" { + t.Fatalf("expected 'npm i', got %q", got) + } + }) + + t.Run("yarn_lock", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "yarn.lock"), "") + got := GetNodePMInstallCmd(dir) + if got != "yarn install --frozen-lockfile" { + t.Fatalf("expected 'yarn install --frozen-lockfile', got %q", got) + } + }) + + t.Run("pnpm_lock", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pnpm-lock.yaml"), "") + got := GetNodePMInstallCmd(dir) + if got != "pnpm i --frozen-lockfile" { + t.Fatalf("expected 'pnpm i --frozen-lockfile', got %q", got) + } + }) + + t.Run("package_lock_json", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package-lock.json"), "{}") + got := GetNodePMInstallCmd(dir) + if got != "npm ci" { + t.Fatalf("expected 'npm ci', got %q", got) + } + }) + + t.Run("bun_lock", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "bun.lockb"), "") + got := GetNodePMInstallCmd(dir) + if got != "bun i" { + t.Fatalf("expected 'bun i', got %q", got) + } + }) + + t.Run("packageManager_pnpm", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), `{"packageManager":"pnpm@9.0.0"}`) + got := GetNodePMInstallCmd(dir) + if got != "pnpm i" { + t.Fatalf("expected 'pnpm i', got %q", got) + } + }) + + t.Run("packageManager_yarn", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), `{"packageManager":"yarn@4.0.0"}`) + got := GetNodePMInstallCmd(dir) + if got != "yarn install" { + t.Fatalf("expected 'yarn install', got %q", got) + } + }) +} + +// --------------------------------------------------------------------------- +// GetPipCleanupLines tests +// --------------------------------------------------------------------------- + +func TestGetPipCleanupLines(t *testing.T) { + t.Run("uv_with_all_tools", func(t *testing.T) { + result := GetPipCleanupLines("uv pip install --system", []string{"pip", "setuptools", "wheel"}, "uv") + assertContains(t, result, "RUN pip uninstall -y pip setuptools wheel") + assertContains(t, result, "rm /usr/bin/uv /usr/bin/uvx") + assertContains(t, result, "/usr/local/lib/python*/site-packages/pip*") + }) + + t.Run("pip_with_all_tools", func(t *testing.T) { + result := GetPipCleanupLines("pip install", []string{"pip", "setuptools", "wheel"}, "pip") + assertContains(t, result, "RUN pip uninstall -y pip setuptools wheel") + assertNotContains(t, result, "rm /usr/bin/uv") + }) + + t.Run("no_tools_to_uninstall_uv", func(t *testing.T) { + result := GetPipCleanupLines("uv pip install --system", nil, "uv") + assertNotContains(t, result, "RUN pip uninstall") + assertContains(t, result, "rm /usr/bin/uv /usr/bin/uvx") + }) +} + +// --------------------------------------------------------------------------- +// ConfigToDocker tests -- Python +// --------------------------------------------------------------------------- + +func TestConfigToDockerSimple(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11") + assertContains(t, dockerfile, "LANGSERVE_GRAPHS=") + assertContains(t, dockerfile, "uv pip install --system") + // Should contain working directory reference + baseName := filepath.Base(dir) + assertContains(t, dockerfile, "/deps/outer-"+baseName) +} + +func TestConfigToDockerPipConfig(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + writeFile(t, filepath.Join(dir, "pipconfig.txt"), "[global]\nindex-url = https://pypi.org/simple\n") + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "pip_config_file": "pipconfig.txt", + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11") + assertContains(t, dockerfile, "ADD pipconfig.txt /pipconfig.txt") + assertContains(t, dockerfile, "PIP_CONFIG_FILE=/pipconfig.txt") +} + +func TestConfigToDockerLocalDeps(t *testing.T) { + dir := t.TempDir() + // Create graphs directory with a Python file (src-layout faux package) + writeFile(t, filepath.Join(dir, "graphs", "subpkg", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"./graphs"}, + "graphs": map[string]any{"agent": "./graphs/subpkg/agent.py:graph"}, + }) + + dockerfile, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api-custom", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraph-api-custom:3.11") + assertContains(t, dockerfile, "Adding non-package dependency graphs") + assertContains(t, dockerfile, "/deps/outer-graphs") + // No additional contexts needed for child directories + if len(contexts) != 0 { + t.Fatalf("expected 0 additional contexts, got %d: %v", len(contexts), contexts) + } +} + +func TestConfigToDockerPyproject(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project] +name = "custom" +version = "0.1" +dependencies = ["langchain"]`) + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./graphs/agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + baseName := filepath.Base(dir) + assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11") + assertContains(t, dockerfile, "Adding local package .") + assertContains(t, dockerfile, "ADD . /deps/"+baseName) + assertContains(t, dockerfile, "WORKDIR /deps/"+baseName) +} + +func TestConfigToDockerNodeJS(t *testing.T) { + dir := t.TempDir() + configPath := setupNodeProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./graphs/agent.js:graph"}, + "dockerfile_lines": []any{"ARG meow", "ARG foo"}, + "auth": map[string]any{"path": "./graphs/auth.mts:auth"}, + "ui": map[string]any{"agent": "./graphs/agent.ui.jsx"}, + "ui_config": map[string]any{"shared": []any{"nuqs"}}, + }) + + dockerfile, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraphjs-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraphjs-api:20") + assertContains(t, dockerfile, "ARG meow") + assertContains(t, dockerfile, "ARG foo") + assertContains(t, dockerfile, "RUN npm i") + assertContains(t, dockerfile, "LANGGRAPH_AUTH=") + assertContains(t, dockerfile, "LANGGRAPH_UI=") + assertContains(t, dockerfile, "LANGGRAPH_UI_CONFIG=") + assertContains(t, dockerfile, "LANGSERVE_GRAPHS=") + assertContains(t, dockerfile, "tsx /api/langgraph_api/js/build.mts") + + if len(contexts) != 0 { + t.Fatalf("expected 0 additional contexts, got %d", len(contexts)) + } +} + +func TestConfigToDockerNodeJSInternalTag(t *testing.T) { + dir := t.TempDir() + configPath := setupNodeProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./graphs/agent.js:graph"}, + "_INTERNAL_docker_tag": "my-tag", + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraphjs-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraphjs-api:my-tag") +} + +func TestConfigToDockerMultiplatform(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "multiplatform", "python.py"), "graph = None\n") + writeFile(t, filepath.Join(dir, "multiplatform", "js.mts"), "export const graph = {};\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "node_version": "22", + "dependencies": []any{"."}, + "graphs": map[string]any{ + "python": "./multiplatform/python.py:graph", + "js": "./multiplatform/js.mts:graph", + }, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + // Multiplatform with both Python and Node -> Python base image + assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11") + // Should install node for JS + assertContains(t, dockerfile, "RUN /storage/install-node.sh") + assertContains(t, dockerfile, "ENV NODE_VERSION=22") + assertContains(t, dockerfile, "npm i && tsx /api/langgraph_api/js/build.mts") + assertContains(t, dockerfile, "LANGSERVE_GRAPHS=") +} + +func TestConfigToDockerPipInstaller(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + baseCfg := func() map[string]any { + return map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./graphs/agent.py:graph"}, + } + } + + t.Run("auto_with_uv_supporting_image", func(t *testing.T) { + raw := baseCfg() + raw["pip_installer"] = "auto" + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + assertContains(t, dockerfile, "uv pip install --system") + assertContains(t, dockerfile, "rm /usr/bin/uv /usr/bin/uvx") + }) + + t.Run("explicit_pip", func(t *testing.T) { + raw := baseCfg() + raw["pip_installer"] = "pip" + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + assertNotContains(t, dockerfile, "uv pip install --system") + assertContains(t, dockerfile, "pip install") + assertNotContains(t, dockerfile, "rm /usr/bin/uv") + }) + + t.Run("explicit_uv", func(t *testing.T) { + raw := baseCfg() + raw["pip_installer"] = "uv" + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + assertContains(t, dockerfile, "uv pip install --system") + assertContains(t, dockerfile, "rm /usr/bin/uv /usr/bin/uvx") + }) + + t.Run("auto_with_old_image_uses_pip", func(t *testing.T) { + raw := baseCfg() + raw["pip_installer"] = "auto" + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.46", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + assertNotContains(t, dockerfile, "uv pip install --system") + assertContains(t, dockerfile, "pip install") + assertNotContains(t, dockerfile, "rm /usr/bin/uv") + }) + + t.Run("default_auto_with_uv_image", func(t *testing.T) { + cfg := mustValidate(t, baseCfg()) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + assertContains(t, dockerfile, "uv pip install --system") + }) +} + +func TestConfigToDockerWebhooksPython(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + webhooks := map[string]any{ + "env_prefix": "LG_WEBHOOK_", + "url": map[string]any{ + "require_https": true, + "allowed_domains": []any{"hooks.example.com", "*.example.org"}, + "allowed_ports": []any{float64(443)}, + "max_url_length": float64(1024), + "disable_loopback": false, + }, + "headers": map[string]any{ + "x-auth": "${{ env.LG_WEBHOOK_TOKEN }}", + "x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix", + }, + } + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "webhooks": webhooks, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + parsed := extractEnvJSON(t, dockerfile, "LANGGRAPH_WEBHOOKS") + if parsed["env_prefix"] != "LG_WEBHOOK_" { + t.Fatalf("expected env_prefix LG_WEBHOOK_, got %v", parsed["env_prefix"]) + } +} + +func TestConfigToDockerNoWebhooks(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertNotContains(t, dockerfile, "ENV LANGGRAPH_WEBHOOKS=") +} + +func TestConfigToDockerEncryption(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "agent.py"), "graph = None\n") + writeFile(t, filepath.Join(dir, "encryption.py"), "encryption = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + // Test encryption config is preserved after validation + validated := mustValidate(t, map[string]any{ + "python_version": "3.11", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "dependencies": []any{"."}, + "encryption": map[string]any{"path": "./encryption.py:encryption"}, + }) + + enc, ok := validated["encryption"].(map[string]any) + if !ok || enc == nil { + t.Fatal("encryption config should be preserved after validation") + } + if enc["path"] != "./encryption.py:encryption" { + t.Fatalf("expected encryption path ./encryption.py:encryption, got %v", enc["path"]) + } +} + +func TestConfigToDockerEncryptionFormatted(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "agent.py"), "my_encryption = None\n") + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./graphs/agent.py:graph"}, + "encryption": map[string]any{"path": "./agent.py:my_encryption"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "LANGGRAPH_ENCRYPTION=") + assertContains(t, dockerfile, "agent.py:my_encryption") +} + +func TestConfigToDockerWithAPIVersion(t *testing.T) { + t.Run("python", func(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + APIVersion: "0.2.74", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + lines := strings.Split(dockerfile, "\n") + if !strings.Contains(lines[0], "FROM langchain/langgraph-api:0.2.74-py3.11") { + t.Fatalf("expected FROM line with api version, got: %s", lines[0]) + } + }) + + t.Run("nodejs", func(t *testing.T) { + dir := t.TempDir() + configPath := setupNodeProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./graphs/agent.js:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraphjs-api", + APIVersion: "0.2.74", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + lines := strings.Split(dockerfile, "\n") + fromLine := "" + for _, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "FROM ") { + fromLine = strings.TrimSpace(l) + break + } + } + if fromLine != "FROM langchain/langgraphjs-api:0.2.74-node20" { + t.Fatalf("expected FROM langchain/langgraphjs-api:0.2.74-node20, got %q", fromLine) + } + }) +} + +func TestConfigToDockerExecutorBaseImage(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-executor", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraph-executor:3.11") + assertContains(t, dockerfile, "LANGSERVE_GRAPHS=") +} + +// --------------------------------------------------------------------------- +// ConfigToDocker -- retain/remove build tools +// --------------------------------------------------------------------------- + +func TestConfigRetainBuildTools(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + baseCfg := func() map[string]any { + return map[string]any{ + "python_version": "3.11", + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./graphs/agent.py:graph"}, + } + } + + t.Run("keep_pkg_tools_true", func(t *testing.T) { + raw := baseCfg() + raw["keep_pkg_tools"] = true + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + for _, pkg := range []string{"pip", "setuptools", "wheel"} { + assertNotContains(t, dockerfile, "/usr/local/lib/python*/site-packages/"+pkg+"*") + } + assertNotContains(t, dockerfile, "RUN pip uninstall -y pip setuptools wheel") + }) + + t.Run("keep_pkg_tools_false", func(t *testing.T) { + raw := baseCfg() + raw["keep_pkg_tools"] = false + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + for _, pkg := range []string{"pip", "setuptools", "wheel"} { + assertContains(t, dockerfile, "/usr/local/lib/python*/site-packages/"+pkg+"*") + } + assertContains(t, dockerfile, "RUN pip uninstall -y pip setuptools wheel") + }) + + t.Run("keep_pkg_tools_list", func(t *testing.T) { + raw := baseCfg() + raw["keep_pkg_tools"] = []any{"pip", "setuptools"} + cfg := mustValidate(t, raw) + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api:0.2.47", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "/usr/local/lib/python*/site-packages/wheel*") + assertNotContains(t, dockerfile, "/usr/local/lib/python*/site-packages/pip*") + assertNotContains(t, dockerfile, "/usr/local/lib/python*/site-packages/setuptools*") + assertContains(t, dockerfile, "RUN pip uninstall -y wheel") + assertNotContains(t, dockerfile, "RUN pip uninstall -y pip setuptools") + }) +} + +// --------------------------------------------------------------------------- +// ConfigToDocker -- PyPI dependencies +// --------------------------------------------------------------------------- + +func TestConfigToDockerPyPIDeps(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "python_version": "3.12", + "dependencies": []any{"./graphs/", "langchain", "langchain_openai"}, + "graphs": map[string]any{"agent": "./graphs/agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.12") + assertContains(t, dockerfile, "langchain langchain_openai") +} + +// --------------------------------------------------------------------------- +// ConfigToDocker -- dockerfile_lines +// --------------------------------------------------------------------------- + +func TestConfigToDockerDockerfileLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "python_version": "3.12", + "dependencies": []any{"./graphs/"}, + "graphs": map[string]any{"agent": "./graphs/agent.py:graph"}, + "dockerfile_lines": []any{"ARG meow", "ARG foo"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "ARG meow") + assertContains(t, dockerfile, "ARG foo") +} + +// --------------------------------------------------------------------------- +// ConfigToDocker -- gen UI with Python (installs node) +// --------------------------------------------------------------------------- + +func TestConfigToDockerGenUIPython(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "agent.py"), "graph = None\n") + writeFile(t, filepath.Join(dir, "graphs", "agent.ui.jsx"), "export default null;\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "ui": map[string]any{"agent": "./graphs/agent.ui.jsx"}, + "ui_config": map[string]any{"shared": []any{"nuqs"}}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11") + assertContains(t, dockerfile, "RUN /storage/install-node.sh") + assertContains(t, dockerfile, "LANGGRAPH_UI=") + assertContains(t, dockerfile, "LANGGRAPH_UI_CONFIG=") + assertContains(t, dockerfile, "npm i && tsx /api/langgraph_api/js/build.mts") + assertContains(t, dockerfile, "ENV NODE_VERSION=20") +} + +// --------------------------------------------------------------------------- +// ConfigToCompose tests +// --------------------------------------------------------------------------- + +func TestConfigToComposeSimple(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, "pull_policy: build") + assertContains(t, compose, "dockerfile_inline:") + assertContains(t, compose, "FROM langchain/langgraph-api:3.11") + assertContains(t, compose, "LANGSERVE_GRAPHS=") + assertContains(t, compose, "context: .") + // Should use escaped variable names for compose + assertContains(t, compose, "$$dep") +} + +func TestConfigToComposeEnvVars(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "env": map[string]any{"OPENAI_API_KEY": "key"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api-custom", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, `OPENAI_API_KEY: "key"`) + assertContains(t, compose, "FROM langchain/langgraph-api-custom:3.11") +} + +func TestConfigToComposeEnvFile(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "env": ".env", + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, "env_file: .env") +} + +func TestConfigToComposeWithAPIVersion(t *testing.T) { + t.Run("python", func(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + APIVersion: "0.2.74", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, "FROM langchain/langgraph-api:0.2.74-py3.11") + }) + + t.Run("nodejs", func(t *testing.T) { + dir := t.TempDir() + configPath := setupNodeProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "node_version": "20", + "graphs": map[string]any{"agent": "./graphs/agent.js:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraphjs-api", + APIVersion: "0.2.74", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, "FROM langchain/langgraphjs-api:0.2.74-node20") + }) +} + +func TestConfigToComposeDistributedMode(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + EngineRuntimeMode: "distributed", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, "FROM langchain/langgraph-api:3.11") + assertContains(t, compose, "langgraph-orchestrator:") + assertContains(t, compose, "EXECUTOR_TARGET: langgraph-executor:8188") + assertContains(t, compose, "langgraph-executor:") + assertContains(t, compose, "FROM langchain/langgraph-executor:3.11") + assertContains(t, compose, `entrypoint: ["sh", "/storage/executor_entrypoint.sh"]`) + assertContains(t, compose, "EXECUTOR_GRPC_PORT:") + assertContains(t, compose, "ENGINE_GRPC_ADDRESS:") + assertContains(t, compose, "LSD_GRPC_SERVER_ADDRESS:") + assertContains(t, compose, `LANGGRAPH_HTTP: ""`) + assertContains(t, compose, "REDIS_URI: redis://langgraph-redis:6379") +} + +func TestConfigToComposeDistributedModeWithEnvFile(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "env": ".env", + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + EngineRuntimeMode: "distributed", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + count := strings.Count(compose, "env_file: .env") + if count != 3 { + t.Fatalf("expected env_file to appear 3 times (api, orchestrator, executor), got %d", count) + } +} + +func TestConfigToComposeDistributedTwoDockerfiles(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + EngineRuntimeMode: "distributed", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + var fromLines []string + for _, line := range strings.Split(compose, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "FROM ") { + fromLines = append(fromLines, trimmed) + } + } + if len(fromLines) != 2 { + t.Fatalf("expected 2 FROM lines, got %d: %v", len(fromLines), fromLines) + } + assertContains(t, fromLines[0], "FROM langchain/langgraph-api:3.11") + assertContains(t, fromLines[1], "FROM langchain/langgraph-executor:3.11") +} + +func TestConfigToComposeCombinedModeNoOrchestrator(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + EngineRuntimeMode: "combined_queue_worker", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertNotContains(t, compose, "langgraph-orchestrator:") + assertNotContains(t, compose, "langgraph-executor:") +} + +func TestConfigToComposeDefaultModeNoOrchestrator(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertNotContains(t, compose, "langgraph-orchestrator:") + assertNotContains(t, compose, "langgraph-executor:") +} + +func TestConfigToComposeDistributedCorrectPaths(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + EngineRuntimeMode: "distributed", + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + // Both API and executor should contain LANGSERVE_GRAPHS + count := 0 + for _, line := range strings.Split(compose, "\n") { + if strings.Contains(strings.TrimSpace(line), "LANGSERVE_GRAPHS=") { + count++ + } + } + if count != 2 { + t.Fatalf("expected 2 LANGSERVE_GRAPHS lines, got %d", count) + } +} + +// --------------------------------------------------------------------------- +// ConfigToCompose -- watch mode +// --------------------------------------------------------------------------- + +func TestConfigToComposeWatch(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{ + BaseImage: "langchain/langgraph-api", + Watch: true, + }) + if err != nil { + t.Fatalf("ConfigToCompose failed: %v", err) + } + + assertContains(t, compose, "develop:") + assertContains(t, compose, "watch:") + assertContains(t, compose, "action: rebuild") +} + +// --------------------------------------------------------------------------- +// AssembleLocalDeps tests +// --------------------------------------------------------------------------- + +func TestAssembleLocalDeps(t *testing.T) { + t.Run("faux_package_flat_layout", func(t *testing.T) { + dir := t.TempDir() + // Create a flat-layout faux package (directory with __init__.py) + writeFile(t, filepath.Join(dir, "mypkg", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := map[string]any{ + "dependencies": []any{"./mypkg"}, + "graphs": map[string]any{"agent": "./mypkg/graph.py:graph"}, + } + + deps, err := AssembleLocalDeps(configPath, cfg) + if err != nil { + t.Fatalf("AssembleLocalDeps failed: %v", err) + } + + if len(deps.FauxPkgs) != 1 { + t.Fatalf("expected 1 faux package, got %d", len(deps.FauxPkgs)) + } + for _, faux := range deps.FauxPkgs { + assertContains(t, faux.ContainerPath, "/deps/outer-mypkg/mypkg") + } + }) + + t.Run("real_package_pyproject", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "mypkg", "pyproject.toml"), `[project] +name = "test" +version = "0.1"`) + writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := map[string]any{ + "dependencies": []any{"./mypkg"}, + "graphs": map[string]any{"agent": "./mypkg/graph.py:graph"}, + } + + deps, err := AssembleLocalDeps(configPath, cfg) + if err != nil { + t.Fatalf("AssembleLocalDeps failed: %v", err) + } + + if len(deps.RealPkgs) != 1 { + t.Fatalf("expected 1 real package, got %d", len(deps.RealPkgs)) + } + }) + + t.Run("real_package_setup_py", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "mypkg", "setup.py"), "from setuptools import setup; setup(name='test')") + writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := map[string]any{ + "dependencies": []any{"./mypkg"}, + "graphs": map[string]any{"agent": "./mypkg/graph.py:graph"}, + } + + deps, err := AssembleLocalDeps(configPath, cfg) + if err != nil { + t.Fatalf("AssembleLocalDeps failed: %v", err) + } + + if len(deps.RealPkgs) != 1 { + t.Fatalf("expected 1 real package, got %d", len(deps.RealPkgs)) + } + }) + + t.Run("dot_dependency_sets_workdir", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project] +name = "myapp" +version = "0.1"`) + writeFile(t, filepath.Join(dir, "graph.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./graph.py:graph"}, + } + + deps, err := AssembleLocalDeps(configPath, cfg) + if err != nil { + t.Fatalf("AssembleLocalDeps failed: %v", err) + } + + baseName := filepath.Base(dir) + expected := "/deps/" + baseName + if deps.WorkingDir != expected { + t.Fatalf("expected WorkingDir %q, got %q", expected, deps.WorkingDir) + } + }) + + t.Run("missing_dependency_dir", func(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := map[string]any{ + "dependencies": []any{"./missing"}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + + _, err := AssembleLocalDeps(configPath, cfg) + if err == nil { + t.Fatal("expected error for missing dependency, got nil") + } + assertContains(t, err.Error(), "Could not find local dependency") + }) + + t.Run("requirements_txt_detected", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "mypkg", "requirements.txt"), "langchain>=0.1\n") + writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := map[string]any{ + "dependencies": []any{"./mypkg"}, + "graphs": map[string]any{"agent": "./mypkg/graph.py:graph"}, + } + + deps, err := AssembleLocalDeps(configPath, cfg) + if err != nil { + t.Fatalf("AssembleLocalDeps failed: %v", err) + } + + if len(deps.PipReqs) != 1 { + t.Fatalf("expected 1 pip req, got %d", len(deps.PipReqs)) + } + }) +} + +// --------------------------------------------------------------------------- +// ConfigToDocker -- invalid inputs +// --------------------------------------------------------------------------- + +func TestConfigToDockerInvalidInputs(t *testing.T) { + t.Run("missing_local_dependency", func(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"./missing"}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + }) + + _, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err == nil { + t.Fatal("expected error for missing dependency") + } + }) + + t.Run("missing_local_module", func(t *testing.T) { + dir := t.TempDir() + configPath := setupSimplePythonProject(t, dir) + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"."}, + "graphs": map[string]any{"agent": "./missing_agent.py:graph"}, + }) + + _, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err == nil { + t.Fatal("expected error for missing module") + } + }) +} + +// --------------------------------------------------------------------------- +// ConfigToDocker -- requirements.txt with faux package +// --------------------------------------------------------------------------- + +func TestConfigToDockerWithRequirements(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "mypkg", "requirements.txt"), "langchain>=0.1\n") + writeFile(t, filepath.Join(dir, "mypkg", "subpkg", "agent.py"), "graph = None\n") + configPath := filepath.Join(dir, "langgraph.json") + writeFile(t, configPath, "{}\n") + + cfg := mustValidate(t, map[string]any{ + "dependencies": []any{"./mypkg"}, + "graphs": map[string]any{"agent": "./mypkg/subpkg/agent.py:graph"}, + }) + + dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{ + BaseImage: "langchain/langgraph-api", + }) + if err != nil { + t.Fatalf("ConfigToDocker failed: %v", err) + } + + assertContains(t, dockerfile, "Installing local requirements") + assertContains(t, dockerfile, "requirements.txt") +} diff --git a/libs/cli/internal/config/uvlock.go b/libs/cli/internal/config/uvlock.go new file mode 100644 index 000000000..ae4a6253a --- /dev/null +++ b/libs/cli/internal/config/uvlock.go @@ -0,0 +1,1633 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +// UvLockSourceEntry represents a single [tool.uv.sources] entry with its +// provenance (which pyproject.toml declared it, and from which directory). +type UvLockSourceEntry struct { + Name string + Value any + DeclaredRoot string // absolute path + PyprojectPath string +} + +// UvLockPackage represents one workspace package discovered from pyproject.toml. +type UvLockPackage struct { + Name string + NormalizedName string + Root string // absolute path + PyprojectPath string + RawDependencySpecs any + RawUvTool any + PackageEnabled bool + DependencyNames []string + WorkspaceDependencies []string +} + +// UvLockWorkspace holds the result of discovering all workspace packages. +type UvLockWorkspace struct { + RawRootSourceEntries any + PackagesByName map[string]*UvLockPackage + PackagesByRoot map[string]*UvLockPackage +} + +// UvLockPlan is the fully resolved build plan for a uv-lock deployment. +type UvLockPlan struct { + ProjectRoot string + PyprojectPath string + UvLockPath string + Target *UvLockPackage + TargetRoot string + InstallOrder []*UvLockPackage + ContainerRoots map[string]string // hostRoot -> containerPath + WorkingDir string + AllWorkspaceRoots map[string]bool +} + +// --------------------------------------------------------------------------- +// Minimal TOML parser +// --------------------------------------------------------------------------- + +// parseTOML parses a subset of TOML needed for pyproject.toml files. +// Supports: string values, boolean values, arrays of strings, inline tables, +// and dotted table headers. +func parseTOML(data string) map[string]any { + result := make(map[string]any) + currentSection := result + currentPath := []string{} + + lines := strings.Split(data, "\n") + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Table header: [section] or [section.subsection] + if strings.HasPrefix(line, "[") && !strings.HasPrefix(line, "[[") { + end := strings.Index(line, "]") + if end < 0 { + continue + } + sectionKey := strings.TrimSpace(line[1:end]) + parts := strings.Split(sectionKey, ".") + currentPath = parts + currentSection = ensureNestedMap(result, parts) + continue + } + + // Array of tables: [[section]] + if strings.HasPrefix(line, "[[") { + end := strings.Index(line, "]]") + if end < 0 { + continue + } + // We don't need array-of-tables for our use case, skip + continue + } + + // Key = value + eqIdx := strings.Index(line, "=") + if eqIdx < 0 { + continue + } + key := strings.TrimSpace(line[:eqIdx]) + valStr := strings.TrimSpace(line[eqIdx+1:]) + + // Handle dotted keys like tool.uv.package + keyParts := strings.Split(key, ".") + var target map[string]any + var finalKey string + if len(keyParts) > 1 { + target = ensureNestedMap(currentSection, keyParts[:len(keyParts)-1]) + finalKey = keyParts[len(keyParts)-1] + } else { + target = currentSection + finalKey = key + } + + // Multi-line arrays + if strings.HasPrefix(valStr, "[") && !strings.Contains(valStr, "]") { + // Collect continuation lines + for i+1 < len(lines) { + i++ + cont := strings.TrimSpace(lines[i]) + valStr += " " + cont + if strings.Contains(cont, "]") { + break + } + } + } + + // Multi-line inline tables + if strings.HasPrefix(valStr, "{") && !strings.Contains(valStr, "}") { + for i+1 < len(lines) { + i++ + cont := strings.TrimSpace(lines[i]) + valStr += " " + cont + if strings.Contains(cont, "}") { + break + } + } + } + + value := parseTOMLValue(valStr) + // Use the full path for context when resolving dotted keys in sections + _ = currentPath + target[finalKey] = value + } + return result +} + +// ensureNestedMap navigates/creates nested maps for a dotted key path. +func ensureNestedMap(root map[string]any, parts []string) map[string]any { + current := root + for _, part := range parts { + if existing, ok := current[part]; ok { + if m, ok := existing.(map[string]any); ok { + current = m + } else { + // Overwrite non-map with map (shouldn't happen in valid TOML) + m := make(map[string]any) + current[part] = m + current = m + } + } else { + m := make(map[string]any) + current[part] = m + current = m + } + } + return current +} + +// parseTOMLValue parses a single TOML value from a string. +func parseTOMLValue(s string) any { + s = strings.TrimSpace(s) + + // Boolean + if s == "true" { + return true + } + if s == "false" { + return false + } + + // String (double-quoted) + if strings.HasPrefix(s, "\"") { + return parseTOMLString(s) + } + // String (single-quoted / literal) + if strings.HasPrefix(s, "'") { + end := strings.LastIndex(s, "'") + if end > 0 { + return s[1:end] + } + return s[1:] + } + + // Array + if strings.HasPrefix(s, "[") { + return parseTOMLArray(s) + } + + // Inline table + if strings.HasPrefix(s, "{") { + return parseTOMLInlineTable(s) + } + + // Number or other - return as string + // Strip trailing comments + if idx := strings.Index(s, " #"); idx >= 0 { + s = strings.TrimSpace(s[:idx]) + } + return s +} + +// parseTOMLString extracts a double-quoted TOML string. +func parseTOMLString(s string) string { + if len(s) < 2 || s[0] != '"' { + return s + } + // Find closing quote, handling escapes + result := strings.Builder{} + i := 1 + for i < len(s) { + if s[i] == '\\' && i+1 < len(s) { + switch s[i+1] { + case '"': + result.WriteByte('"') + case '\\': + result.WriteByte('\\') + case 'n': + result.WriteByte('\n') + case 't': + result.WriteByte('\t') + default: + result.WriteByte('\\') + result.WriteByte(s[i+1]) + } + i += 2 + continue + } + if s[i] == '"' { + break + } + result.WriteByte(s[i]) + i++ + } + return result.String() +} + +// parseTOMLArray parses a TOML array value like ["a", "b", "c"]. +func parseTOMLArray(s string) []any { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "[") { + return nil + } + + // Find matching close bracket + end := findMatchingBracket(s, 0, '[', ']') + if end < 0 { + end = len(s) - 1 + } + inner := strings.TrimSpace(s[1:end]) + if inner == "" { + return []any{} + } + + var result []any + for _, item := range splitTOMLItems(inner) { + item = strings.TrimSpace(item) + if item == "" { + continue + } + result = append(result, parseTOMLValue(item)) + } + return result +} + +// parseTOMLInlineTable parses an inline table like { workspace = true }. +func parseTOMLInlineTable(s string) map[string]any { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "{") { + return nil + } + end := strings.LastIndex(s, "}") + if end < 0 { + end = len(s) + } + inner := strings.TrimSpace(s[1:end]) + if inner == "" { + return map[string]any{} + } + + result := make(map[string]any) + for _, pair := range splitTOMLItems(inner) { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + eqIdx := strings.Index(pair, "=") + if eqIdx < 0 { + continue + } + key := strings.TrimSpace(pair[:eqIdx]) + val := strings.TrimSpace(pair[eqIdx+1:]) + result[key] = parseTOMLValue(val) + } + return result +} + +// splitTOMLItems splits comma-separated TOML items, respecting nesting. +func splitTOMLItems(s string) []string { + var items []string + depth := 0 + inStr := false + strChar := byte(0) + start := 0 + + for i := 0; i < len(s); i++ { + ch := s[i] + if inStr { + if ch == '\\' { + i++ // skip escape + continue + } + if ch == strChar { + inStr = false + } + continue + } + if ch == '"' || ch == '\'' { + inStr = true + strChar = ch + continue + } + if ch == '[' || ch == '{' { + depth++ + continue + } + if ch == ']' || ch == '}' { + depth-- + continue + } + if ch == ',' && depth == 0 { + items = append(items, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + items = append(items, s[start:]) + } + return items +} + +// findMatchingBracket finds the index of the matching close bracket. +func findMatchingBracket(s string, start int, open, close byte) int { + depth := 0 + inStr := false + strChar := byte(0) + for i := start; i < len(s); i++ { + ch := s[i] + if inStr { + if ch == '\\' { + i++ + continue + } + if ch == strChar { + inStr = false + } + continue + } + if ch == '"' || ch == '\'' { + inStr = true + strChar = ch + continue + } + if ch == open { + depth++ + } else if ch == close { + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// --------------------------------------------------------------------------- +// Core helpers +// --------------------------------------------------------------------------- + +var normalizeNamePattern = regexp.MustCompile(`[-_.]+`) + +// normalizePackageName replaces sequences of [-_.] with - and lowercases. +func normalizePackageName(name string) string { + return strings.ToLower(normalizeNamePattern.ReplaceAllString(name, "-")) +} + +var depNamePattern = regexp.MustCompile(`^\s*([A-Za-z0-9][A-Za-z0-9._-]*)`) + +// parseDependencyName extracts the package name from a PEP 508 string. +func parseDependencyName(dep, packageName, pyprojectPath string) (string, error) { + match := depNamePattern.FindStringSubmatch(dep) + if match == nil { + return "", fmt.Errorf( + "source.kind 'uv' only supports PEP 508 dependency strings "+ + "with an explicit package name. Could not parse dependency "+ + "%q in %s for package '%s'.", + dep, pyprojectPath, packageName) + } + return normalizePackageName(match[1]), nil +} + +// loadPyproject reads and parses a pyproject.toml file. +func loadPyproject(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not read %s: %w", path, err) + } + return parseTOML(string(data)), nil +} + +// getNestedMap navigates a nested map via dotted key path. +func getNestedMap(m map[string]any, keys ...string) map[string]any { + current := m + for _, k := range keys { + next, ok := current[k].(map[string]any) + if !ok { + return map[string]any{} + } + current = next + } + return current +} + +// getNestedValue navigates a nested map and returns the final value. +func getNestedValue(m map[string]any, keys ...string) any { + if len(keys) == 0 { + return nil + } + current := m + for _, k := range keys[:len(keys)-1] { + next, ok := current[k].(map[string]any) + if !ok { + return nil + } + current = next + } + return current[keys[len(keys)-1]] +} + +// --------------------------------------------------------------------------- +// Dependency resolution +// --------------------------------------------------------------------------- + +// getDependencyNames parses and normalizes [project].dependencies. +func getDependencyNames(depSpecs any, packageName, pyprojectPath string) ([]string, error) { + if depSpecs == nil { + return nil, nil + } + arr, ok := depSpecs.([]any) + if !ok { + return nil, fmt.Errorf( + "source.kind 'uv' requires [project].dependencies to be a "+ + "list of strings in %s.", pyprojectPath) + } + var names []string + for _, item := range arr { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf( + "source.kind 'uv' requires [project].dependencies to be a "+ + "list of strings in %s.", pyprojectPath) + } + name, err := parseDependencyName(s, packageName, pyprojectPath) + if err != nil { + return nil, err + } + names = append(names, name) + } + return names, nil +} + +// getUvLockPackageEnabled returns whether tool.uv.package is true (default: true). +func getUvLockPackageEnabled(pkg *UvLockPackage) (bool, error) { + uvTool := pkg.RawUvTool + if uvTool == nil { + return true, nil + } + uvMap, ok := uvTool.(map[string]any) + if !ok { + return false, fmt.Errorf( + "source.kind 'uv' requires [tool.uv] to be a table in %s.", + pkg.PyprojectPath) + } + pkgVal, exists := uvMap["package"] + if !exists { + return true, nil + } + b, ok := pkgVal.(bool) + if !ok { + return false, fmt.Errorf( + "source.kind 'uv' requires [tool.uv].package to be a boolean in %s.", + pkg.PyprojectPath) + } + return b, nil +} + +// getUvLockSourceEntries merges root-level and package-level [tool.uv.sources]. +func getUvLockSourceEntries( + pkg *UvLockPackage, + projectRoot, rootPyprojectPath string, + rawRootSourceEntries any, +) ([]UvLockSourceEntry, error) { + rootSources, ok := rawRootSourceEntries.(map[string]any) + if !ok { + return nil, fmt.Errorf( + "source.kind 'uv' requires [tool.uv.sources] to be a table in %s.", + rootPyprojectPath) + } + + var pkgSources map[string]any + if uvMap, ok := pkg.RawUvTool.(map[string]any); ok { + if s, ok := uvMap["sources"].(map[string]any); ok { + pkgSources = s + } else if uvMap["sources"] != nil { + return nil, fmt.Errorf( + "source.kind 'uv' requires [tool.uv.sources] to be a table in %s.", + pkg.PyprojectPath) + } + } + + // Build merged entries: root first, then package-level overrides + entryMap := make(map[string]UvLockSourceEntry) + for name, val := range rootSources { + entryMap[name] = UvLockSourceEntry{ + Name: name, + Value: val, + DeclaredRoot: projectRoot, + PyprojectPath: rootPyprojectPath, + } + } + for name, val := range pkgSources { + entryMap[name] = UvLockSourceEntry{ + Name: name, + Value: val, + DeclaredRoot: pkg.Root, + PyprojectPath: pkg.PyprojectPath, + } + } + + entries := make([]UvLockSourceEntry, 0, len(entryMap)) + for _, e := range entryMap { + entries = append(entries, e) + } + return entries, nil +} + +// validateUvLockSourceEntry validates a single source entry and returns +// the set of workspace dependency names it references. +func validateUvLockSourceEntry( + sourceName string, + sourceValue any, + declaredRoot, pyprojectPath, projectRoot string, + pkgsByName, pkgsByRoot map[string]*UvLockPackage, +) (map[string]bool, error) { + wsDeps := make(map[string]bool) + + // Recurse into lists + if arr, ok := sourceValue.([]any); ok { + for _, item := range arr { + sub, err := validateUvLockSourceEntry( + sourceName, item, declaredRoot, pyprojectPath, projectRoot, + pkgsByName, pkgsByRoot) + if err != nil { + return nil, err + } + for k := range sub { + wsDeps[k] = true + } + } + return wsDeps, nil + } + + m, ok := sourceValue.(map[string]any) + if !ok { + return wsDeps, nil + } + + normalizedSourceName := normalizePackageName(sourceName) + + // workspace = true + if ws, ok := m["workspace"]; ok && ws == true { + pkg := pkgsByName[normalizedSourceName] + if pkg == nil { + return nil, fmt.Errorf( + "'%s' in %s is marked as `{ workspace = true }` but no matching "+ + "workspace package was found under project_root (%s). Check that "+ + "'%s' appears in [tool.uv.workspace].members.", + sourceName, pyprojectPath, projectRoot, sourceName) + } + enabled, err := getUvLockPackageEnabled(pkg) + if err != nil { + return nil, err + } + if !enabled { + return nil, fmt.Errorf( + "'%s' in %s is a workspace dependency but sets "+ + "`tool.uv.package = false` in %s. "+ + "Workspace dependencies must be buildable packages.", + sourceName, pyprojectPath, pkg.PyprojectPath) + } + wsDeps[pkg.NormalizedName] = true + } + + // path = "..." + if pathRef, ok := m["path"]; ok { + pathStr, ok := pathRef.(string) + if ok { + if filepath.IsAbs(pathStr) || strings.HasPrefix(pathStr, "/") { + return nil, fmt.Errorf( + "'%s' in %s uses an absolute path (%s), which is not supported. "+ + "Use a relative path or `{ workspace = true }` instead.", + sourceName, pyprojectPath, pathStr) + } + + resolved, _ := filepath.Abs(filepath.Join(declaredRoot, pathStr)) + resolved = filepath.Clean(resolved) + if !isEqualOrChild(resolved, projectRoot) { + return nil, fmt.Errorf( + "'%s' in %s uses a path source that resolves to %s, "+ + "which is outside project_root (%s).", + sourceName, pyprojectPath, resolved, projectRoot) + } + + pkg := pkgsByRoot[resolved] + if pkg == nil { + return nil, fmt.Errorf( + "'%s' in %s uses a path source that resolves to %s, "+ + "which is not a workspace package under project_root (%s).", + sourceName, pyprojectPath, resolved, projectRoot) + } + if pkg.NormalizedName != normalizedSourceName { + return nil, fmt.Errorf( + "'%s' in %s points to %s, which defines package '%s'. "+ + "The dependency name and the workspace package name must match.", + sourceName, pyprojectPath, resolved, pkg.Name) + } + enabled, err := getUvLockPackageEnabled(pkg) + if err != nil { + return nil, err + } + if !enabled { + return nil, fmt.Errorf( + "'%s' in %s resolves to workspace package '%s', which sets "+ + "`tool.uv.package = false` in %s. "+ + "Workspace dependencies must be buildable packages.", + sourceName, pyprojectPath, pkg.Name, pkg.PyprojectPath) + } + wsDeps[pkg.NormalizedName] = true + } + } + + // Recurse into other dict values + for key, val := range m { + if key == "workspace" || key == "path" { + continue + } + sub, err := validateUvLockSourceEntry( + sourceName, val, declaredRoot, pyprojectPath, projectRoot, + pkgsByName, pkgsByRoot) + if err != nil { + return nil, err + } + for k := range sub { + wsDeps[k] = true + } + } + + return wsDeps, nil +} + +// validateUvLockPackage validates a package's dependencies and source entries. +func validateUvLockPackage( + pkg *UvLockPackage, + projectRoot, rootPyprojectPath string, + rawRootSourceEntries any, + pkgsByName, pkgsByRoot map[string]*UvLockPackage, +) error { + depNames, err := getDependencyNames( + pkg.RawDependencySpecs, pkg.Name, pkg.PyprojectPath) + if err != nil { + return err + } + depNameSet := make(map[string]bool, len(depNames)) + for _, n := range depNames { + depNameSet[n] = true + } + + wsDeps := make(map[string]bool) + sourceEntries, err := getUvLockSourceEntries( + pkg, projectRoot, rootPyprojectPath, rawRootSourceEntries) + if err != nil { + return err + } + + for _, entry := range sourceEntries { + if !depNameSet[normalizePackageName(entry.Name)] { + continue + } + sub, err := validateUvLockSourceEntry( + entry.Name, entry.Value, + entry.DeclaredRoot, entry.PyprojectPath, projectRoot, + pkgsByName, pkgsByRoot) + if err != nil { + return err + } + for k := range sub { + wsDeps[k] = true + } + } + + enabled, err := getUvLockPackageEnabled(pkg) + if err != nil { + return err + } + pkg.PackageEnabled = enabled + pkg.DependencyNames = depNames + + // Filter to workspace deps preserving dependency order + var wsDepOrdered []string + for _, n := range depNames { + if wsDeps[n] { + wsDepOrdered = append(wsDepOrdered, n) + } + } + pkg.WorkspaceDependencies = wsDepOrdered + return nil +} + +// --------------------------------------------------------------------------- +// Workspace discovery +// --------------------------------------------------------------------------- + +// discoverWorkspacePackages parses the root pyproject.toml, globs workspace +// members, and returns the full UvLockWorkspace. +func discoverWorkspacePackages(projectRoot, pyprojectPath string) (*UvLockWorkspace, error) { + rootData, err := loadPyproject(pyprojectPath) + if err != nil { + return nil, err + } + + rootSourceEntries := getNestedValue(rootData, "tool", "uv", "sources") + if rootSourceEntries == nil { + rootSourceEntries = map[string]any{} + } + + var candidateRoots []string + + // Root project itself (if it has [project].name) + rootProject := getNestedMap(rootData, "project") + if name, _ := rootProject["name"].(string); name != "" { + candidateRoots = append(candidateRoots, projectRoot) + } + + // Workspace members + workspaceMembers := getNestedValue(rootData, "tool", "uv", "workspace", "members") + if workspaceMembers != nil { + membersList, ok := workspaceMembers.([]any) + if !ok { + return nil, fmt.Errorf( + "source.kind 'uv' requires [tool.uv.workspace].members to be a list.") + } + for _, patternAny := range membersList { + pattern, ok := patternAny.(string) + if !ok { + return nil, fmt.Errorf( + "source.kind 'uv' requires every [tool.uv.workspace].members " + + "entry to be a string.") + } + globPattern := filepath.Join(projectRoot, pattern) + matches, err := filepath.Glob(globPattern) + if err != nil { + // Invalid glob pattern; skip + continue + } + sort.Strings(matches) + for _, match := range matches { + info, err := os.Stat(match) + if err != nil { + continue + } + pkgRoot := match + if !info.IsDir() { + pkgRoot = filepath.Dir(match) + } + pkgRoot, _ = filepath.Abs(pkgRoot) + pkgRoot = filepath.Clean(pkgRoot) + pyprojectFile := filepath.Join(pkgRoot, "pyproject.toml") + if fi, err := os.Stat(pyprojectFile); err == nil && !fi.IsDir() { + candidateRoots = append(candidateRoots, pkgRoot) + } + } + } + } + + // Deduplicate while preserving order + seen := make(map[string]bool) + var uniqueRoots []string + for _, r := range candidateRoots { + if !seen[r] { + seen[r] = true + uniqueRoots = append(uniqueRoots, r) + } + } + + // Parse each member + var packages []*UvLockPackage + for _, pkgRoot := range uniqueRoots { + memberPyprojectPath := filepath.Join(pkgRoot, "pyproject.toml") + pyData, err := loadPyproject(memberPyprojectPath) + if err != nil { + return nil, err + } + + projectData := getNestedMap(pyData, "project") + pkgName, _ := projectData["name"].(string) + if pkgName == "" { + return nil, fmt.Errorf( + "source.kind 'uv' requires every workspace package to define "+ + "[project].name in %s.", memberPyprojectPath) + } + + packages = append(packages, &UvLockPackage{ + Name: pkgName, + NormalizedName: normalizePackageName(pkgName), + Root: pkgRoot, + PyprojectPath: memberPyprojectPath, + RawDependencySpecs: projectData["dependencies"], + RawUvTool: getNestedValue(pyData, "tool", "uv"), + }) + } + + pkgsByName := make(map[string]*UvLockPackage, len(packages)) + pkgsByRoot := make(map[string]*UvLockPackage, len(packages)) + for _, pkg := range packages { + if existing, ok := pkgsByName[pkg.NormalizedName]; ok { + return nil, fmt.Errorf( + "source.kind 'uv' requires unique workspace package names, "+ + "but both %s and %s define '%s'.", + existing.PyprojectPath, pkg.PyprojectPath, pkg.Name) + } + pkgsByName[pkg.NormalizedName] = pkg + pkgsByRoot[pkg.Root] = pkg + } + + return &UvLockWorkspace{ + RawRootSourceEntries: rootSourceEntries, + PackagesByName: pkgsByName, + PackagesByRoot: pkgsByRoot, + }, nil +} + +// --------------------------------------------------------------------------- +// Target inference +// --------------------------------------------------------------------------- + +// inferTargetPackage determines which workspace package is the deployment target. +func inferTargetPackage( + configRoot, projectRoot string, + source map[string]any, + pkgsByName, pkgsByRoot map[string]*UvLockPackage, +) (*UvLockPackage, error) { + if pkgNameAny, exists := source["package"]; exists { + pkgName, ok := pkgNameAny.(string) + if !ok || strings.TrimSpace(pkgName) == "" { + return nil, fmt.Errorf("`source.package` must be a non-empty string.") + } + target := pkgsByName[normalizePackageName(pkgName)] + if target == nil { + available := sortedPackageNames(pkgsByName) + return nil, fmt.Errorf( + "Could not find source.package '%s' in the uv project at %s. "+ + "It must match a [project].name from one of the discovered "+ + "packages. Available packages: %s.", + pkgName, projectRoot, available) + } + return target, nil + } + + // Find containing packages (sorted by depth, deepest first) + type pkgWithDepth struct { + pkg *UvLockPackage + depth int + } + var containing []pkgWithDepth + for root, pkg := range pkgsByRoot { + if configRoot == root || isEqualOrChild(configRoot, root) { + parts := strings.Split(root, string(filepath.Separator)) + containing = append(containing, pkgWithDepth{pkg: pkg, depth: len(parts)}) + } + } + sort.Slice(containing, func(i, j int) bool { + return containing[i].depth > containing[j].depth + }) + + if len(containing) > 0 { + target := containing[0].pkg + if target.Root != projectRoot || + len(pkgsByName) == 1 || + configRoot == projectRoot { + return target, nil + } + } + + if len(pkgsByName) == 1 { + for _, pkg := range pkgsByName { + return pkg, nil + } + } + + available := sortedPackageNames(pkgsByName) + return nil, fmt.Errorf( + "source.package is required because source.root resolves to a uv "+ + "workspace with multiple packages and no unique target package could be "+ + "inferred from langgraph.json at %s. Available packages: %s. "+ + "Move langgraph.json into the target package or set source.package.", + configRoot, available) +} + +// --------------------------------------------------------------------------- +// Container path mapping +// --------------------------------------------------------------------------- + +const containerWorkspaceRoot = "/deps/workspace" + +// containerRootForPackage maps a package root to its container path. +func containerRootForPackage(projectRoot, packageRoot string) string { + rel, err := filepath.Rel(projectRoot, packageRoot) + if err != nil || rel == "." { + return containerWorkspaceRoot + } + // Use forward slashes for container paths + return containerWorkspaceRoot + "/" + filepath.ToSlash(rel) +} + +// resolveUvLockContainerPath maps a host path to a container path using the plan. +func resolveUvLockContainerPath(hostPath string, plan *UvLockPlan) string { + // Sort by depth (deepest first) so more specific roots match first + type rootEntry struct { + root string + path string + } + var entries []rootEntry + for root, cpath := range plan.ContainerRoots { + entries = append(entries, rootEntry{root: root, path: cpath}) + } + sort.Slice(entries, func(i, j int) bool { + return len(strings.Split(entries[i].root, string(filepath.Separator))) > + len(strings.Split(entries[j].root, string(filepath.Separator))) + }) + + for _, entry := range entries { + if hostPath == entry.root || isEqualOrChild(hostPath, entry.root) { + // Guard against workspace root matching unrelated members + if len(plan.AllWorkspaceRoots) > 0 && + pathInUnrelatedMember(hostPath, entry.root, plan) { + continue + } + rel, err := filepath.Rel(entry.root, hostPath) + if err != nil { + continue + } + if rel == "." { + return entry.path + } + return entry.path + "/" + filepath.ToSlash(rel) + } + } + return "" +} + +// pathInUnrelatedMember returns true if hostPath is inside a workspace member +// that is NOT in the closure (container_roots). +func pathInUnrelatedMember(hostPath, matchedRoot string, plan *UvLockPlan) bool { + for wsRoot := range plan.AllWorkspaceRoots { + if wsRoot == matchedRoot { + continue + } + if isEqualOrChild(matchedRoot, wsRoot) { + // matchedRoot is inside wsRoot -> wsRoot is less specific -> skip + continue + } + if hostPath == wsRoot || isEqualOrChild(hostPath, wsRoot) { + if _, inClosure := plan.ContainerRoots[wsRoot]; !inClosure { + return true + } + } + } + return false +} + +// --------------------------------------------------------------------------- +// Copy items for workspace packages +// --------------------------------------------------------------------------- + +// uvLockPackageCopyItems returns (source, destination) pairs for COPY/ADD. +// source is relative to projectRoot; destination is the container path. +func uvLockPackageCopyItems(pkg *UvLockPackage, plan *UvLockPlan) ([][2]string, error) { + if pkg.Root != plan.ProjectRoot { + rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root) + if err != nil { + return nil, err + } + return [][2]string{{filepath.ToSlash(rel), plan.ContainerRoots[pkg.Root]}}, nil + } + + // Root package: enumerate entries, skipping workspace member roots + rootContainer := plan.ContainerRoots[pkg.Root] + wsMemberRoots := make(map[string]bool) + for wsRoot := range plan.AllWorkspaceRoots { + if wsRoot != plan.ProjectRoot { + wsMemberRoots[wsRoot] = true + } + } + + var iterEntries func(currentDir string) ([][2]string, error) + iterEntries = func(currentDir string) ([][2]string, error) { + dirEntries, err := os.ReadDir(currentDir) + if err != nil { + return nil, err + } + // Sort for deterministic output + sort.Slice(dirEntries, func(i, j int) bool { + return dirEntries[i].Name() < dirEntries[j].Name() + }) + + var result [][2]string + for _, entry := range dirEntries { + childPath := filepath.Join(currentDir, entry.Name()) + childAbs, _ := filepath.Abs(childPath) + + if wsMemberRoots[childAbs] { + continue + } + + // Check if any workspace member is a descendant + hasDescendantMember := false + if entry.IsDir() { + for wsRoot := range wsMemberRoots { + if isEqualOrChild(wsRoot, childAbs) && wsRoot != childAbs { + hasDescendantMember = true + break + } + } + } + + if entry.IsDir() && hasDescendantMember { + sub, err := iterEntries(childAbs) + if err != nil { + return nil, err + } + result = append(result, sub...) + continue + } + + relChild, err := filepath.Rel(plan.ProjectRoot, childAbs) + if err != nil { + continue + } + relPosix := filepath.ToSlash(relChild) + result = append(result, [2]string{ + relPosix, + rootContainer + "/" + relPosix, + }) + } + return result, nil + } + + return iterEntries(plan.ProjectRoot) +} + +// --------------------------------------------------------------------------- +// Plan construction +// --------------------------------------------------------------------------- + +// planUvLockWorkspace is the main planning function: resolve paths, discover +// workspace, infer target, validate, build install order, compute container paths. +func planUvLockWorkspace(configPath string, config map[string]any) (*UvLockPlan, error) { + configPathAbs, err := filepath.Abs(configPath) + if err != nil { + return nil, err + } + configRoot := filepath.Dir(configPathAbs) + + source, _ := config["source"].(map[string]any) + root := "." + if r, ok := source["root"].(string); ok && strings.TrimSpace(r) != "" { + root = r + } + + projectRoot, _ := filepath.Abs(filepath.Join(configRoot, root)) + projectRoot = filepath.Clean(projectRoot) + pyprojectPath := filepath.Join(projectRoot, "pyproject.toml") + uvLockPath := filepath.Join(projectRoot, "uv.lock") + + if _, err := os.Stat(uvLockPath); os.IsNotExist(err) { + return nil, fmt.Errorf( + "No uv.lock found at %s. Your langgraph.json sets "+ + "source.root=%q, which resolves to "+ + "%s. Make sure this is the directory where you run "+ + "`uv lock` (it should contain both pyproject.toml and uv.lock).", + uvLockPath, root, projectRoot) + } + if _, err := os.Stat(pyprojectPath); os.IsNotExist(err) { + return nil, fmt.Errorf( + "No pyproject.toml found at %s. Your langgraph.json "+ + "sets source.root=%q, which resolves to "+ + "%s. This should be your uv workspace root.", + pyprojectPath, root, projectRoot) + } + + workspace, err := discoverWorkspacePackages(projectRoot, pyprojectPath) + if err != nil { + return nil, err + } + + target, err := inferTargetPackage( + configRoot, projectRoot, source, + workspace.PackagesByName, workspace.PackagesByRoot) + if err != nil { + return nil, err + } + + if err := validateUvLockPackage( + target, projectRoot, pyprojectPath, + workspace.RawRootSourceEntries, + workspace.PackagesByName, workspace.PackagesByRoot, + ); err != nil { + return nil, err + } + + if !target.PackageEnabled { + return nil, fmt.Errorf( + "'%s' has `tool.uv.package = false` in %s, so it cannot be "+ + "deployed. Either remove that setting or point `source.package` "+ + "at a different workspace member.", + target.Name, target.PyprojectPath) + } + + // Build install order via DFS + var installOrder []*UvLockPackage + visited := make(map[string]bool) + validated := map[string]bool{target.NormalizedName: true} + + var visit func(pkg *UvLockPackage) error + visit = func(pkg *UvLockPackage) error { + if visited[pkg.NormalizedName] { + return nil + } + visited[pkg.NormalizedName] = true + + if !validated[pkg.NormalizedName] { + if err := validateUvLockPackage( + pkg, projectRoot, pyprojectPath, + workspace.RawRootSourceEntries, + workspace.PackagesByName, workspace.PackagesByRoot, + ); err != nil { + return err + } + validated[pkg.NormalizedName] = true + } + + for _, depName := range pkg.WorkspaceDependencies { + dep := workspace.PackagesByName[depName] + if dep != nil { + if err := visit(dep); err != nil { + return err + } + } + } + installOrder = append(installOrder, pkg) + return nil + } + + if err := visit(target); err != nil { + return nil, err + } + + // Container roots for the install closure + containerRoots := make(map[string]string, len(installOrder)) + for _, pkg := range installOrder { + containerRoots[pkg.Root] = containerRootForPackage(projectRoot, pkg.Root) + } + + // All workspace roots (for exclusion logic) + allWsRoots := make(map[string]bool) + for _, pkg := range workspace.PackagesByName { + allWsRoots[pkg.Root] = true + } + + // Determine working dir by resolving configRoot + tempPlan := &UvLockPlan{ + ProjectRoot: projectRoot, + PyprojectPath: pyprojectPath, + UvLockPath: uvLockPath, + Target: target, + TargetRoot: target.Root, + InstallOrder: installOrder, + ContainerRoots: containerRoots, + WorkingDir: containerRootForPackage(projectRoot, target.Root), + AllWorkspaceRoots: allWsRoots, + } + workingDir := resolveUvLockContainerPath(configRoot, tempPlan) + if workingDir == "" { + workingDir = containerRoots[target.Root] + } + + return &UvLockPlan{ + ProjectRoot: projectRoot, + PyprojectPath: pyprojectPath, + UvLockPath: uvLockPath, + Target: target, + TargetRoot: target.Root, + InstallOrder: installOrder, + ContainerRoots: containerRoots, + WorkingDir: workingDir, + AllWorkspaceRoots: allWsRoots, + }, nil +} + +// --------------------------------------------------------------------------- +// Import path rewriting +// --------------------------------------------------------------------------- + +// rewriteUvLockImportPath rewrites a "module:attr" import string so that the +// module path points to the correct container location. +func rewriteUvLockImportPath( + configPath, importStr string, plan *UvLockPlan, label string, +) (string, error) { + parts := strings.SplitN(importStr, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf( + "Import string %q must be in format \":\".", + importStr) + } + moduleStr := parts[0] + attrStr := parts[1] + + if !strings.Contains(moduleStr, "/") && !strings.Contains(moduleStr, "\\") { + return importStr, nil + } + + configDir := filepath.Dir(configPath) + resolved, _ := filepath.Abs(filepath.Join(configDir, moduleStr)) + resolved = filepath.Clean(resolved) + + info, err := os.Stat(resolved) + if err != nil { + return "", fmt.Errorf("Could not find %s: %s", label, resolved) + } + if info.IsDir() { + return "", fmt.Errorf("%s must be a file: %s", + strings.ToUpper(label[:1])+label[1:], resolved) + } + + containerPath := resolveUvLockContainerPath(resolved, plan) + if containerPath == "" { + var copiedDirs []string + for _, pkg := range plan.InstallOrder { + rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root) + if err != nil || rel == "." { + copiedDirs = append(copiedDirs, ".") + } else { + copiedDirs = append(copiedDirs, filepath.ToSlash(rel)) + } + } + return "", fmt.Errorf( + "%s '%s' resolves to %s, which is not inside the target "+ + "package '%s' or any of its workspace dependencies. Only these "+ + "directories are copied into the container: %s. If this file "+ + "lives in another workspace package, add it as a dependency of "+ + "'%s' with `{ workspace = true }` in [tool.uv.sources].", + strings.ToUpper(label[:1])+label[1:], + importStr, resolved, plan.Target.Name, + strings.Join(copiedDirs, ", "), plan.Target.Name) + } + + return containerPath + ":" + attrStr, nil +} + +// updateUvLockGraphPaths rewrites graph import paths for uv-lock mode. +func updateUvLockGraphPaths(configPath string, config map[string]any, plan *UvLockPlan) error { + graphs, _ := config["graphs"].(map[string]any) + for graphID, data := range graphs { + switch v := data.(type) { + case map[string]any: + pathStr, ok := v["path"].(string) + if !ok || pathStr == "" { + return fmt.Errorf( + "Graph '%s' must contain a 'path' key if it is a dictionary.", + graphID) + } + rewritten, err := rewriteUvLockImportPath( + configPath, pathStr, plan, fmt.Sprintf("graph '%s'", graphID)) + if err != nil { + return err + } + v["path"] = rewritten + case string: + rewritten, err := rewriteUvLockImportPath( + configPath, v, plan, fmt.Sprintf("graph '%s'", graphID)) + if err != nil { + return err + } + graphs[graphID] = rewritten + default: + return fmt.Errorf( + "Graph '%s' must be a string or a dictionary with a 'path' key.", + graphID) + } + } + return nil +} + +// updateUvLockComponentPath rewrites a single section.key import path. +func updateUvLockComponentPath( + configPath string, config map[string]any, plan *UvLockPlan, + section, key, label string, +) error { + sectionMap, ok := config[section].(map[string]any) + if !ok { + return nil + } + pathStr, ok := sectionMap[key].(string) + if !ok || pathStr == "" { + return nil + } + rewritten, err := rewriteUvLockImportPath(configPath, pathStr, plan, label) + if err != nil { + return err + } + sectionMap[key] = rewritten + return nil +} + +// updateUvLockUIPaths rewrites UI file paths for uv-lock mode. +func updateUvLockUIPaths(configPath string, config map[string]any, plan *UvLockPlan) error { + ui, ok := config["ui"].(map[string]any) + if !ok { + return nil + } + + configDir := filepath.Dir(configPath) + for uiName, pathAny := range ui { + pathStr, ok := pathAny.(string) + if !ok { + continue + } + + resolved, _ := filepath.Abs(filepath.Join(configDir, pathStr)) + resolved = filepath.Clean(resolved) + + info, err := os.Stat(resolved) + if err != nil { + return fmt.Errorf("Could not find ui '%s': %s", uiName, resolved) + } + if info.IsDir() { + return fmt.Errorf("Ui '%s' must be a file: %s", uiName, resolved) + } + + containerPath := resolveUvLockContainerPath(resolved, plan) + if containerPath == "" { + var copiedDirs []string + for _, pkg := range plan.InstallOrder { + rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root) + if err != nil || rel == "." { + copiedDirs = append(copiedDirs, ".") + } else { + copiedDirs = append(copiedDirs, filepath.ToSlash(rel)) + } + } + return fmt.Errorf( + "Ui '%s' resolves to %s, which is not inside the target "+ + "package '%s' or any of its workspace dependencies. Only these "+ + "directories are copied into the container: %s. If this file "+ + "lives in another workspace package, add it as a dependency of "+ + "'%s' with `{ workspace = true }` in [tool.uv.sources].", + uiName, resolved, plan.Target.Name, + strings.Join(copiedDirs, ", "), plan.Target.Name) + } + + ui[uiName] = containerPath + } + return nil +} + +// --------------------------------------------------------------------------- +// Dockerfile generation +// --------------------------------------------------------------------------- + +// PythonConfigToDockerUVLock generates a Dockerfile and additional build contexts +// for a Python-based LangGraph configuration using uv lock mode. +func PythonConfigToDockerUVLock( + configPath string, + config map[string]any, + baseImage string, + apiVersion string, + buildToolsToUninstall []string, +) (string, map[string]string, error) { + + if !ImageSupportsUV(baseImage) { + return "", nil, fmt.Errorf( + "source.kind 'uv' requires a base image with uv support " + + "(langchain/langgraph-api >= 0.2.47)") + } + + configPathAbs, _ := filepath.Abs(configPath) + configRoot := filepath.Dir(configPathAbs) + + installCmd := "uv pip install --system" + _, globalReqsPipInstall, pipConfigFileStr := buildPythonInstallCommands(config, installCmd) + + plan, err := planUvLockWorkspace(configPath, config) + if err != nil { + return "", nil, err + } + + // Rewrite graph paths + if err := updateUvLockGraphPaths(configPathAbs, config, plan); err != nil { + return "", nil, err + } + + // Rewrite component paths + for _, sc := range [][3]string{ + {"auth", "path", "auth.path"}, + {"encryption", "path", "encryption.path"}, + {"checkpointer", "path", "checkpointer.path"}, + {"http", "app", "http.app"}, + } { + if err := updateUvLockComponentPath( + configPathAbs, config, plan, sc[0], sc[1], sc[2]); err != nil { + return "", nil, err + } + } + + // Rewrite UI paths + if err := updateUvLockUIPaths(configPathAbs, config, plan); err != nil { + return "", nil, err + } + + // Additional contexts + additionalContexts := make(map[string]string) + var workspaceContextName string + if plan.ProjectRoot != configRoot && !isEqualOrChild(plan.ProjectRoot, configRoot) { + workspaceContextName = "uv-workspace-root" + additionalContexts[workspaceContextName] = plan.ProjectRoot + } + + copyFromProjectRoot := func(relativePath, destination string) string { + if workspaceContextName != "" { + source := relativePath + if source == "" || source == "." { + source = "." + } + return fmt.Sprintf("COPY --from=%s %s %s", workspaceContextName, source, destination) + } + sourcePath := filepath.Join(plan.ProjectRoot, relativePath) + relSource, _ := filepath.Rel(configRoot, sourcePath) + return fmt.Sprintf("ADD %s %s", filepath.ToSlash(relSource), destination) + } + + uvExportProjectDir := "/tmp/uv_export/project" + envVars := BuildRuntimeEnvVars(config) + imageStr := DockerTag(config, baseImage, apiVersion) + + var lines []string + + if len(additionalContexts) > 0 { + lines = append(lines, "# syntax=docker/dockerfile:1.4", "") + } + + lines = append(lines, fmt.Sprintf("FROM %s", imageStr), "") + + // dockerfile_lines + dfLines := configSlice(config, "dockerfile_lines") + if len(dfLines) > 0 { + for _, l := range dfLines { + if s, ok := l.(string); ok && s != "" { + lines = append(lines, s) + } + } + lines = append(lines, "") + } + + // install node + nv, _ := config["node_version"].(string) + if (config["ui"] != nil || nv != "") && plan.WorkingDir != "" { + lines = append(lines, "RUN /storage/install-node.sh", "") + } + + // pip config + if pipConfigFileStr != "" { + lines = append(lines, pipConfigFileStr, "") + } + + // -- Installing dependencies from uv.lock -- + lines = append(lines, "# -- Installing dependencies from uv.lock --") + lines = append(lines, + copyFromProjectRoot("pyproject.toml", uvExportProjectDir+"/pyproject.toml")) + lines = append(lines, + copyFromProjectRoot("uv.lock", uvExportProjectDir+"/uv.lock")) + + // Copy workspace member pyproject.toml files into the export dir + // so that `uv export --package` can resolve workspace dependencies. + for _, pkg := range plan.InstallOrder { + if pkg.Root == plan.ProjectRoot { + continue + } + rel, err := filepath.Rel(plan.ProjectRoot, pkg.PyprojectPath) + if err != nil { + continue + } + relPosix := filepath.ToSlash(rel) + lines = append(lines, + copyFromProjectRoot(relPosix, uvExportProjectDir+"/"+relPosix)) + } + + lines = append(lines, fmt.Sprintf("WORKDIR %s", uvExportProjectDir)) + + quotedName := shellQuote(plan.Target.Name) + lines = append(lines, fmt.Sprintf("RUN uv export --package %s --frozen --no-hashes --no-emit-project --no-emit-workspace -o uv_requirements.txt", quotedName)) + lines = append(lines, fmt.Sprintf("RUN %s -r uv_requirements.txt", globalReqsPipInstall)) + lines = append(lines, "RUN rm -rf /tmp/uv_export") + lines = append(lines, "# -- End of uv.lock dependencies install --", "") + + // Add workspace packages in install order + for _, pkg := range plan.InstallOrder { + rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root) + if err != nil { + rel = "." + } + packageLabel := filepath.ToSlash(rel) + if packageLabel == "." { + packageLabel = "." + } + + lines = append(lines, fmt.Sprintf("# -- Adding workspace package %s --", packageLabel)) + + copyItems, err := uvLockPackageCopyItems(pkg, plan) + if err != nil { + return "", nil, err + } + for _, item := range copyItems { + lines = append(lines, copyFromProjectRoot(item[0], item[1])) + } + + lines = append(lines, fmt.Sprintf("WORKDIR %s", plan.ContainerRoots[pkg.Root])) + lines = append(lines, fmt.Sprintf("RUN %s --no-deps -e .", globalReqsPipInstall)) + lines = append(lines, fmt.Sprintf("# -- End of workspace package %s --", packageLabel), "") + } + + // env vars + if len(envVars) > 0 { + lines = append(lines, envVars...) + lines = append(lines, "") + } + + // JS install + if (config["ui"] != nil || nv != "") && plan.WorkingDir != "" { + nodeVer := nv + if nodeVer == "" { + nodeVer = DefaultNodeVersion + } + lines = append(lines, + "# -- Installing JS dependencies --", + fmt.Sprintf("ENV NODE_VERSION=%s", nodeVer), + fmt.Sprintf("WORKDIR %s", plan.WorkingDir), + fmt.Sprintf("RUN %s && tsx /api/langgraph_api/js/build.mts", + GetNodePMInstallCmd(plan.TargetRoot)), + "# -- End of JS dependencies install --", + "", + ) + } + + // pip cleanup + lines = append(lines, + GetPipCleanupLines(installCmd, buildToolsToUninstall, "uv"), + "", + ) + + // working dir + if plan.WorkingDir != "" { + lines = append(lines, fmt.Sprintf("WORKDIR %s", plan.WorkingDir)) + } + + return strings.Join(lines, "\n"), additionalContexts, nil +} + +// --------------------------------------------------------------------------- +// Utility helpers +// --------------------------------------------------------------------------- + +// isEqualOrChild returns true if child equals parent or is a subdirectory of parent. +func isEqualOrChild(child, parent string) bool { + if child == parent { + return true + } + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return !strings.HasPrefix(rel, "..") +} + +// sortedPackageNames returns a sorted comma-separated list of package names, +// or "(none)" if the map is empty. +func sortedPackageNames(pkgsByName map[string]*UvLockPackage) string { + if len(pkgsByName) == 0 { + return "(none)" + } + names := make([]string, 0, len(pkgsByName)) + for _, pkg := range pkgsByName { + names = append(names, pkg.Name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} diff --git a/libs/cli/internal/config/uvlock_test.go b/libs/cli/internal/config/uvlock_test.go new file mode 100644 index 000000000..392645cf2 --- /dev/null +++ b/libs/cli/internal/config/uvlock_test.go @@ -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") +} diff --git a/libs/cli/internal/deploy/helpers.go b/libs/cli/internal/deploy/helpers.go index d7edb7a99..f92a05ad0 100644 --- a/libs/cli/internal/deploy/helpers.go +++ b/libs/cli/internal/deploy/helpers.go @@ -7,6 +7,7 @@ import ( "path/filepath" "regexp" "strings" + "time" ) // APIKeyEnvNames lists the environment variable names checked (in order) when @@ -251,6 +252,43 @@ func ResolvedReservedEnvVars() map[string]bool { 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 { diff --git a/libs/cli/internal/deploy/helpers_test.go b/libs/cli/internal/deploy/helpers_test.go new file mode 100644 index 000000000..765f7a6a1 --- /dev/null +++ b/libs/cli/internal/deploy/helpers_test.go @@ -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) + } +} diff --git a/libs/cli/internal/docker/docker.go b/libs/cli/internal/docker/docker.go index d49205552..e8b1dc1e6 100644 --- a/libs/cli/internal/docker/docker.go +++ b/libs/cli/internal/docker/docker.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/langchain-ai/langgraph/libs/cli/internal/config" ) // DefaultPostgresURI is the default connection string used when no custom @@ -496,9 +498,21 @@ func BuildDockerImage(opts BuildImageOpts) error { } } - // Generate the Dockerfile. This is a placeholder that will call into - // the config package once ConfigToDocker is implemented. - dockerfile := generateDockerfileStub(opts) + // 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...) @@ -513,14 +527,3 @@ func BuildDockerImage(opts BuildImageOpts) error { return cmd.Run() } - -// generateDockerfileStub is a placeholder that will be replaced by a call to -// config.ConfigToDocker once that function is implemented in the config package. -func generateDockerfileStub(opts BuildImageOpts) string { - // Placeholder: produce a minimal Dockerfile from the base image. - base := opts.BaseImage - if base == "" { - base = "langchain/langgraph-api" - } - return fmt.Sprintf("FROM %s\n", base) -} diff --git a/libs/cli/internal/root/root.go b/libs/cli/internal/root/root.go index 234603b08..e5dead1a6 100644 --- a/libs/cli/internal/root/root.go +++ b/libs/cli/internal/root/root.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/langchain-ai/langgraph/libs/cli/internal/config" "github.com/langchain-ai/langgraph/libs/cli/internal/deploy" @@ -596,8 +597,10 @@ Options: EngineRuntimeMode: flags.engineRuntimeMode, }) - // Merge compose: infra + app overlay - fullCompose := infraYAML + composeSnippet + // Wrap the app config snippet into a valid compose overlay YAML. + // ConfigToCompose returns content indented at the service-property level + // (8 spaces), so we wrap it in the correct compose structure. + appOverlayYAML := "services:\n langgraph-api:" + composeSnippet // Pull if flags.pull && flags.image == "" { @@ -606,15 +609,25 @@ Options: _ = lgexec.Run("docker", []string{"pull", tag}, lgexec.RunOpts{Verbose: flags.verbose}) } - // Write compose to temp file and run - tmpFile, err := os.CreateTemp("", "langgraph-compose-*.yml") + // Write infrastructure compose and app overlay to separate temp files. + // Docker compose handles merging when given multiple -f flags. + tmpInfra, err := os.CreateTemp("", "langgraph-infra-*.yml") if err != nil { errPrint(stderr, err.Error()) return 1 } - defer os.Remove(tmpFile.Name()) - _, _ = tmpFile.WriteString(fullCompose) - tmpFile.Close() + defer os.Remove(tmpInfra.Name()) + _, _ = tmpInfra.WriteString(infraYAML) + tmpInfra.Close() + + tmpApp, err := os.CreateTemp("", "langgraph-app-*.yml") + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + defer os.Remove(tmpApp.Name()) + _, _ = tmpApp.WriteString(appOverlayYAML) + tmpApp.Close() composeCmd := "docker" composeArgs := []string{"compose"} @@ -623,7 +636,7 @@ Options: composeArgs = nil } - upArgs := append(composeArgs, "-f", tmpFile.Name(), "up") + upArgs := append(composeArgs, "-f", tmpInfra.Name(), "-f", tmpApp.Name(), "up") if flags.wait { upArgs = append(upArgs, "--wait") } else { @@ -926,7 +939,6 @@ Options: _ = tag _ = deploymentType - _ = noWait _ = verbose _ = remote _ = name @@ -1027,6 +1039,21 @@ Options: return 1 } + if !noWait { + _, _ = fmt.Fprintln(stdout, "Waiting for deployment...") + finalStatus, pollErr := deploy.PollDeploymentStatus(client, depID, 300, 2, func(status string) { + _, _ = fmt.Fprintf(stdout, " Status: %s\n", status) + }) + if pollErr != nil { + errPrint(stderr, pollErr.Error()) + return 1 + } + if finalStatus != "DEPLOYED" { + errPrint(stderr, fmt.Sprintf("Deployment failed with status: %s", finalStatus)) + return 1 + } + } + _, _ = fmt.Fprintf(stdout, "%sDeployment updated successfully!%s\n", colorGreen, colorReset) return 0 } @@ -1340,7 +1367,65 @@ Options: payload["query"] = query } - _ = follow // TODO: implement follow mode with polling + if follow { + payload["order"] = "asc" + seen := make(map[string]bool) + for { + var resp map[string]any + var err error + if logType == "build" { + if revisionID == "" { + revResp, rerr := client.ListRevisions(deploymentID, 1) + if rerr != nil { + errPrint(stderr, rerr.Error()) + return 1 + } + revisions, _ := revResp["revisions"].([]any) + if len(revisions) > 0 { + rev, _ := revisions[0].(map[string]any) + revisionID, _ = rev["id"].(string) + } + } + if revisionID == "" { + time.Sleep(2 * time.Second) + continue + } + resp, err = client.GetBuildLogs(deploymentID, revisionID, payload) + } else { + resp, err = client.GetDeployLogs(deploymentID, payload, revisionID) + } + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + logs, _ := resp["logs"].([]any) + for _, l := range logs { + entry, _ := l.(map[string]any) + id, _ := entry["id"].(string) + if id == "" { + // Fall back to timestamp+message as key + ts, _ := entry["timestamp"].(string) + msg, _ := entry["message"].(string) + id = ts + "|" + msg + } + if seen[id] { + continue + } + seen[id] = true + ts, _ := entry["timestamp"].(string) + lvl, _ := entry["level"].(string) + msg, _ := entry["message"].(string) + if ts != "" { + _, _ = fmt.Fprintf(stdout, "[%s] ", ts) + } + if lvl != "" { + _, _ = fmt.Fprintf(stdout, "[%s] ", lvl) + } + _, _ = fmt.Fprintln(stdout, msg) + } + time.Sleep(2 * time.Second) + } + } var resp map[string]any var err error diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 4b8a37ec7..ed4b3aedf 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -61,6 +61,9 @@ default-groups = ['dev'] [tool.hatch.build.targets.wheel] include = ["langgraph_cli"] +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + [tool.pytest.ini_options] addopts = "--strict-markers --strict-config --durations=5 -vv" asyncio_mode = "auto"