blob: a9e873830180ccf4ca53beb8b6eddfbf0310c78a [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package dockerimg
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "io/fs"
11 "maps"
12 "net/http"
13 "slices"
14 "strings"
15 "text/template"
16
17 "sketch.dev/ant"
18)
19
20func hashInitFiles(initFiles map[string]string) string {
21 h := sha256.New()
22 for _, path := range slices.Sorted(maps.Keys(initFiles)) {
23 fmt.Fprintf(h, "%s\n%s\n\n", path, initFiles[path])
24 }
David Crawshaw11129492025-04-25 20:41:53 -070025 fmt.Fprintf(h, "docker template 1\n%s\n", dockerfileCustomTmpl)
26 fmt.Fprintf(h, "docker template 2\n%s\n", dockerfileDefaultTmpl)
Earl Lee2e463fb2025-04-17 11:22:22 -070027 return hex.EncodeToString(h.Sum(nil))
28}
29
David Crawshaw11129492025-04-25 20:41:53 -070030// DefaultImage is intended to ONLY be used by the pushdockerimg.go script.
31func DefaultImage() (name, dockerfile, hash string) {
32 buf := new(bytes.Buffer)
33 err := template.Must(template.New("dockerfile").Parse(dockerfileBaseTmpl)).Execute(buf, map[string]string{
34 "From": defaultBaseImg,
35 })
36 if err != nil {
37 panic(err)
38 }
39 return dockerfileDefaultImg, buf.String(), hashInitFiles(nil)
40}
41
42const dockerfileDefaultImg = "ghcr.io/boldsoftware/sketch:v1"
43const defaultBaseImg = "golang:1.24.2-alpine3.21"
44
David Crawshawbe10fa92025-04-18 01:16:00 -070045// TODO: add semgrep, prettier -- they require node/npm/etc which is more complicated than apk
46// If/when we do this, add them into the list of available tools in bash.go.
David Crawshaw11129492025-04-25 20:41:53 -070047const dockerfileBaseTmpl = `FROM {{.From}}
David Crawshawbe10fa92025-04-18 01:16:00 -070048
Philip Zeyligercc3ba222025-04-23 14:52:21 -070049RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf python3 curl vim
David Crawshawbe10fa92025-04-18 01:16:00 -070050
51ENV GOTOOLCHAIN=auto
52ENV GOPATH=/go
53ENV PATH="$GOPATH/bin:$PATH"
54
55RUN go install golang.org/x/tools/cmd/goimports@latest
56RUN go install golang.org/x/tools/gopls@latest
57RUN go install mvdan.cc/gofumpt@latest
58
59RUN mkdir -p /root/.cache/sketch/webui
David Crawshaw11129492025-04-25 20:41:53 -070060`
David Crawshawbe10fa92025-04-18 01:16:00 -070061
David Crawshaw11129492025-04-25 20:41:53 -070062const dockerfileFragment = `
David Crawshawbe10fa92025-04-18 01:16:00 -070063ARG GIT_USER_EMAIL
64ARG GIT_USER_NAME
65
66RUN git config --global user.email "$GIT_USER_EMAIL" && \
67 git config --global user.name "$GIT_USER_NAME"
68
69LABEL sketch_context="{{.InitFilesHash}}"
70COPY . /app
71
72WORKDIR /app{{.SubDir}}
73RUN if [ -f go.mod ]; then go mod download; fi
74
David Crawshaw11129492025-04-25 20:41:53 -070075{{.ExtraCmds}}
76
77CMD ["/bin/sketch"]
78`
79
80// dockerfileCustomTmpl is the dockerfile template used when the LLM
81// chooses a custom base image.
82const dockerfileCustomTmpl = dockerfileBaseTmpl + dockerfileFragment
83
84// dockerfileDefaultTmpl is the dockerfile used when the LLM went with
85// the defaultBaseImg. In this case, we use a pre-canned image.
86const dockerfileDefaultTmpl = "FROM " + dockerfileDefaultImg + "\n" + dockerfileFragment
David Crawshawbe10fa92025-04-18 01:16:00 -070087
Earl Lee2e463fb2025-04-17 11:22:22 -070088// createDockerfile creates a Dockerfile for a git repo.
89// It expects the relevant initFiles to have been provided.
90// If the sketch binary is being executed in a sub-directory of the repository,
91// the relative path is provided on subPathWorkingDir.
92func createDockerfile(ctx context.Context, httpc *http.Client, antURL, antAPIKey string, initFiles map[string]string, subPathWorkingDir string) (string, error) {
93 if subPathWorkingDir == "." {
94 subPathWorkingDir = ""
95 } else if subPathWorkingDir != "" && subPathWorkingDir[0] != '/' {
96 subPathWorkingDir = "/" + subPathWorkingDir
97 }
98 toolCalled := false
99 var dockerfileFROM, dockerfileExtraCmds string
100 runDockerfile := func(ctx context.Context, input json.RawMessage) (string, error) {
101 // TODO: unmarshal straight into a struct
102 var m map[string]any
103 if err := json.Unmarshal(input, &m); err != nil {
104 return "", fmt.Errorf(`input=%[1]v (%[1]T), wanted a map[string]any, got: %w`, input, err)
105 }
106 var ok bool
107 dockerfileFROM, ok = m["from"].(string)
108 if !ok {
109 return "", fmt.Errorf(`input["from"]=%[1]v (%[1]T), wanted a string`, m["path"])
110 }
111 dockerfileExtraCmds, ok = m["extra_cmds"].(string)
112 if !ok {
113 return "", fmt.Errorf(`input["extra_cmds"]=%[1]v (%[1]T), wanted a string`, m["path"])
114 }
115 toolCalled = true
116 return "OK", nil
117 }
118 convo := ant.NewConvo(ctx, antAPIKey)
119 if httpc != nil {
120 convo.HTTPC = httpc
121 }
122 if antURL != "" {
123 convo.URL = antURL
124 }
125 convo.Tools = []*ant.Tool{{
126 Name: "dockerfile",
127 Description: "Helps define a Dockerfile that sets up a dev environment for this project.",
128 Run: runDockerfile,
129 InputSchema: ant.MustSchema(`{
130 "type": "object",
131 "required": ["from", "extra_cmds"],
132 "properties": {
133 "from": {
134 "type": "string",
135 "description": "The alpine base image provided to the dockerfile FROM command"
136 },
137 "extra_cmds": {
138 "type": "string",
139 "description": "Extra commands to add to the dockerfile."
140 }
141 }
142}`),
143 }}
144
Earl Lee2e463fb2025-04-17 11:22:22 -0700145 // TODO: it's basically impossible to one-shot a python env. We need an agent loop for that.
146 // Right now the prompt contains a set of half-baked workarounds.
147
148 // If you want to edit the model prompt, run:
149 //
Philip Zeyligercc3ba222025-04-23 14:52:21 -0700150 // go test ./dockerimg -httprecord ".*" -rewritewant
Earl Lee2e463fb2025-04-17 11:22:22 -0700151 //
152 // Then look at the changes with:
153 //
Philip Zeyligercc3ba222025-04-23 14:52:21 -0700154 // git diff dockerimg/testdata/*.dockerfile
Earl Lee2e463fb2025-04-17 11:22:22 -0700155 //
156 // If the dockerfile changes are a strict improvement, commit all the changes.
157 msg := ant.Message{
158 Role: ant.MessageRoleUser,
159 Content: []ant.Content{{
160 Type: ant.ContentTypeText,
161 Text: `
162Call the dockerfile tool to create a Dockerfile.
163The parameters to dockerfile fill out the From and ExtraCmds
164template variables in the following Go template:
165
David Crawshaw11129492025-04-25 20:41:53 -0700166` + "```\n" + dockerfileCustomTmpl + "\n```" + `
Earl Lee2e463fb2025-04-17 11:22:22 -0700167
168In particular:
David Crawshaw11129492025-04-25 20:41:53 -0700169- Assume it is primarily a Go project. For a minimal env, prefer ` + defaultBaseImg + ` as a base image.
Earl Lee2e463fb2025-04-17 11:22:22 -0700170- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.
171 Favor using uv, and use one of these base images, depending on the preferred python version:
172 ghcr.io/astral-sh/uv:python3.13-alpine
173 ghcr.io/astral-sh/uv:python3.12-alpine
174 ghcr.io/astral-sh/uv:python3.11-alpine
175- When using pip to install packages, use: uv pip install --system.
176- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.
177- Include any tools particular to this repository that can be inferred from the given context.
178- Append || true to any apk add commands in case the package does not exist.
David Crawshaw11129492025-04-25 20:41:53 -0700179- Do NOT expose any ports.
180- Do NOT generate any CMD or ENTRYPOINT extra commands.
Earl Lee2e463fb2025-04-17 11:22:22 -0700181`,
182 }},
183 }
184 if len(initFiles) > 0 {
185 msg.Content[0].Text += "Here is the content of several files from the repository that may be relevant:\n\n"
186 }
187
188 for _, name := range slices.Sorted(maps.Keys(initFiles)) {
189 msg.Content = append(msg.Content, ant.Content{
190 Type: ant.ContentTypeText,
191 Text: fmt.Sprintf("Here is the contents %s:\n<file>\n%s\n</file>\n\n", name, initFiles[name]),
192 })
193 }
194 msg.Content = append(msg.Content, ant.Content{
195 Type: ant.ContentTypeText,
196 Text: "Now call the dockerfile tool.",
197 })
198 res, err := convo.SendMessage(msg)
199 if err != nil {
200 return "", err
201 }
202 if res.StopReason != ant.StopReasonToolUse {
203 return "", fmt.Errorf("expected stop reason %q, got %q", ant.StopReasonToolUse, res.StopReason)
204 }
205 if _, err := convo.ToolResultContents(context.TODO(), res); err != nil {
206 return "", err
207 }
208 if !toolCalled {
209 return "", fmt.Errorf("no dockerfile returned")
210 }
211
David Crawshaw11129492025-04-25 20:41:53 -0700212 tmpl := dockerfileCustomTmpl
213 if dockerfileFROM == defaultBaseImg {
214 // Because the LLM has chosen the image we recommended, we
215 // can use a pre-canned image of our entire template, which
216 // saves a lot of build time.
217 tmpl = dockerfileDefaultTmpl
218 }
219
Earl Lee2e463fb2025-04-17 11:22:22 -0700220 buf := new(bytes.Buffer)
David Crawshaw11129492025-04-25 20:41:53 -0700221 err = template.Must(template.New("dockerfile").Parse(tmpl)).Execute(buf, map[string]string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700222 "From": dockerfileFROM,
223 "ExtraCmds": dockerfileExtraCmds,
224 "InitFilesHash": hashInitFiles(initFiles),
225 "SubDir": subPathWorkingDir,
226 })
227 if err != nil {
228 return "", fmt.Errorf("dockerfile template failed: %w", err)
229 }
230
231 return buf.String(), nil
232}
233
234// For future reference: we can find the current git branch/checkout with: git symbolic-ref -q --short HEAD || git describe --tags --exact-match 2>/dev/null || git rev-parse HEAD
235
236func readInitFiles(fsys fs.FS) (map[string]string, error) {
237 result := make(map[string]string)
238
239 err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
240 if err != nil {
241 return err
242 }
243 if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
244 return fs.SkipDir
245 }
246 if !d.Type().IsRegular() {
247 return nil
248 }
249
250 // Case 1: Check for README files
251 // TODO: find README files between the .git root (where we start)
252 // and the dir that sketch was initialized. This needs more info
253 // plumbed to this function.
254 if strings.HasPrefix(strings.ToLower(path), "readme") {
255 content, err := fs.ReadFile(fsys, path)
256 if err != nil {
257 return err
258 }
259 result[path] = string(content)
260 return nil
261 }
262
263 // Case 2: Check for GitHub workflow files
264 if strings.HasPrefix(path, ".github/workflows/") {
265 content, err := fs.ReadFile(fsys, path)
266 if err != nil {
267 return err
268 }
269 result[path] = string(content)
270 return nil
271 }
272
273 return nil
274 })
275 if err != nil {
276 return nil, err
277 }
278 return result, nil
279}