Files
2026-04-08 05:40:59 -07:00

1493 lines
43 KiB
Go

package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// PipReq represents a (hostPath, containerPath) pair for a requirements.txt.
type PipReq struct {
HostPath string
ContainerPath string
}
// RealPkg represents a real Python package (has pyproject.toml or setup.py).
type RealPkg struct {
RelPath string
ContainerName string
}
// FauxPkg represents a directory without packaging metadata (faux package).
type FauxPkg struct {
RelPath string
ContainerPath string
}
// LocalDeps holds all resolved local dependency information for Dockerfile generation.
type LocalDeps struct {
PipReqs []PipReq
RealPkgs map[string]RealPkg // hostPath -> RealPkg
FauxPkgs map[string]FauxPkg // hostPath -> FauxPkg
WorkingDir string // "" if not set
AdditionalContexts []string // resolved paths needing extra build contexts
}
// DockerOpts groups options for ConfigToDocker.
type DockerOpts struct {
BaseImage string
APIVersion string
InstallCommand string
BuildCommand string
BuildContext string
EscapeVariables bool
}
// ComposeOpts groups options for ConfigToCompose.
type ComposeOpts struct {
BaseImage string
APIVersion string
Image string
Watch bool
EngineRuntimeMode string
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
var buildTools = []string{"pip", "setuptools", "wheel"}
var reservedPackageNames = map[string]bool{
"src": true,
"langgraph-api": true,
"langgraph_api": true,
"langgraph": true,
"langchain-core": true,
"langchain_core": true,
"pydantic": true,
"orjson": true,
"fastapi": true,
"uvicorn": true,
"psycopg": true,
"httpx": true,
"langsmith": true,
}
var semverPattern = regexp.MustCompile(`:(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)`)
// ---------------------------------------------------------------------------
// 1. DefaultBaseImage
// ---------------------------------------------------------------------------
// DefaultBaseImage returns the base Docker image for a config.
func DefaultBaseImage(config map[string]any, engineRuntimeMode string) string {
if bi, _ := config["base_image"].(string); bi != "" {
return bi
}
nv, _ := config["node_version"].(string)
pv, _ := config["python_version"].(string)
if nv != "" && pv == "" {
return "langchain/langgraphjs-api"
}
if engineRuntimeMode == "distributed" {
return "langchain/langgraph-executor"
}
return "langchain/langgraph-api"
}
// ---------------------------------------------------------------------------
// 2. DockerTag
// ---------------------------------------------------------------------------
// DockerTag computes the full image:tag string for a config.
func DockerTag(config map[string]any, baseImage, apiVersion string) string {
if apiVersion == "" {
apiVersion, _ = config["api_version"].(string)
}
if baseImage == "" {
baseImage = DefaultBaseImage(config, "combined_queue_worker")
}
imageDistro, _ := config["image_distro"].(string)
distroTag := ""
if imageDistro != "" && imageDistro != DefaultImageDistro {
distroTag = "-" + imageDistro
}
if tag, _ := config["_INTERNAL_docker_tag"].(string); tag != "" {
return baseImage + ":" + tag
}
nv, _ := config["node_version"].(string)
pv, _ := config["python_version"].(string)
var language, version string
if nv != "" && pv == "" {
language = "node"
version = nv
} else {
language = "py"
version = pv
}
versionDistroTag := version + distroTag
if apiVersion != "" {
fullTag := apiVersion + "-" + language + versionDistroTag
return baseImage + ":" + fullTag
}
if strings.Contains(baseImage, "/langgraph-server") && !strings.Contains(baseImage, versionDistroTag) {
return baseImage + "-" + language + versionDistroTag
}
return baseImage + ":" + versionDistroTag
}
// ---------------------------------------------------------------------------
// 4. AssembleLocalDeps
// ---------------------------------------------------------------------------
// AssembleLocalDeps inspects the config's dependencies list and classifies
// each local (dot-prefixed) dependency as a real package, faux package, etc.
func AssembleLocalDeps(configPath string, config map[string]any) (*LocalDeps, error) {
configPath, err := filepath.Abs(configPath)
if err != nil {
return nil, err
}
configDir := filepath.Dir(configPath)
reserved := make(map[string]bool)
for k, v := range reservedPackageNames {
reserved[k] = v
}
checkReserved := func(name, ref string) error {
if reserved[name] {
return fmt.Errorf(
"Package name '%s' used in local dep '%s' is reserved. "+
"Rename the directory.", name, ref)
}
reserved[name] = true
return nil
}
counter := map[string]int{}
var pipReqs []PipReq
realPkgs := map[string]RealPkg{}
fauxPkgs := map[string]FauxPkg{}
workingDir := ""
var additionalContexts []string
deps := configSlice(config, "dependencies")
for _, depAny := range deps {
localDep, ok := depAny.(string)
if !ok || !strings.HasPrefix(localDep, ".") {
continue
}
resolved, err := filepath.Abs(filepath.Join(configDir, localDep))
if err != nil {
return nil, err
}
info, err := os.Stat(resolved)
if err != nil {
return nil, fmt.Errorf("Could not find local dependency: %s", resolved)
}
if !info.IsDir() {
return nil, fmt.Errorf("Local dependency must be a directory: %s", resolved)
}
// Check if resolved is same as configDir or a child
if resolved != configDir {
// Check if configDir is a parent of resolved
rel, relErr := filepath.Rel(configDir, resolved)
if relErr != nil || strings.HasPrefix(rel, "..") {
additionalContexts = append(additionalContexts, resolved)
}
}
entries, err := os.ReadDir(resolved)
if err != nil {
return nil, err
}
fileNames := make(map[string]bool)
for _, e := range entries {
fileNames[e.Name()] = true
}
if fileNames["pyproject.toml"] || fileNames["setup.py"] {
// Real package
containerName := filepath.Base(resolved)
if counter[containerName] > 0 {
containerName = fmt.Sprintf("%s_%d", containerName, counter[containerName])
}
counter[containerName]++
realPkgs[resolved] = RealPkg{RelPath: localDep, ContainerName: containerName}
if localDep == "." {
workingDir = "/deps/" + containerName
}
} else {
baseName := filepath.Base(resolved)
var containerPath string
if fileNames["__init__.py"] {
// Flat layout
if strings.Contains(baseName, "-") {
return nil, fmt.Errorf(
"Package name '%s' contains a hyphen. "+
"Rename the directory to use it as flat-layout package.",
baseName)
}
if err := checkReserved(baseName, localDep); err != nil {
return nil, err
}
containerPath = fmt.Sprintf("/deps/outer-%s/%s", baseName, baseName)
} else {
// Src layout
containerPath = fmt.Sprintf("/deps/outer-%s/src", baseName)
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == "__pycache__" || strings.HasPrefix(entry.Name(), ".") {
continue
}
subDir := filepath.Join(resolved, entry.Name())
subEntries, subErr := os.ReadDir(subDir)
if subErr != nil {
continue // permission error etc.
}
for _, sf := range subEntries {
if strings.HasSuffix(sf.Name(), ".py") {
if err := checkReserved(entry.Name(), localDep); err != nil {
return nil, err
}
break
}
}
}
}
fauxPkgs[resolved] = FauxPkg{RelPath: localDep, ContainerPath: containerPath}
if localDep == "." {
workingDir = containerPath
}
if fileNames["requirements.txt"] {
rfile := filepath.Join(resolved, "requirements.txt")
pipReqs = append(pipReqs, PipReq{
HostPath: rfile,
ContainerPath: containerPath + "/requirements.txt",
})
}
}
}
return &LocalDeps{
PipReqs: pipReqs,
RealPkgs: realPkgs,
FauxPkgs: fauxPkgs,
WorkingDir: workingDir,
AdditionalContexts: additionalContexts,
}, nil
}
// ---------------------------------------------------------------------------
// 5. UpdateGraphPaths
// ---------------------------------------------------------------------------
// UpdateGraphPaths remaps each graph's import path to the correct in-container path.
func UpdateGraphPaths(configPath string, config map[string]any, deps *LocalDeps) error {
configPath, _ = filepath.Abs(configPath)
configDir := filepath.Dir(configPath)
graphs, _ := config["graphs"].(map[string]any)
for graphID, data := range graphs {
var importStr string
switch v := data.(type) {
case string:
importStr = v
case map[string]any:
p, ok := v["path"].(string)
if !ok || p == "" {
return fmt.Errorf(
"Graph '%s' must contain a 'path' key if it is a dictionary.",
graphID)
}
importStr = p
default:
return fmt.Errorf(
"Graph '%s' must be a string or a dictionary with a 'path' key.",
graphID)
}
parts := strings.SplitN(importStr, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf(
"Import string \"%s\" must be in format \"<module>:<attribute>\".",
importStr)
}
moduleStr := parts[0]
attrStr := parts[1]
if strings.Contains(moduleStr, "/") || strings.Contains(moduleStr, "\\") {
resolved, err := filepath.Abs(filepath.Join(configDir, moduleStr))
if err != nil {
return err
}
info, statErr := os.Stat(resolved)
if statErr != nil {
return fmt.Errorf("Could not find local module: %s", resolved)
}
if info.IsDir() {
return fmt.Errorf("Local module must be a file: %s", resolved)
}
found := false
// Check real packages
for pkgPath, pkg := range deps.RealPkgs {
rel, relErr := filepath.Rel(pkgPath, resolved)
if relErr == nil && !strings.HasPrefix(rel, "..") {
containerPath := "/deps/" + pkg.ContainerName + "/" + filepath.ToSlash(rel)
moduleStr = containerPath
found = true
break
}
}
if !found {
// Check faux packages
for fauxPath, faux := range deps.FauxPkgs {
rel, relErr := filepath.Rel(fauxPath, resolved)
if relErr == nil && !strings.HasPrefix(rel, "..") {
moduleStr = faux.ContainerPath + "/" + filepath.ToSlash(rel)
found = true
break
}
}
}
if !found {
return fmt.Errorf(
"Module '%s' not found in 'dependencies' list. "+
"Add its containing package to 'dependencies' list.",
importStr)
}
// Update config
switch data.(type) {
case map[string]any:
data.(map[string]any)["path"] = moduleStr + ":" + attrStr
default:
graphs[graphID] = moduleStr + ":" + attrStr
}
}
}
return nil
}
// ---------------------------------------------------------------------------
// 6. UpdateConfigPaths
// ---------------------------------------------------------------------------
// UpdateConfigPaths rewrites auth, encryption, checkpointer, and http paths
// to point to the correct location inside the Docker container.
func UpdateConfigPaths(configPath string, config map[string]any, deps *LocalDeps) error {
configPath, _ = filepath.Abs(configPath)
configDir := filepath.Dir(configPath)
if err := updateModulePath(configDir, config, deps, "auth", "path", "Auth file"); err != nil {
return err
}
if err := updateModulePath(configDir, config, deps, "encryption", "path", "Encryption file"); err != nil {
return err
}
if err := updateModulePath(configDir, config, deps, "checkpointer", "path", "Checkpointer file"); err != nil {
return err
}
if err := updateHTTPAppPath(configDir, config, deps); err != nil {
return err
}
return nil
}
// updateModulePath is a helper for auth.path, encryption.path, checkpointer.path.
func updateModulePath(configDir string, config map[string]any, deps *LocalDeps, section, key, label string) error {
sectionMap, ok := config[section].(map[string]any)
if !ok || sectionMap == nil {
return nil
}
pathStr, _ := sectionMap[key].(string)
if pathStr == "" {
return nil
}
parts := strings.SplitN(pathStr, ":", 2)
if len(parts) != 2 {
return nil // already validated elsewhere
}
moduleStr := parts[0]
attrStr := parts[1]
if !strings.HasPrefix(moduleStr, ".") {
return nil // absolute path or module import
}
resolved, err := filepath.Abs(filepath.Join(configDir, moduleStr))
if err != nil {
return err
}
info, statErr := os.Stat(resolved)
if statErr != nil {
return fmt.Errorf("%s not found: %s (from %s)", label, resolved, pathStr)
}
if info.IsDir() {
return fmt.Errorf("%s path must be a file: %s", label, resolved)
}
// Check faux packages first (higher priority)
for fauxPath, faux := range deps.FauxPkgs {
rel, relErr := filepath.Rel(fauxPath, resolved)
if relErr == nil && !strings.HasPrefix(rel, "..") {
sectionMap[key] = faux.ContainerPath + "/" + filepath.ToSlash(rel) + ":" + attrStr
return nil
}
}
// Check real packages
for realPath, pkg := range deps.RealPkgs {
rel, relErr := filepath.Rel(realPath, resolved)
if relErr == nil && !strings.HasPrefix(rel, "..") {
sectionMap[key] = "/deps/" + filepath.Base(realPath) + "/" + filepath.ToSlash(rel) + ":" + attrStr
_ = pkg // use the real_path base, matching Python
return nil
}
}
depsJSON, _ := json.Marshal(config["dependencies"])
return fmt.Errorf(
"%s '%s' not covered by dependencies.\n"+
"Add its parent directory to the 'dependencies' array in your config.\n"+
"Current dependencies: %s",
label, resolved, string(depsJSON))
}
// updateHTTPAppPath handles http.app path remapping.
func updateHTTPAppPath(configDir string, config map[string]any, deps *LocalDeps) error {
httpConf, ok := config["http"].(map[string]any)
if !ok || httpConf == nil {
return nil
}
appStr, _ := httpConf["app"].(string)
if appStr == "" {
return nil
}
parts := strings.SplitN(appStr, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf(
"Import string \"%s\" must be in format \"<module>:<attribute>\".",
appStr)
}
moduleStr := parts[0]
attrStr := parts[1]
if !strings.Contains(moduleStr, "/") && !strings.Contains(moduleStr, "\\") {
return nil // not a file path
}
resolved, err := filepath.Abs(filepath.Join(configDir, moduleStr))
if err != nil {
return err
}
info, statErr := os.Stat(resolved)
if statErr != nil {
return fmt.Errorf("Could not find HTTP app module: %s", resolved)
}
if info.IsDir() {
return fmt.Errorf("HTTP app module must be a file: %s", resolved)
}
// Check real packages
for pkgPath, pkg := range deps.RealPkgs {
rel, relErr := filepath.Rel(pkgPath, resolved)
if relErr == nil && !strings.HasPrefix(rel, "..") {
containerPath := "/deps/" + pkg.ContainerName + "/" + filepath.ToSlash(rel)
httpConf["app"] = containerPath + ":" + attrStr
return nil
}
}
// Check faux packages
for fauxPath, faux := range deps.FauxPkgs {
rel, relErr := filepath.Rel(fauxPath, resolved)
if relErr == nil && !strings.HasPrefix(rel, "..") {
httpConf["app"] = faux.ContainerPath + "/" + filepath.ToSlash(rel) + ":" + attrStr
return nil
}
}
return fmt.Errorf(
"HTTP app module '%s' not found in 'dependencies' list. "+
"Add its containing package to 'dependencies' list.",
appStr)
}
// ---------------------------------------------------------------------------
// 7. BuildRuntimeEnvVars
// ---------------------------------------------------------------------------
// BuildRuntimeEnvVars generates ENV lines for the Dockerfile from config sections.
func BuildRuntimeEnvVars(config map[string]any) []string {
var envVars []string
envSections := []struct {
key string
envVar string
}{
{"store", "LANGGRAPH_STORE"},
{"auth", "LANGGRAPH_AUTH"},
{"encryption", "LANGGRAPH_ENCRYPTION"},
{"http", "LANGGRAPH_HTTP"},
{"webhooks", "LANGGRAPH_WEBHOOKS"},
{"checkpointer", "LANGGRAPH_CHECKPOINTER"},
{"ui", "LANGGRAPH_UI"},
{"ui_config", "LANGGRAPH_UI_CONFIG"},
}
for _, s := range envSections {
if val := config[s.key]; val != nil {
j, _ := json.Marshal(val)
envVars = append(envVars, fmt.Sprintf("ENV %s='%s'", s.envVar, string(j)))
}
}
graphsJSON, _ := json.Marshal(config["graphs"])
envVars = append(envVars, fmt.Sprintf("ENV LANGSERVE_GRAPHS='%s'", string(graphsJSON)))
return envVars
}
// ---------------------------------------------------------------------------
// 8. ImageSupportsUV
// ---------------------------------------------------------------------------
// ImageSupportsUV returns true if the base image supports the uv pip installer.
func ImageSupportsUV(baseImage string) bool {
if baseImage == "langchain/langgraph-trial" {
return false
}
match := semverPattern.FindStringSubmatch(baseImage)
if match == nil {
return true
}
versionStr := match[1]
parts := strings.Split(versionStr, ".")
version := make([]int, len(parts))
for i, p := range parts {
fmt.Sscanf(p, "%d", &version[i])
}
minUV := []int{0, 2, 47}
return !versionSliceLessThan(version, minUV)
}
// versionSliceLessThan compares two version slices.
func versionSliceLessThan(a, b []int) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] < b[i] {
return true
}
if a[i] > b[i] {
return false
}
}
return len(a) < len(b)
}
// ---------------------------------------------------------------------------
// 9. GetBuildToolsToUninstall
// ---------------------------------------------------------------------------
// GetBuildToolsToUninstall returns the list of build tools that should be
// removed from the final image.
func GetBuildToolsToUninstall(config map[string]any) ([]string, error) {
kpt := config["keep_pkg_tools"]
if kpt == nil {
return []string{"pip", "setuptools", "wheel"}, nil
}
// Check for boolean false (falsy)
if b, ok := kpt.(bool); ok {
if b {
return nil, nil
}
return []string{"pip", "setuptools", "wheel"}, nil
}
// Check for list
if arr, ok := kpt.([]any); ok {
keepSet := map[string]bool{}
for _, item := range arr {
tool, ok := item.(string)
if !ok {
return nil, fmt.Errorf(
"Invalid build tool to uninstall: %v. Expected one of %v",
item, buildTools)
}
valid := false
for _, bt := range buildTools {
if tool == bt {
valid = true
break
}
}
if !valid {
return nil, fmt.Errorf(
"Invalid build tool to uninstall: %s. Expected one of %v",
tool, buildTools)
}
keepSet[tool] = true
}
var result []string
for _, bt := range buildTools {
if !keepSet[bt] {
result = append(result, bt)
}
}
sort.Strings(result)
return result, nil
}
return nil, fmt.Errorf(
"Invalid value for keep_pkg_tools: %v."+
" Expected True or a list containing any of %v.",
kpt, buildTools)
}
// ---------------------------------------------------------------------------
// 10. GetPipCleanupLines
// ---------------------------------------------------------------------------
// GetPipCleanupLines generates the RUN commands for pip cleanup in the Dockerfile.
func GetPipCleanupLines(installCmd string, toUninstall []string, pipInstaller string) string {
var commands []string
commands = append(commands, fmt.Sprintf(`# -- Ensure user deps didn't inadvertently overwrite langgraph-api
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
RUN PYTHONDONTWRITEBYTECODE=1 %s --no-cache-dir --no-deps -e /api
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
# -- Removing build deps from the final image ~<:===~~~ --`, installCmd))
if len(toUninstall) > 0 {
// Validate
for _, pack := range toUninstall {
valid := false
for _, bt := range buildTools {
if pack == bt {
valid = true
break
}
}
if !valid {
// This matches the Python ValueError
panic(fmt.Sprintf("Invalid build tool: %s; must be one of %s",
pack, strings.Join(buildTools, ", ")))
}
}
sorted := make([]string, len(toUninstall))
copy(sorted, toUninstall)
sort.Strings(sorted)
packsStr := strings.Join(sorted, " ")
commands = append(commands, fmt.Sprintf("RUN pip uninstall -y %s", packsStr))
// Remove from /usr/local/lib
var localRm []string
for _, pack := range toUninstall {
localRm = append(localRm, fmt.Sprintf("/usr/local/lib/python*/site-packages/%s*", pack))
}
localRmStr := strings.Join(localRm, " ")
hasPip := false
for _, p := range toUninstall {
if p == "pip" {
hasPip = true
break
}
}
if hasPip {
localRmStr += ` && find /usr/local/bin -name "pip*" -delete || true`
}
commands = append(commands, fmt.Sprintf("RUN rm -rf %s", localRmStr))
// Remove from /usr/lib (wolfi)
var wolfiRm []string
for _, pack := range toUninstall {
wolfiRm = append(wolfiRm, fmt.Sprintf("/usr/lib/python*/site-packages/%s*", pack))
}
wolfiRmStr := strings.Join(wolfiRm, " ")
if hasPip {
wolfiRmStr += ` && find /usr/bin -name "pip*" -delete || true`
}
commands = append(commands, fmt.Sprintf("RUN rm -rf %s", wolfiRmStr))
if pipInstaller == "uv" {
commands = append(commands, fmt.Sprintf(
"RUN uv pip uninstall --system %s && rm /usr/bin/uv /usr/bin/uvx", packsStr))
}
} else {
if pipInstaller == "uv" {
commands = append(commands,
"RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --")
}
}
return strings.Join(commands, "\n")
}
// ---------------------------------------------------------------------------
// 11. PythonConfigToDocker
// ---------------------------------------------------------------------------
// PythonConfigToDocker generates a Dockerfile and additional build contexts
// for a Python-based LangGraph configuration.
func PythonConfigToDocker(
configPath string,
config map[string]any,
baseImage string,
apiVersion string,
escapeVariables bool,
) (string, map[string]string, error) {
sourceKind := getSourceKind(config)
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"
}
if pipInstaller == "auto" {
if ImageSupportsUV(baseImage) {
pipInstaller = "uv"
} else {
pipInstaller = "pip"
}
}
var installCmd string
switch pipInstaller {
case "uv":
installCmd = "uv pip install --system"
case "pip":
installCmd = "pip install"
default:
return "", nil, fmt.Errorf("Invalid pip_installer: %s", pipInstaller)
}
localReqsPipInstall, globalReqsPipInstall, pipConfigFileStr := buildPythonInstallCommands(config, installCmd)
// Collect PyPI dependencies (non-local)
deps := configSlice(config, "dependencies")
var pypiDeps []string
for _, d := range deps {
s, ok := d.(string)
if ok && !strings.HasPrefix(s, ".") {
pypiDeps = append(pypiDeps, s)
}
}
configPathAbs, _ := filepath.Abs(configPath)
configDir := filepath.Dir(configPathAbs)
localDeps, err := AssembleLocalDeps(configPath, config)
if err != nil {
return "", nil, err
}
if err := UpdateGraphPaths(configPath, config, localDeps); err != nil {
return "", nil, err
}
if err := UpdateConfigPaths(configPath, config, localDeps); err != nil {
return "", nil, err
}
// PyPI install line
pipPkgsStr := ""
if len(pypiDeps) > 0 {
pipPkgsStr = fmt.Sprintf("RUN %s %s", localReqsPipInstall, strings.Join(pypiDeps, " "))
}
// Requirements.txt install
pipReqsStr := ""
if len(localDeps.PipReqs) > 0 {
var addLines []string
for _, req := range localDeps.PipReqs {
isAdditional := false
reqParent := filepath.Dir(req.HostPath)
for _, ac := range localDeps.AdditionalContexts {
if reqParent == ac {
isAdditional = true
break
}
}
if isAdditional {
addLines = append(addLines,
fmt.Sprintf("COPY --from=outer-%s requirements.txt %s",
filepath.Base(req.HostPath), req.ContainerPath))
} else {
relPath, _ := filepath.Rel(configDir, req.HostPath)
addLines = append(addLines,
fmt.Sprintf("ADD %s %s", filepath.ToSlash(relPath), req.ContainerPath))
}
}
var reqArgs []string
for _, req := range localDeps.PipReqs {
reqArgs = append(reqArgs, "-r "+req.ContainerPath)
}
pipReqsStr = fmt.Sprintf("# -- Installing local requirements --\n%s\nRUN %s %s\n# -- End of local requirements install --",
strings.Join(addLines, "\n"),
localReqsPipInstall,
strings.Join(reqArgs, " "))
}
// Faux packages
var fauxParts []string
for fullpath, faux := range localDeps.FauxPkgs {
baseName := filepath.Base(fullpath)
isAdditional := false
for _, ac := range localDeps.AdditionalContexts {
if fullpath == ac {
isAdditional = true
break
}
}
var addLine string
if isAdditional {
addLine = fmt.Sprintf("# -- Adding non-package dependency %s --\nCOPY --from=outer-%s . %s",
baseName, baseName, faux.ContainerPath)
} else {
addLine = fmt.Sprintf("# -- Adding non-package dependency %s --\nADD %s %s",
baseName, faux.RelPath, faux.ContainerPath)
}
pyprojectPath := fmt.Sprintf("/deps/outer-%s/pyproject.toml", baseName)
// Shell-quote: the Python code uses shlex.quote which wraps in single quotes
quotedPath := shellQuote(pyprojectPath)
part := fmt.Sprintf(`%s
RUN set -ex && \
for line in '[project]' \
'name = "%s"' \
'version = "0.1"' \
'[tool.setuptools.package-data]' \
'"*" = ["**/*"]' \
'[build-system]' \
'requires = ["setuptools>=61"]' \
'build-backend = "setuptools.build_meta"'; do \
echo "$line" >> %s; \
done
# -- End of non-package dependency %s --`, addLine, baseName, quotedPath, baseName)
fauxParts = append(fauxParts, part)
}
fauxPkgsStr := strings.Join(fauxParts, "\n\n")
// Real packages
var localParts []string
for fullpath, pkg := range localDeps.RealPkgs {
isAdditional := false
for _, ac := range localDeps.AdditionalContexts {
if fullpath == ac {
isAdditional = true
break
}
}
if isAdditional {
localParts = append(localParts, fmt.Sprintf(
"# -- Adding local package %s --\nCOPY --from=%s . /deps/%s\n# -- End of local package %s --",
pkg.RelPath, pkg.ContainerName, pkg.ContainerName, pkg.RelPath))
} else {
localParts = append(localParts, fmt.Sprintf(
"# -- Adding local package %s --\nADD %s /deps/%s\n# -- End of local package %s --",
pkg.RelPath, pkg.RelPath, pkg.ContainerName, pkg.RelPath))
}
}
localPkgsStr := strings.Join(localParts, "\n")
// Additional contexts
additionalContexts := map[string]string{}
additionalContextNames := map[string]string{} // path -> name
usedContextNames := map[string]bool{}
registerAdditionalContext := func(path, preferredName string) string {
if name, ok := additionalContextNames[path]; ok {
return name
}
name := preferredName
suffix := 1
for usedContextNames[name] {
name = fmt.Sprintf("%s_%d", preferredName, suffix)
suffix++
}
usedContextNames[name] = true
additionalContextNames[path] = name
additionalContexts[name] = path
return name
}
for _, p := range localDeps.AdditionalContexts {
if pkg, ok := localDeps.RealPkgs[p]; ok {
registerAdditionalContext(p, pkg.ContainerName)
} else if _, ok := localDeps.FauxPkgs[p]; ok {
registerAdditionalContext(p, "outer-"+filepath.Base(p))
} else {
return "", nil, fmt.Errorf("Unknown additional context: %s", p)
}
}
// Install node string
nv, _ := config["node_version"].(string)
installNodeStr := ""
if (config["ui"] != nil || nv != "") && localDeps.WorkingDir != "" {
installNodeStr = "RUN /storage/install-node.sh"
}
// Combine install steps
installSteps := []string{installNodeStr, pipConfigFileStr, pipPkgsStr, pipReqsStr, localPkgsStr, fauxPkgsStr}
var filteredSteps []string
for _, s := range installSteps {
if s != "" {
filteredSteps = append(filteredSteps, s)
}
}
installs := strings.Join(filteredSteps, "\n\n")
envVars := BuildRuntimeEnvVars(config)
// JS install
jsInstStr := ""
if (config["ui"] != nil || nv != "") && localDeps.WorkingDir != "" {
nodeVer := nv
if nodeVer == "" {
nodeVer = DefaultNodeVersion
}
jsInstStr = strings.Join([]string{
"# -- Installing JS dependencies --",
fmt.Sprintf("ENV NODE_VERSION=%s", nodeVer),
fmt.Sprintf("WORKDIR %s", localDeps.WorkingDir),
fmt.Sprintf("RUN %s && tsx /api/langgraph_api/js/build.mts", GetNodePMInstallCmd(configDir)),
"# -- End of JS dependencies install --",
}, "\n")
}
imageStr := DockerTag(config, baseImage, apiVersion)
// Build Dockerfile
var dockerFileContents []string
if len(additionalContexts) > 0 {
dockerFileContents = append(dockerFileContents, "# syntax=docker/dockerfile:1.4", "")
}
depVName := "$dep"
if escapeVariables {
depVName = "$$dep"
}
dockerfileLines := configSlice(config, "dockerfile_lines")
var dfLines []string
for _, l := range dockerfileLines {
if s, ok := l.(string); ok {
dfLines = append(dfLines, s)
}
}
localDepsInstallStr := fmt.Sprintf(`RUN for dep in /deps/*; do \
echo "Installing %s"; \
if [ -d "%s" ]; then \
echo "Installing %s"; \
(cd "%s" && %s -e .); \
fi; \
done`, depVName, depVName, depVName, depVName, globalReqsPipInstall)
dockerFileContents = append(dockerFileContents,
fmt.Sprintf("FROM %s", imageStr),
"",
strings.Join(dfLines, "\n"),
"",
installs,
"",
"# -- Installing all local dependencies --",
localDepsInstallStr,
"# -- End of local dependencies install --",
strings.Join(envVars, "\n"),
"",
jsInstStr,
"",
GetPipCleanupLines(installCmd, buildToolsToUninstall, pipInstaller),
"",
)
if localDeps.WorkingDir != "" {
dockerFileContents = append(dockerFileContents, fmt.Sprintf("WORKDIR %s", localDeps.WorkingDir))
} else {
dockerFileContents = append(dockerFileContents, "")
}
return strings.Join(dockerFileContents, "\n"), additionalContexts, nil
}
// ---------------------------------------------------------------------------
// 12. NodeConfigToDocker
// ---------------------------------------------------------------------------
// NodeConfigToDocker generates a Dockerfile for a Node.js-based LangGraph configuration.
func NodeConfigToDocker(
configPath string,
config map[string]any,
baseImage string,
apiVersion string,
installCommand string,
buildCommand string,
buildContext string,
) (string, map[string]string, error) {
configPathAbs, _ := filepath.Abs(configPath)
configDir := filepath.Dir(configPathAbs)
var installRoot string
if buildContext != "" {
installRoot, _ = filepath.Abs(buildContext)
} else {
installRoot = configDir
}
installCmd := installCommand
if installCmd == "" {
installCmd = GetNodePMInstallCmd(installRoot)
}
var fauxPath string
var containerRoot string
if buildContext != "" {
relWorkdir := calculateRelativeWorkdir(configPathAbs, buildContext)
containerName := filepath.Base(buildContext)
containerRoot = "/deps/" + containerName
if relWorkdir != "" {
fauxPath = containerRoot + "/" + relWorkdir
} else {
fauxPath = containerRoot
}
} else {
fauxPath = "/deps/" + filepath.Base(configDir)
}
imageStr := DockerTag(config, baseImage, apiVersion)
envVars := BuildRuntimeEnvVars(config)
var installWorkdir string
var installStep string
var buildStep string
if buildContext != "" {
installWorkdir = containerRoot
installStep = "RUN " + installCmd
if buildCommand != "" {
buildStep = "RUN " + buildCommand
} else {
buildStep = `RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts`
}
} else {
installWorkdir = fauxPath
installStep = "RUN " + installCmd
buildStep = `RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts`
}
buildWorkdir := fauxPath
addDest := fauxPath
if buildContext != "" {
addDest = containerRoot
}
dockerfileLines := configSlice(config, "dockerfile_lines")
var dfLines []string
for _, l := range dockerfileLines {
if s, ok := l.(string); ok {
dfLines = append(dfLines, s)
}
}
dockerFileContents := []string{
fmt.Sprintf("FROM %s", imageStr),
"",
strings.Join(dfLines, "\n"),
"",
fmt.Sprintf("ADD . %s", addDest),
"",
fmt.Sprintf("WORKDIR %s", installWorkdir),
"",
installStep,
"",
strings.Join(envVars, "\n"),
"",
fmt.Sprintf("WORKDIR %s", buildWorkdir),
"",
buildStep,
}
return strings.Join(dockerFileContents, "\n"), map[string]string{}, nil
}
// calculateRelativeWorkdir computes the relative path from build context to config dir.
func calculateRelativeWorkdir(configPath, buildContext string) string {
configDir, _ := filepath.Abs(filepath.Dir(configPath))
buildContextPath, _ := filepath.Abs(buildContext)
rel, err := filepath.Rel(buildContextPath, configDir)
if err != nil || strings.HasPrefix(rel, "..") {
// The Python version raises ValueError here
return ""
}
if rel == "." {
return ""
}
return filepath.ToSlash(rel)
}
// ---------------------------------------------------------------------------
// 13. ConfigToDocker
// ---------------------------------------------------------------------------
// ConfigToDocker routes to NodeConfigToDocker or PythonConfigToDocker based on config.
func ConfigToDocker(configPath string, config map[string]any, opts DockerOpts) (string, map[string]string, error) {
baseImage := opts.BaseImage
if baseImage == "" {
baseImage = DefaultBaseImage(config, "combined_queue_worker")
}
nv, _ := config["node_version"].(string)
pv, _ := config["python_version"].(string)
if nv != "" && pv == "" {
return NodeConfigToDocker(
configPath, config, baseImage, opts.APIVersion,
opts.InstallCommand, opts.BuildCommand, opts.BuildContext,
)
}
return PythonConfigToDocker(
configPath, config, baseImage, opts.APIVersion, opts.EscapeVariables,
)
}
// ---------------------------------------------------------------------------
// 14. ConfigToCompose
// ---------------------------------------------------------------------------
// ConfigToCompose generates the compose override section.
func ConfigToCompose(configPath string, config map[string]any, opts ComposeOpts) (string, error) {
baseImage := opts.BaseImage
if baseImage == "" {
baseImage = DefaultBaseImage(config, "combined_queue_worker")
}
// Build env vars string
envVarsStr := ""
if envMap, ok := config["env"].(map[string]any); ok {
var lines []string
// Sort keys for deterministic output
keys := make([]string, 0, len(envMap))
for k := range envMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
lines = append(lines, fmt.Sprintf(" %s: \"%v\"", k, envMap[k]))
}
envVarsStr = strings.Join(lines, "\n")
}
envFileStr := ""
if envStr, ok := config["env"].(string); ok {
envFileStr = "env_file: " + envStr
}
watchStr := ""
if opts.Watch {
deps := configSlice(config, "dependencies")
if len(deps) == 0 {
deps = []any{"."}
}
watchPaths := []string{filepath.Base(configPath)}
for _, d := range deps {
if s, ok := d.(string); ok && strings.HasPrefix(s, ".") {
watchPaths = append(watchPaths, s)
}
}
var watchActions []string
for _, path := range watchPaths {
watchActions = append(watchActions,
fmt.Sprintf(" - path: %s\n action: rebuild", path))
}
watchStr = fmt.Sprintf("\n develop:\n watch:\n%s\n",
strings.Join(watchActions, "\n"))
}
if opts.Image != "" {
return fmt.Sprintf("\n%s\n %s\n %s\n",
indent(envVarsStr, " "),
envFileStr,
watchStr), nil
}
// Deep copy config for potential distributed mode
var configSnapshot map[string]any
engineRuntimeMode := opts.EngineRuntimeMode
if engineRuntimeMode == "" {
engineRuntimeMode = "combined_queue_worker"
}
if engineRuntimeMode == "distributed" {
configSnapshot = deepCopyConfig(config)
}
dockerfile, additionalContexts, err := ConfigToDocker(configPath, config, DockerOpts{
BaseImage: baseImage,
APIVersion: opts.APIVersion,
EscapeVariables: true,
})
if err != nil {
return "", err
}
additionalContextsStr := ""
if len(additionalContexts) > 0 {
var lines []string
// Sort for deterministic output
keys := make([]string, 0, len(additionalContexts))
for k := range additionalContexts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, name := range keys {
lines = append(lines, fmt.Sprintf(" - %s: %s", name, additionalContexts[name]))
}
additionalContextsStr = fmt.Sprintf("\n additional_contexts:\n%s",
strings.Join(lines, "\n"))
}
result := fmt.Sprintf("\n%s\n %s\n pull_policy: build\n build:\n context: .%s\n dockerfile_inline: |\n%s\n %s\n",
indent(envVarsStr, " "),
envFileStr,
additionalContextsStr,
indent(dockerfile, " "),
watchStr)
if engineRuntimeMode == "distributed" {
executorBaseImage := DefaultBaseImage(configSnapshot, "distributed")
executorDockerfile, executorAdditionalContexts, err := ConfigToDocker(
configPath, configSnapshot, DockerOpts{
BaseImage: executorBaseImage,
APIVersion: opts.APIVersion,
EscapeVariables: true,
})
if err != nil {
return "", err
}
executorAdditionalContextsStr := ""
if len(executorAdditionalContexts) > 0 {
var lines []string
keys := make([]string, 0, len(executorAdditionalContexts))
for k := range executorAdditionalContexts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, name := range keys {
lines = append(lines, fmt.Sprintf(" - %s: %s", name, executorAdditionalContexts[name]))
}
executorAdditionalContextsStr = fmt.Sprintf("\n additional_contexts:\n%s",
strings.Join(lines, "\n"))
}
postgresURI := "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
result += fmt.Sprintf(` langgraph-orchestrator:
image: langchain/langgraph-orchestrator-licensed:latest
depends_on:
langgraph-api:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
DATABASE_URI: %s
EXECUTOR_TARGET: langgraph-executor:8188
%s
langgraph-executor:
depends_on:
langgraph-postgres:
condition: service_healthy
langgraph-api:
condition: service_healthy
entrypoint: ["sh", "/storage/executor_entrypoint.sh"]
environment:
DATABASE_URI: %s
REDIS_URI: redis://langgraph-redis:6379
EXECUTOR_GRPC_PORT: "8188"
ENGINE_GRPC_ADDRESS: "langgraph-orchestrator:50054"
LSD_GRPC_SERVER_ADDRESS: "localhost:50050"
LANGGRAPH_HTTP: ""
%s
pull_policy: build
build:
context: .%s
dockerfile_inline: |
%s
`, postgresURI, envFileStr, postgresURI, envFileStr,
executorAdditionalContextsStr,
indent(executorDockerfile, " "))
}
return result, nil
}
// ---------------------------------------------------------------------------
// 15. GetNodePMInstallCmd
// ---------------------------------------------------------------------------
// GetNodePMInstallCmd detects the appropriate Node.js package manager install command.
func GetNodePMInstallCmd(projectDir string) string {
testFile := func(name string) bool {
info, err := os.Stat(filepath.Join(projectDir, name))
return err == nil && !info.IsDir()
}
yarn := testFile("yarn.lock")
pnpm := testFile("pnpm-lock.yaml")
npm := testFile("package-lock.json")
bun := testFile("bun.lockb")
if yarn {
return "yarn install --frozen-lockfile"
}
if pnpm {
return "pnpm i --frozen-lockfile"
}
if npm {
return "npm ci"
}
if bun {
return "bun i"
}
// Fallback: check package.json packageManager field
pkgManagerName := getPkgManagerName(projectDir)
switch pkgManagerName {
case "yarn":
return "yarn install"
case "pnpm":
return "pnpm i"
case "bun":
return "bun i"
default:
return "npm i"
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// buildPythonInstallCommands builds the install command strings with optional pip config.
func buildPythonInstallCommands(config map[string]any, installCmd string) (localReqs, globalReqs, pipConfigFileStr string) {
base := fmt.Sprintf("PYTHONDONTWRITEBYTECODE=1 %s --no-cache-dir -c /api/constraints.txt", installCmd)
localReqs = base
globalReqs = base
if pcf, _ := config["pip_config_file"].(string); pcf != "" {
localReqs = "PIP_CONFIG_FILE=/pipconfig.txt " + localReqs
globalReqs = "PIP_CONFIG_FILE=/pipconfig.txt " + globalReqs
pipConfigFileStr = fmt.Sprintf("ADD %s /pipconfig.txt", pcf)
}
return
}
// configSlice extracts a []any from config[key], returning nil if not present.
func configSlice(config map[string]any, key string) []any {
if v, ok := config[key].([]any); ok {
return v
}
return nil
}
// shellQuote wraps a string in single quotes, escaping embedded single quotes.
func shellQuote(s string) string {
// shlex.quote: wrap in single quotes, replace ' with '"'"'
return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
}
// indent prepends prefix to each line of text.
func indent(text, prefix string) string {
if text == "" {
return text
}
lines := strings.Split(text, "\n")
for i, line := range lines {
if line != "" {
lines[i] = prefix + line
}
}
return strings.Join(lines, "\n")
}
// deepCopyConfig does a JSON round-trip deep copy of a config map.
func deepCopyConfig(config map[string]any) map[string]any {
data, _ := json.Marshal(config)
var result map[string]any
_ = json.Unmarshal(data, &result)
return result
}
// getPkgManagerName reads the packageManager or devEngines.packageManager.name
// field from package.json.
func getPkgManagerName(projectDir string) string {
data, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
if err != nil {
return ""
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return ""
}
// Check packageManager field
if pm, ok := pkg["packageManager"].(string); ok && pm != "" {
pm = strings.TrimLeft(pm, "^")
parts := strings.SplitN(pm, "@", 2)
return parts[0]
}
// Check devEngines.packageManager.name
if devEngines, ok := pkg["devEngines"].(map[string]any); ok {
if pmObj, ok := devEngines["packageManager"].(map[string]any); ok {
if name, ok := pmObj["name"].(string); ok && name != "" {
return name
}
}
}
return ""
}