Skip to content

Commit 3902374

Browse files
authored
Add generate dockerfile command (#655)
* Add generate dockerfile command * update * __ * remove entrypoint stuff * rm
1 parent e402e57 commit 3902374

File tree

3 files changed

+171
-78
lines changed

3 files changed

+171
-78
lines changed

cmd/lk/agent.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"errors"
2020
"fmt"
2121
"os"
22+
"path/filepath"
2223
"regexp"
2324
"slices"
2425
"strings"
@@ -111,6 +112,22 @@ var (
111112
DisableSliceFlagSeparator: true,
112113
ArgsUsage: "[working-dir]",
113114
},
115+
{
116+
Name: "dockerfile",
117+
Usage: "Generate Dockerfile and .dockerignore for your project",
118+
Before: createAgentClient,
119+
Action: generateAgentDockerfile,
120+
Flags: []cli.Flag{
121+
silentFlag,
122+
&cli.BoolFlag{
123+
Name: "overwrite",
124+
Usage: "Overwrite existing Dockerfile and/or .dockerignore if they exist",
125+
Required: false,
126+
Value: false,
127+
},
128+
},
129+
ArgsUsage: "[working-dir]",
130+
},
114131
{
115132
Name: "config",
116133
Usage: fmt.Sprintf("Creates a %s in the working directory for an existing agent.", config.LiveKitTOMLFile),
@@ -378,6 +395,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error {
378395
}
379396

380397
projectType, err := agentfs.DetectProjectType(workingDir)
398+
fmt.Printf("Detected project type [%s]\n", util.Accented(string(projectType)))
381399
if err != nil {
382400
return fmt.Errorf("unable to determine project type: %w, please use a supported project type, or create your own Dockerfile in the current directory", err)
383401
}
@@ -1232,3 +1250,68 @@ func requireConfig(workingDir, tomlFilename string) (bool, error) {
12321250
lkConfig, exists, err = config.LoadTOMLFile(workingDir, tomlFilename)
12331251
return exists, err
12341252
}
1253+
1254+
func generateAgentDockerfile(ctx context.Context, cmd *cli.Command) error {
1255+
if cmd.NArg() > 0 {
1256+
workingDir = cmd.Args().First()
1257+
}
1258+
1259+
if stat, err := os.Stat(workingDir); err != nil || !stat.IsDir() {
1260+
return fmt.Errorf("invalid working directory: %s", workingDir)
1261+
}
1262+
1263+
settingsMap, err := getClientSettings(ctx, cmd.Bool("silent"))
1264+
if err != nil {
1265+
return err
1266+
}
1267+
1268+
projectType, err := agentfs.DetectProjectType(workingDir)
1269+
fmt.Printf("Detected project type [%s]\n", util.Accented(string(projectType)))
1270+
if err != nil {
1271+
return fmt.Errorf("unable to determine project type: %w, please use a supported project type, or create your own Dockerfile in the current directory", err)
1272+
}
1273+
1274+
dockerfilePath := filepath.Join(workingDir, "Dockerfile")
1275+
dockerignorePath := filepath.Join(workingDir, ".dockerignore")
1276+
overwrite := cmd.Bool("overwrite")
1277+
1278+
writeDockerfile := true
1279+
writeDockerignore := true
1280+
if !overwrite {
1281+
if _, err := os.Stat(dockerfilePath); err == nil {
1282+
fmt.Println(util.Accented("Dockerfile") + " already exists; skipping. Use --overwrite to replace.")
1283+
writeDockerfile = false
1284+
}
1285+
if _, err := os.Stat(dockerignorePath); err == nil {
1286+
fmt.Println(util.Accented(".dockerignore") + " already exists; skipping. Use --overwrite to replace.")
1287+
writeDockerignore = false
1288+
}
1289+
}
1290+
1291+
if !writeDockerfile && !writeDockerignore {
1292+
return nil
1293+
}
1294+
1295+
// Generate contents without writing
1296+
dockerfileContent, dockerignoreContent, err := agentfs.GenerateDockerArtifacts(workingDir, projectType, settingsMap)
1297+
if err != nil {
1298+
return err
1299+
}
1300+
1301+
if writeDockerfile {
1302+
if err := os.WriteFile(dockerfilePath, dockerfileContent, 0644); err != nil {
1303+
return err
1304+
}
1305+
1306+
fmt.Printf("Wrote new %s\n", util.Accented("Dockerfile"))
1307+
}
1308+
1309+
if writeDockerignore {
1310+
if err := os.WriteFile(dockerignorePath, dockerignoreContent, 0644); err != nil {
1311+
return err
1312+
}
1313+
fmt.Printf("Wrote new %s\n", util.Accented(".dockerignore"))
1314+
}
1315+
1316+
return nil
1317+
}

pkg/agentfs/docker.go

Lines changed: 88 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
"fmt"
2121
"os"
2222
"path/filepath"
23-
"slices"
23+
"sort"
2424
"strings"
2525
"text/template"
2626

@@ -49,115 +49,134 @@ func HasDockerfile(dir string) (bool, error) {
4949
}
5050

5151
func CreateDockerfile(dir string, projectType ProjectType, settingsMap map[string]string) error {
52-
if len(settingsMap) == 0 {
53-
return fmt.Errorf("unable to fetch client settings from server, please try again later")
52+
dockerfileContent, dockerIgnoreContent, err := GenerateDockerArtifacts(dir, projectType, settingsMap)
53+
if err != nil {
54+
return err
5455
}
5556

56-
var dockerfileContent []byte
57-
var dockerIgnoreContent []byte
58-
var err error
57+
if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfileContent, 0644); err != nil {
58+
return err
59+
}
5960

60-
dockerfileContent, err = fs.ReadFile("examples/" + string(projectType) + ".Dockerfile")
61-
if err != nil {
61+
if err := os.WriteFile(filepath.Join(dir, ".dockerignore"), dockerIgnoreContent, 0644); err != nil {
6262
return err
6363
}
6464

65-
dockerIgnoreContent, err = fs.ReadFile("examples/" + string(projectType) + ".dockerignore")
65+
return nil
66+
}
67+
68+
// GenerateDockerArtifacts returns the Dockerfile and .dockerignore contents for the
69+
// provided project type without writing them to disk. The Dockerfile content may be
70+
// templated/validated (e.g., Python entrypoint).
71+
func GenerateDockerArtifacts(dir string, projectType ProjectType, settingsMap map[string]string) ([]byte, []byte, error) {
72+
if len(settingsMap) == 0 {
73+
return nil, nil, fmt.Errorf("unable to fetch client settings from server, please try again later")
74+
}
75+
76+
dockerfileContent, err := fs.ReadFile("examples/" + string(projectType) + ".Dockerfile")
6677
if err != nil {
67-
return err
78+
return nil, nil, err
79+
}
80+
81+
dockerIgnoreContent, err := fs.ReadFile("examples/" + string(projectType) + ".dockerignore")
82+
if err != nil {
83+
return nil, nil, err
6884
}
6985

7086
// TODO: (@rektdeckard) support Node entrypoint validation
7187
if projectType.IsPython() {
72-
dockerfileContent, err = validateEntrypoint(dir, dockerfileContent, dockerIgnoreContent, projectType, settingsMap)
88+
dockerfileContent, err = validateEntrypoint(dir, dockerfileContent, dockerIgnoreContent, projectType)
7389
if err != nil {
74-
return err
90+
return nil, nil, err
7591
}
7692
}
7793

78-
err = os.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfileContent, 0644)
94+
return dockerfileContent, dockerIgnoreContent, nil
95+
}
96+
97+
func validateEntrypoint(dir string, dockerfileContent []byte, dockerignoreContent []byte, projectType ProjectType) ([]byte, error) {
98+
// Build matcher from the Dockerignore content so we don't consider ignored files
99+
reader := bytes.NewReader(dockerignoreContent)
100+
patterns, err := ignorefile.ReadAll(reader)
79101
if err != nil {
80-
return err
102+
return nil, err
81103
}
82-
83-
err = os.WriteFile(filepath.Join(dir, ".dockerignore"), dockerIgnoreContent, 0644)
104+
matcher, err := patternmatcher.New(patterns)
84105
if err != nil {
85-
return err
106+
return nil, err
86107
}
87108

88-
return nil
89-
}
90-
91-
func validateEntrypoint(dir string, dockerfileContent []byte, dockerignoreContent []byte, projectType ProjectType, settingsMap map[string]string) ([]byte, error) {
92-
valFile := func(fileName string) (string, error) {
93-
// NOTE: we need to recurse to find entrypoints which may exist in src/ or some other directory.
94-
// This could be a lot of files, so we omit any files in .dockerignore, since they cannot be
95-
// used as entrypoints.
96-
97-
reader := bytes.NewReader(dockerignoreContent)
98-
patterns, err := ignorefile.ReadAll(reader)
109+
var fileList []string
110+
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
99111
if err != nil {
100-
return "", err
112+
return err
101113
}
102-
matcher, err := patternmatcher.New(patterns)
103-
if err != nil {
104-
return "", err
114+
if ignored, err := matcher.MatchesOrParentMatches(path); ignored {
115+
return nil
116+
} else if err != nil {
117+
return err
105118
}
106-
107-
var fileList []string
108-
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
109-
if err != nil {
110-
return err
111-
}
112-
if ignored, err := matcher.MatchesOrParentMatches(path); ignored {
119+
if !d.IsDir() && strings.HasSuffix(d.Name(), projectType.FileExt()) {
120+
// Exclude double-underscore files (e.g., __init__.py) which cannot be entrypoint
121+
// except for __main__.py, which is the default entrypoint for Python.
122+
if strings.HasPrefix(d.Name(), "__") && d.Name() != "__main__.py" {
113123
return nil
114-
} else if err != nil {
115-
return err
116-
}
117-
if !d.IsDir() && strings.HasSuffix(d.Name(), projectType.FileExt()) {
118-
fileList = append(fileList, path)
119124
}
120-
return nil
121-
}); err != nil {
122-
return "", fmt.Errorf("error walking directory %s: %w", dir, err)
123-
}
124-
125-
if slices.Contains(fileList, fileName) {
126-
return fileName, nil
125+
fileList = append(fileList, path)
127126
}
127+
return nil
128+
}); err != nil {
129+
return nil, fmt.Errorf("error walking directory %s: %w", dir, err)
130+
}
128131

129-
// If no matching files found, return early
130-
if len(fileList) == 0 {
131-
return "", nil
132+
// Prioritize common entrypoint filenames at the top of the list
133+
if len(fileList) > 1 {
134+
priority := func(p string) int {
135+
name := filepath.Base(p)
136+
switch name {
137+
case "__main__.py":
138+
return 0
139+
case "main.py":
140+
return 1
141+
case "agent.py":
142+
return 2
143+
default:
144+
return 3
145+
}
132146
}
147+
sort.SliceStable(fileList, func(i, j int) bool {
148+
pi := priority(fileList[i])
149+
pj := priority(fileList[j])
150+
if pi != pj {
151+
return pi < pj
152+
}
153+
return fileList[i] < fileList[j]
154+
})
155+
}
133156

134-
var selected string
157+
var newEntrypoint string
158+
if len(fileList) == 0 {
159+
newEntrypoint = "main.py"
160+
} else if len(fileList) == 1 {
161+
newEntrypoint = fileList[0]
162+
} else {
163+
selected := fileList[0]
135164
form := huh.NewForm(
136165
huh.NewGroup(
137166
huh.NewSelect[string]().
138-
Title(fmt.Sprintf("Select %s file to use as entrypoint", projectType.Lang())).
167+
Title(fmt.Sprintf("Select the %s file which contains your agent's entrypoint", projectType.Lang())).
139168
Options(huh.NewOptions(fileList...)...).
140169
Value(&selected).
141170
WithTheme(util.Theme),
142171
),
143172
)
144-
145173
if err := form.Run(); err != nil {
146-
return "", err
174+
return nil, err
147175
}
148-
149-
return selected, nil
176+
newEntrypoint = selected
150177
}
151178

152-
if err := validateSettingsMap(settingsMap, []string{"python_entrypoint"}); err != nil {
153-
return nil, err
154-
}
155-
156-
pythonEntrypoint := settingsMap["python_entrypoint"]
157-
newEntrypoint, err := valFile(pythonEntrypoint)
158-
if err != nil {
159-
return nil, err
160-
}
179+
fmt.Printf("Using entrypoint file [%s]\n", util.Accented(newEntrypoint))
161180

162181
tpl := template.Must(template.New("Dockerfile").Parse(string(dockerfileContent)))
163182
buf := &bytes.Buffer{}

pkg/agentfs/utils.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,3 @@ func ParseMem(mem string, suffix bool) (string, error) {
171171
}
172172
return fmt.Sprintf("%.2g", memGB), nil
173173
}
174-
175-
func validateSettingsMap(settingsMap map[string]string, keys []string) error {
176-
for _, key := range keys {
177-
if _, ok := settingsMap[key]; !ok {
178-
return fmt.Errorf("client setting %s is required, please try again later", key)
179-
}
180-
}
181-
return nil
182-
}

0 commit comments

Comments
 (0)