Skip to content

Commit 0cb6b70

Browse files
committed
feat(agents): support uv python project detection and auto setup
1 parent 7262cf2 commit 0cb6b70

File tree

7 files changed

+186
-56
lines changed

7 files changed

+186
-56
lines changed

cmd/lk/agent.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,10 @@ func requireSecrets(_ context.Context, cmd *cli.Command, required, lazy bool) ([
10051005
secrets := make(map[string]*lkproto.AgentSecret)
10061006
for _, secret := range cmd.StringSlice("secrets") {
10071007
secret := strings.Split(secret, "=")
1008+
if len(secret) != 2 || secret[0] == "" || secret[1] == "" {
1009+
// Don't include empty secrets
1010+
continue
1011+
}
10081012
agentSecret := &lkproto.AgentSecret{
10091013
Name: secret[0],
10101014
Value: []byte(secret[1]),

pkg/agentfs/docker.go

Lines changed: 33 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import (
2121
"fmt"
2222
"os"
2323
"path/filepath"
24+
"slices"
2425
"strings"
2526

2627
"github.com/charmbracelet/huh"
28+
"github.com/pkg/errors"
2729

2830
"github.com/livekit/livekit-cli/v2/pkg/util"
2931
"github.com/livekit/protocol/logger"
@@ -51,29 +53,25 @@ func CreateDockerfile(dir string, settingsMap map[string]string) error {
5153
return fmt.Errorf("unable to fetch client settings from server, please try again later")
5254
}
5355

54-
projectType := ""
55-
if isNode(dir) {
56-
projectType = "node"
57-
} else if isPython(dir) {
58-
projectType = "python"
59-
} else {
60-
return fmt.Errorf("unable to determine project type, please create a Dockerfile in the current directory")
56+
projectType, err := DetectProjectType(dir)
57+
if err != nil {
58+
return errors.Wrap(err, "unable to determine project type, please create a Dockerfile in the current directory")
6159
}
6260

6361
var dockerfileContent []byte
6462
var dockerIgnoreContent []byte
65-
var err error
6663

67-
dockerfileContent, err = fs.ReadFile("examples/" + projectType + ".Dockerfile")
64+
dockerfileContent, err = fs.ReadFile("examples/" + string(projectType) + ".Dockerfile")
6865
if err != nil {
6966
return err
7067
}
71-
dockerIgnoreContent, err = fs.ReadFile("examples/" + projectType + ".dockerignore")
68+
dockerIgnoreContent, err = fs.ReadFile("examples/" + string(projectType) + ".dockerignore")
7269
if err != nil {
7370
return err
7471
}
7572

76-
if projectType == "python" {
73+
// TODO: (@rektdeckard) support Node entrypoint validation
74+
if projectType.IsPython() {
7775
dockerfileContent, err = validateEntrypoint(dir, dockerfileContent, projectType, settingsMap)
7876
if err != nil {
7977
return err
@@ -93,52 +91,39 @@ func CreateDockerfile(dir string, settingsMap map[string]string) error {
9391
return nil
9492
}
9593

96-
func validateEntrypoint(dir string, dockerfileContent []byte, projectType string, settingsMap map[string]string) ([]byte, error) {
97-
fileList := make(map[string]bool)
98-
entries, err := os.ReadDir(dir)
99-
if err != nil {
100-
return nil, err
101-
}
102-
103-
for _, entry := range entries {
104-
if entry.IsDir() {
105-
continue
106-
}
107-
fileList[entry.Name()] = true
108-
}
109-
94+
func validateEntrypoint(dir string, dockerfileContent []byte, projectType ProjectType, settingsMap map[string]string) ([]byte, error) {
11095
valFile := func(fileName string) (string, error) {
111-
if _, exists := fileList[fileName]; exists {
112-
return fileName, nil
113-
}
114-
115-
var suffix string
116-
switch projectType {
117-
case "python":
118-
suffix = ".py"
119-
case "node":
120-
suffix = ".js"
96+
// NOTE: we need to recurse to find entrypoints which may exist in src/ or some other directory.
97+
// This could be a lot of files, so we omit any files in .dockerignore, since they cannot be
98+
// used as entrypoints.
99+
var fileList []string
100+
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
101+
if err != nil {
102+
return err
103+
}
104+
if !d.IsDir() && strings.HasSuffix(d.Name(), ".py") {
105+
fileList = append(fileList, path)
106+
}
107+
return nil
108+
}); err != nil {
109+
panic(err)
121110
}
122111

123-
// Collect all matching files
124-
var options []string
125-
for _, entry := range entries {
126-
if strings.HasSuffix(entry.Name(), suffix) {
127-
options = append(options, entry.Name())
128-
}
112+
if slices.Contains(fileList, fileName) {
113+
return fileName, nil
129114
}
130115

131116
// If no matching files found, return early
132-
if len(options) == 0 {
117+
if len(fileList) == 0 {
133118
return "", nil
134119
}
135120

136121
var selected string
137122
form := huh.NewForm(
138123
huh.NewGroup(
139124
huh.NewSelect[string]().
140-
Title(fmt.Sprintf("Select %s file to use as entrypoint", projectType)).
141-
Options(huh.NewOptions(options...)...).
125+
Title(fmt.Sprintf("Select %s file to use as entrypoint", projectType.Lang())).
126+
Options(huh.NewOptions(fileList...)...).
142127
Value(&selected).
143128
WithTheme(util.Theme),
144129
),
@@ -152,8 +137,7 @@ func validateEntrypoint(dir string, dockerfileContent []byte, projectType string
152137
return selected, nil
153138
}
154139

155-
err = validateSettingsMap(settingsMap, []string{"python_entrypoint"})
156-
if err != nil {
140+
if err := validateSettingsMap(settingsMap, []string{"python_entrypoint"}); err != nil {
157141
return nil, err
158142
}
159143

@@ -165,7 +149,7 @@ func validateEntrypoint(dir string, dockerfileContent []byte, projectType string
165149

166150
lines := bytes.Split(dockerfileContent, []byte("\n"))
167151
var result bytes.Buffer
168-
for i := 0; i < len(lines); i++ {
152+
for i := range lines {
169153
line := lines[i]
170154
trimmedLine := bytes.TrimSpace(line)
171155

@@ -226,7 +210,7 @@ func validateEntrypoint(dir string, dockerfileContent []byte, projectType string
226210
return nil, err
227211
}
228212
for i, arg := range cmdArray {
229-
if strings.HasSuffix(arg, ".py") {
213+
if strings.HasSuffix(arg, projectType.FileExt()) {
230214
cmdArray[i] = newEntrypoint
231215
break
232216
}
@@ -237,7 +221,7 @@ func validateEntrypoint(dir string, dockerfileContent []byte, projectType string
237221
}
238222
fmt.Fprintf(&result, "CMD %s\n", newJSON)
239223
}
240-
} else if bytes.HasPrefix(trimmedLine, []byte(fmt.Sprintf("RUN python %s", pythonEntrypoint))) {
224+
} else if bytes.HasPrefix(trimmedLine, fmt.Appendf(nil, "RUN python %s", pythonEntrypoint)) {
241225
line = bytes.ReplaceAll(line, []byte(pythonEntrypoint), []byte(newEntrypoint))
242226
result.Write(line)
243227
if i < len(lines)-1 {
File renamed without changes.
File renamed without changes.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# This sample Dockerfile creates a production-ready container for a LiveKit voice AI agent
2+
# syntax=docker/dockerfile:1
3+
4+
# Use the official UV Python base image with Python 3.11 on Debian Bookworm
5+
# UV is a fast Python package manager that provides better performance than pip
6+
# We use the slim variant to keep the image size smaller while still having essential tools
7+
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
8+
9+
# Keeps Python from buffering stdout and stderr to avoid situations where
10+
# the application crashes without emitting any logs due to buffering.
11+
ENV PYTHONUNBUFFERED=1
12+
13+
# Create a non-privileged user that the app will run under.
14+
# See https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#user
15+
ARG UID=10001
16+
RUN adduser \
17+
--disabled-password \
18+
--gecos "" \
19+
--home "/home/appuser" \
20+
--shell "/sbin/nologin" \
21+
--uid "${UID}" \
22+
appuser
23+
24+
# Install build dependencies required for Python packages with native extensions
25+
# gcc: C compiler needed for building Python packages with C extensions
26+
# python3-dev: Python development headers needed for compilation
27+
# We clean up the apt cache after installation to keep the image size down
28+
RUN apt-get update && \
29+
apt-get install -y \
30+
gcc \
31+
python3-dev \
32+
&& rm -rf /var/lib/apt/lists/*
33+
34+
# Set the working directory to the user's home directory
35+
# This is where our application code will live
36+
WORKDIR /home/appuser
37+
38+
# Copy all application files into the container
39+
# This includes source code, configuration files, and dependency specifications
40+
# (Excludes files specified in .dockerignore)
41+
COPY . .
42+
43+
# Change ownership of all app files to the non-privileged user
44+
# This ensures the application can read/write files as needed
45+
RUN chown -R appuser:appuser /home/appuser
46+
47+
# Switch to the non-privileged user for all subsequent operations
48+
# This improves security by not running as root
49+
USER appuser
50+
51+
# Create a cache directory for the user
52+
# This is used by UV and Python for caching packages and bytecode
53+
RUN mkdir -p /home/appuser/.cache
54+
55+
# Install Python dependencies using UV's lock file
56+
# --locked ensures we use exact versions from uv.lock for reproducible builds
57+
# This creates a virtual environment and installs all dependencies
58+
# Ensure your uv.lock file is checked in for consistency across environments
59+
RUN uv sync --locked
60+
61+
# Pre-download any ML models or files the agent needs
62+
# This ensures the container is ready to run immediately without downloading
63+
# dependencies at runtime, which improves startup time and reliability
64+
RUN uv run src/agent.py download-files
65+
66+
# Expose the healthcheck port
67+
# This allows Docker and orchestration systems to check if the container is healthy
68+
EXPOSE 8081
69+
70+
# Run the application using UV
71+
# UV will activate the virtual environment and run the agent
72+
# The "start" command tells the worker to connect to LiveKit and begin waiting for jobs
73+
CMD ["uv", "run", "src/agent.py", "start"]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
*.egg-info
2+
__pycache__
3+
.pytest_cache
4+
.ruff_cache
5+
.env
6+
.env.*
7+
.DS_Store
8+
.idea
9+
.venv
10+
.vscode
11+
*.pyc
12+
*.pyo
13+
*.pyd
14+
.git
15+
.gitignore
16+
README.md
17+
LICENSE
18+
.github
19+
tests/

pkg/agentfs/utils.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package agentfs
1616

1717
import (
18+
"errors"
1819
"fmt"
1920
"os"
2021
"path/filepath"
@@ -23,18 +24,67 @@ import (
2324
"k8s.io/apimachinery/pkg/api/resource"
2425
)
2526

26-
func isPython(dir string) bool {
27-
if _, err := os.Stat(filepath.Join(dir, "requirements.txt")); err == nil {
28-
return true
27+
type ProjectType string
28+
29+
const (
30+
ProjectTypePythonPip ProjectType = "python.pip"
31+
ProjectTypePythonUV ProjectType = "python.uv"
32+
ProjectTypeNode ProjectType = "node"
33+
ProjectTypeUnknown ProjectType = "unknown"
34+
)
35+
36+
func (p ProjectType) IsPython() bool {
37+
return p == ProjectTypePythonPip || p == ProjectTypePythonUV
38+
}
39+
40+
func (p ProjectType) Lang() string {
41+
switch p {
42+
case ProjectTypePythonPip, ProjectTypePythonUV:
43+
return "Python"
44+
case ProjectTypeNode:
45+
return "Node.js"
46+
default:
47+
return ""
48+
}
49+
}
50+
51+
func (p ProjectType) FileExt() string {
52+
switch p {
53+
case ProjectTypePythonPip, ProjectTypePythonUV:
54+
return ".py"
55+
case ProjectTypeNode:
56+
return ".js"
57+
default:
58+
return ""
2959
}
30-
return false
60+
}
61+
62+
func isPythonPip(dir string) bool {
63+
_, err := os.Stat(filepath.Join(dir, "requirements.txt"))
64+
return err == nil
65+
}
66+
67+
func isPythonUV(dir string) bool {
68+
_, err := os.Stat(filepath.Join(dir, "pyproject.toml"))
69+
return err == nil
3170
}
3271

3372
func isNode(dir string) bool {
34-
if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil {
35-
return true
73+
_, err := os.Stat(filepath.Join(dir, "package.json"))
74+
return err == nil
75+
}
76+
77+
func DetectProjectType(dir string) (ProjectType, error) {
78+
if isNode(dir) {
79+
return ProjectTypeNode, nil
80+
}
81+
if isPythonPip(dir) {
82+
return ProjectTypePythonPip, nil
83+
}
84+
if isPythonUV(dir) {
85+
return ProjectTypePythonUV, nil
3686
}
37-
return false
87+
return ProjectTypeUnknown, errors.New("project type could not me identified, expect requirements.txt, pyproject.toml, or package.json")
3888
}
3989

4090
func ParseCpu(cpu string) (string, error) {

0 commit comments

Comments
 (0)