mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-20 14:42:28 +02:00
530 lines
16 KiB
Go
530 lines
16 KiB
Go
// Package docker provides Docker compose generation, capability detection,
|
|
// and image building for the LangGraph CLI.
|
|
package docker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/langchain-ai/langgraph/libs/cli/internal/config"
|
|
)
|
|
|
|
// DefaultPostgresURI is the default connection string used when no custom
|
|
// Postgres URI is provided.
|
|
const DefaultPostgresURI = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
|
|
|
// Version represents a semantic version with major, minor, and patch components.
|
|
type Version struct {
|
|
Major, Minor, Patch int
|
|
}
|
|
|
|
// GreaterOrEqual returns true if v >= other.
|
|
func (v Version) GreaterOrEqual(other Version) bool {
|
|
if v.Major != other.Major {
|
|
return v.Major > other.Major
|
|
}
|
|
if v.Minor != other.Minor {
|
|
return v.Minor > other.Minor
|
|
}
|
|
return v.Patch >= other.Patch
|
|
}
|
|
|
|
// DockerCapabilities describes the Docker environment available on the host.
|
|
type DockerCapabilities struct {
|
|
VersionDocker Version
|
|
VersionCompose Version
|
|
HealthcheckStartInterval bool
|
|
ComposeType string // "plugin" or "standalone"
|
|
}
|
|
|
|
// ComposeOpts configures the generated docker-compose YAML.
|
|
type ComposeOpts struct {
|
|
Port int
|
|
DebuggerPort int // 0 means no debugger
|
|
DebuggerBaseURL string // optional base URL for the debugger
|
|
PostgresURI string // empty means use DefaultPostgresURI
|
|
Image string // pre-built image name
|
|
BaseImage string
|
|
APIVersion string
|
|
EngineRuntimeMode string // "combined_queue_worker" or "distributed"
|
|
}
|
|
|
|
// BuildImageOpts configures docker image building.
|
|
type BuildImageOpts struct {
|
|
ConfigPath string
|
|
ConfigJSON map[string]any
|
|
BaseImage string
|
|
APIVersion string
|
|
Pull bool
|
|
Tag string
|
|
Passthrough []string
|
|
InstallCommand string
|
|
BuildCommand string
|
|
DockerCommand []string // default: ["docker", "build"]
|
|
ExtraFlags []string
|
|
Verbose bool
|
|
}
|
|
|
|
// OrderedMap preserves insertion order for map keys.
|
|
type OrderedMap struct {
|
|
Keys []string
|
|
Values map[string]any
|
|
}
|
|
|
|
// NewOrderedMap creates an empty OrderedMap.
|
|
func NewOrderedMap() *OrderedMap {
|
|
return &OrderedMap{
|
|
Values: make(map[string]any),
|
|
}
|
|
}
|
|
|
|
// Set adds or updates a key-value pair, preserving insertion order.
|
|
func (om *OrderedMap) Set(key string, value any) {
|
|
if _, exists := om.Values[key]; !exists {
|
|
om.Keys = append(om.Keys, key)
|
|
}
|
|
om.Values[key] = value
|
|
}
|
|
|
|
// Get retrieves the value for a key.
|
|
func (om *OrderedMap) Get(key string) (any, bool) {
|
|
v, ok := om.Values[key]
|
|
return v, ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ParseVersion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ParseVersion parses a version string like "1.2.3", "v1.2.3-alpha+build",
|
|
// "1.2", or "1" into a Version.
|
|
func ParseVersion(version string) Version {
|
|
parts := strings.SplitN(version, ".", 3)
|
|
|
|
major := "0"
|
|
minor := "0"
|
|
patch := "0"
|
|
|
|
switch len(parts) {
|
|
case 1:
|
|
major = parts[0]
|
|
case 2:
|
|
major = parts[0]
|
|
minor = parts[1]
|
|
default:
|
|
major = parts[0]
|
|
minor = parts[1]
|
|
patch = parts[2]
|
|
}
|
|
|
|
// Strip "v" prefix from major
|
|
major = strings.TrimPrefix(major, "v")
|
|
|
|
// Strip "-" and "+" suffixes from patch
|
|
if idx := strings.IndexAny(patch, "-+"); idx >= 0 {
|
|
patch = patch[:idx]
|
|
}
|
|
|
|
majorInt, _ := strconv.Atoi(major)
|
|
minorInt, _ := strconv.Atoi(minor)
|
|
patchInt, _ := strconv.Atoi(patch)
|
|
|
|
return Version{Major: majorInt, Minor: minorInt, Patch: patchInt}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CanBuildLocally
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CanBuildLocally checks whether local deployment builds can run on this machine.
|
|
// It returns (ok, errorMessage). If ok is true, errorMessage is empty.
|
|
func CanBuildLocally() (bool, string) {
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
return false, "Docker is required but not installed.\n" +
|
|
"Install Docker Desktop: https://docs.docker.com/get-docker/"
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "docker", "info")
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = nil
|
|
if err := cmd.Run(); err != nil {
|
|
return false, "Docker is installed but not running.\nStart Docker and try again."
|
|
}
|
|
|
|
if runtime.GOARCH != "amd64" {
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel2()
|
|
|
|
buildx := exec.CommandContext(ctx2, "docker", "buildx", "version")
|
|
buildx.Stdout = nil
|
|
buildx.Stderr = nil
|
|
if err := buildx.Run(); err != nil {
|
|
arch := runtime.GOARCH
|
|
// Try to match Python's platform.machine() naming for the error message
|
|
if arch == "arm64" {
|
|
arch = "aarch64"
|
|
}
|
|
return false, "Docker Buildx is required but not installed.\n" +
|
|
"Your machine architecture (" + arch + ") requires Buildx to cross-compile images for linux/amd64.\n" +
|
|
"Install Buildx: https://docs.docker.com/build/install-buildx/"
|
|
}
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CheckCapabilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CheckCapabilities detects the Docker and Docker Compose versions available
|
|
// on the host and returns a DockerCapabilities describing them.
|
|
func CheckCapabilities() (*DockerCapabilities, error) {
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
return nil, fmt.Errorf("Docker not installed")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := exec.CommandContext(ctx, "docker", "info", "-f", "{{json .}}").Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Docker not installed or not running")
|
|
}
|
|
|
|
var info map[string]any
|
|
if err := json.Unmarshal(out, &info); err != nil {
|
|
return nil, fmt.Errorf("Docker not installed or not running")
|
|
}
|
|
|
|
serverVersion, _ := info["ServerVersion"].(string)
|
|
if serverVersion == "" {
|
|
return nil, fmt.Errorf("Docker not running")
|
|
}
|
|
|
|
// Try to find compose plugin
|
|
var composeVersionStr string
|
|
composeType := "plugin"
|
|
|
|
found := false
|
|
if clientInfo, ok := info["ClientInfo"].(map[string]any); ok {
|
|
if plugins, ok := clientInfo["Plugins"].([]any); ok {
|
|
for _, p := range plugins {
|
|
pm, ok := p.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
name, _ := pm["Name"].(string)
|
|
if name == "compose" {
|
|
composeVersionStr, _ = pm["Version"].(string)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
// Fall back to standalone docker-compose
|
|
if _, err := exec.LookPath("docker-compose"); err != nil {
|
|
return nil, fmt.Errorf("Docker Compose not installed")
|
|
}
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel2()
|
|
|
|
out2, err := exec.CommandContext(ctx2, "docker-compose", "--version", "--short").Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Docker Compose not installed")
|
|
}
|
|
composeVersionStr = strings.TrimSpace(string(out2))
|
|
composeType = "standalone"
|
|
}
|
|
|
|
dockerVersion := ParseVersion(serverVersion)
|
|
composeVersion := ParseVersion(composeVersionStr)
|
|
|
|
return &DockerCapabilities{
|
|
VersionDocker: dockerVersion,
|
|
VersionCompose: composeVersion,
|
|
HealthcheckStartInterval: dockerVersion.GreaterOrEqual(Version{25, 0, 0}),
|
|
ComposeType: composeType,
|
|
}, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DebuggerCompose
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DebuggerCompose returns a service config map for the langgraph-debugger
|
|
// container, or nil if port is 0 (no debugger requested).
|
|
func DebuggerCompose(port int, baseURL string) *OrderedMap {
|
|
if port == 0 {
|
|
return nil
|
|
}
|
|
|
|
dependsOn := NewOrderedMap()
|
|
postgresCondition := NewOrderedMap()
|
|
postgresCondition.Set("condition", "service_healthy")
|
|
dependsOn.Set("langgraph-postgres", postgresCondition)
|
|
|
|
service := NewOrderedMap()
|
|
service.Set("image", "langchain/langgraph-debugger")
|
|
service.Set("restart", "on-failure")
|
|
service.Set("depends_on", dependsOn)
|
|
service.Set("ports", []any{fmt.Sprintf(`"%d:3968"`, port)})
|
|
|
|
if baseURL != "" {
|
|
env := NewOrderedMap()
|
|
env.Set("VITE_STUDIO_LOCAL_GRAPH_URL", baseURL)
|
|
service.Set("environment", env)
|
|
}
|
|
|
|
result := NewOrderedMap()
|
|
result.Set("langgraph-debugger", service)
|
|
return result
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DictToYAML
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DictToYAML converts an OrderedMap to a YAML string. For top-level keys
|
|
// (indent < 2) it adds a blank line between entries (except the first).
|
|
func DictToYAML(d *OrderedMap, indent int) string {
|
|
var b strings.Builder
|
|
for idx, key := range d.Keys {
|
|
// Add blank line between top-level entries (except first)
|
|
if idx >= 1 && indent < 2 {
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
space := strings.Repeat(" ", indent)
|
|
value := d.Values[key]
|
|
|
|
switch v := value.(type) {
|
|
case *OrderedMap:
|
|
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
|
|
b.WriteString(DictToYAML(v, indent+1))
|
|
case []any:
|
|
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
|
|
for _, item := range v {
|
|
b.WriteString(fmt.Sprintf("%s - %v\n", space, item))
|
|
}
|
|
default:
|
|
b.WriteString(fmt.Sprintf("%s%s: %v\n", space, key, value))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ComposeAsDict
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ComposeAsDict builds the docker-compose configuration as an OrderedMap.
|
|
func ComposeAsDict(caps *DockerCapabilities, opts ComposeOpts) *OrderedMap {
|
|
postgresURI := opts.PostgresURI
|
|
includeDB := false
|
|
if postgresURI == "" {
|
|
includeDB = true
|
|
postgresURI = DefaultPostgresURI
|
|
}
|
|
|
|
services := NewOrderedMap()
|
|
|
|
// --- Redis service ---
|
|
redisHealthcheck := NewOrderedMap()
|
|
redisHealthcheck.Set("test", "redis-cli ping")
|
|
redisHealthcheck.Set("interval", "5s")
|
|
redisHealthcheck.Set("timeout", "1s")
|
|
redisHealthcheck.Set("retries", 5)
|
|
|
|
redisService := NewOrderedMap()
|
|
redisService.Set("image", "redis:6")
|
|
redisService.Set("healthcheck", redisHealthcheck)
|
|
services.Set("langgraph-redis", redisService)
|
|
|
|
// --- Postgres service (if no custom URI) ---
|
|
if includeDB {
|
|
pgEnv := NewOrderedMap()
|
|
pgEnv.Set("POSTGRES_DB", "postgres")
|
|
pgEnv.Set("POSTGRES_USER", "postgres")
|
|
pgEnv.Set("POSTGRES_PASSWORD", "postgres")
|
|
|
|
pgHealthcheck := NewOrderedMap()
|
|
pgHealthcheck.Set("test", "pg_isready -U postgres")
|
|
pgHealthcheck.Set("start_period", "10s")
|
|
pgHealthcheck.Set("timeout", "1s")
|
|
pgHealthcheck.Set("retries", 5)
|
|
|
|
if caps.HealthcheckStartInterval {
|
|
pgHealthcheck.Set("interval", "60s")
|
|
pgHealthcheck.Set("start_interval", "1s")
|
|
} else {
|
|
pgHealthcheck.Set("interval", "5s")
|
|
}
|
|
|
|
pgService := NewOrderedMap()
|
|
pgService.Set("image", "pgvector/pgvector:pg16")
|
|
pgService.Set("ports", []any{`"5433:5432"`})
|
|
pgService.Set("environment", pgEnv)
|
|
pgService.Set("command", []any{"postgres", "-c", "shared_preload_libraries=vector"})
|
|
pgService.Set("volumes", []any{"langgraph-data:/var/lib/postgresql/data"})
|
|
pgService.Set("healthcheck", pgHealthcheck)
|
|
|
|
services.Set("langgraph-postgres", pgService)
|
|
}
|
|
|
|
// --- Debugger service (optional) ---
|
|
if opts.DebuggerPort != 0 {
|
|
debuggerMap := DebuggerCompose(opts.DebuggerPort, opts.DebuggerBaseURL)
|
|
if debuggerMap != nil {
|
|
debuggerService, _ := debuggerMap.Get("langgraph-debugger")
|
|
services.Set("langgraph-debugger", debuggerService)
|
|
}
|
|
}
|
|
|
|
// --- langgraph-api service ---
|
|
apiEnv := NewOrderedMap()
|
|
apiEnv.Set("REDIS_URI", "redis://langgraph-redis:6379")
|
|
apiEnv.Set("POSTGRES_URI", postgresURI)
|
|
|
|
if opts.EngineRuntimeMode == "distributed" {
|
|
apiEnv.Set("N_JOBS_PER_WORKER", `"0"`)
|
|
}
|
|
|
|
apiDependsOn := NewOrderedMap()
|
|
redisCondition := NewOrderedMap()
|
|
redisCondition.Set("condition", "service_healthy")
|
|
apiDependsOn.Set("langgraph-redis", redisCondition)
|
|
|
|
if includeDB {
|
|
pgCondition := NewOrderedMap()
|
|
pgCondition.Set("condition", "service_healthy")
|
|
apiDependsOn.Set("langgraph-postgres", pgCondition)
|
|
}
|
|
|
|
apiService := NewOrderedMap()
|
|
apiService.Set("ports", []any{fmt.Sprintf(`"%d:8000"`, opts.Port)})
|
|
apiService.Set("depends_on", apiDependsOn)
|
|
apiService.Set("environment", apiEnv)
|
|
|
|
if opts.Image != "" {
|
|
apiService.Set("image", opts.Image)
|
|
}
|
|
|
|
if caps.HealthcheckStartInterval {
|
|
apiHealthcheck := NewOrderedMap()
|
|
apiHealthcheck.Set("test", "python /api/healthcheck.py")
|
|
apiHealthcheck.Set("interval", "60s")
|
|
apiHealthcheck.Set("start_interval", "1s")
|
|
apiHealthcheck.Set("start_period", "10s")
|
|
apiService.Set("healthcheck", apiHealthcheck)
|
|
}
|
|
|
|
services.Set("langgraph-api", apiService)
|
|
|
|
// --- Build final compose dict ---
|
|
composeDict := NewOrderedMap()
|
|
if includeDB {
|
|
volumes := NewOrderedMap()
|
|
volumeDriver := NewOrderedMap()
|
|
volumeDriver.Set("driver", "local")
|
|
volumes.Set("langgraph-data", volumeDriver)
|
|
composeDict.Set("volumes", volumes)
|
|
}
|
|
composeDict.Set("services", services)
|
|
|
|
return composeDict
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compose
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Compose generates a docker-compose YAML string from the given capabilities
|
|
// and options.
|
|
func Compose(caps *DockerCapabilities, opts ComposeOpts) string {
|
|
d := ComposeAsDict(caps, opts)
|
|
return DictToYAML(d, 0)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BuildDockerImage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// BuildDockerImage builds a Docker image from a LangGraph configuration.
|
|
// It shells out to docker build (or a custom docker command) with the
|
|
// generated Dockerfile piped via stdin.
|
|
func BuildDockerImage(opts BuildImageOpts) error {
|
|
dockerCmd := opts.DockerCommand
|
|
if len(dockerCmd) == 0 {
|
|
dockerCmd = []string{"docker", "build"}
|
|
}
|
|
|
|
// Pull the base image first if requested.
|
|
if opts.Pull {
|
|
pullCmd := exec.Command("docker", "pull", opts.Tag)
|
|
pullCmd.Stdout = os.Stdout
|
|
pullCmd.Stderr = os.Stderr
|
|
if err := pullCmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to pull image %s: %w", opts.Tag, err)
|
|
}
|
|
}
|
|
|
|
// Build the docker build arguments.
|
|
args := []string{
|
|
"-f", "-", // read Dockerfile from stdin
|
|
"-t", opts.Tag,
|
|
}
|
|
|
|
// Determine build context.
|
|
buildContext := "."
|
|
if opts.ConfigPath != "" {
|
|
// Use the parent directory of the config file by default.
|
|
idx := strings.LastIndex(opts.ConfigPath, "/")
|
|
if idx >= 0 {
|
|
buildContext = opts.ConfigPath[:idx]
|
|
}
|
|
}
|
|
|
|
// Generate the Dockerfile using the real config.ConfigToDocker.
|
|
dockerfile, additionalContexts, err := config.ConfigToDocker(opts.ConfigPath, opts.ConfigJSON, config.DockerOpts{
|
|
BaseImage: opts.BaseImage,
|
|
APIVersion: opts.APIVersion,
|
|
InstallCommand: opts.InstallCommand,
|
|
BuildCommand: opts.BuildCommand,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("generating Dockerfile: %w", err)
|
|
}
|
|
|
|
// Add additional build contexts (for dependencies outside the main context).
|
|
for name, path := range additionalContexts {
|
|
args = append(args, "--build-context", fmt.Sprintf("%s=%s", name, path))
|
|
}
|
|
|
|
// Assemble the full command.
|
|
fullArgs := append(dockerCmd[1:], args...)
|
|
fullArgs = append(fullArgs, opts.ExtraFlags...)
|
|
fullArgs = append(fullArgs, opts.Passthrough...)
|
|
fullArgs = append(fullArgs, buildContext)
|
|
|
|
cmd := exec.Command(dockerCmd[0], fullArgs...)
|
|
cmd.Stdin = strings.NewReader(dockerfile)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return cmd.Run()
|
|
}
|