blob: 6ef95ee2a224bb375131c6b0f54d24a637a19e01 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package dockerimg
2package dockerimg
3
4import (
5 "bytes"
6 "context"
Philip Zeyliger5e227dd2025-04-21 15:55:29 -07007 "crypto/rand"
Earl Lee2e463fb2025-04-17 11:22:22 -07008 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "fmt"
12 "io"
13 "log/slog"
14 "net"
15 "net/http"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "runtime"
20 "strings"
21 "time"
22
Sean McCulloughbaa2b592025-04-23 10:40:08 -070023 "sketch.dev/loop/server"
David Crawshaw8bff16a2025-04-18 01:16:49 -070024 "sketch.dev/loop/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "sketch.dev/skribe"
26)
27
28// ContainerConfig holds all configuration for launching a container
29type ContainerConfig struct {
30 // SessionID is the unique identifier for this session
31 SessionID string
32
33 // LocalAddr is the initial address to use (though it may be overwritten later)
34 LocalAddr string
35
36 // SkabandAddr is the address of the skaband service if available
37 SkabandAddr string
38
39 // AntURL is the URL of the LLM service.
40 AntURL string
41
42 // AntAPIKey is the API key for LLM service.
43 AntAPIKey string
44
45 // Path is the local filesystem path to use
46 Path string
47
48 // GitUsername is the username to use for git operations
49 GitUsername string
50
51 // GitEmail is the email to use for git operations
52 GitEmail string
53
54 // OpenBrowser determines whether to open a browser automatically
55 OpenBrowser bool
56
57 // NoCleanup prevents container cleanup when set to true
58 NoCleanup bool
59
60 // ForceRebuild forces rebuilding of the Docker image even if it exists
61 ForceRebuild bool
62
63 // Host directory to copy container logs into, if not set to ""
64 ContainerLogDest string
65
66 // Path to pre-built linux sketch binary, or build a new one if set to ""
67 SketchBinaryLinux string
68
69 // Sketch client public key.
70 SketchPubKey string
Philip Zeyligerd1402952025-04-23 03:54:37 +000071
Sean McCulloughbaa2b592025-04-23 10:40:08 -070072 // Host port for the container's ssh server
73 SSHPort int
74
75 // Public keys authorized to connect to the container's ssh server
76 SSHAuthorizedKeys []byte
77
78 // Private key used to identify the container's ssh server
79 SSHServerIdentity []byte
80
Philip Zeyligerd1402952025-04-23 03:54:37 +000081 // Host information to pass to the container
82 HostHostname string
83 HostOS string
84 HostWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -070085}
86
87// LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
88// It writes status to stdout.
89func LaunchContainer(ctx context.Context, stdout, stderr io.Writer, config ContainerConfig) error {
90 if _, err := exec.LookPath("docker"); err != nil {
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070091 if runtime.GOOS == "darwin" {
92 return fmt.Errorf("cannot find `docker` binary; run: brew install docker colima && colima start")
93 } else {
94 return fmt.Errorf("cannot find `docker` binary; install docker (e.g., apt-get install docker.io)")
95 }
Earl Lee2e463fb2025-04-17 11:22:22 -070096 }
97
98 if out, err := combinedOutput(ctx, "docker", "ps"); err != nil {
99 // `docker ps` provides a good error message here that can be
100 // easily chatgpt'ed by users, so send it to the user as-is:
101 // Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
102 return fmt.Errorf("docker ps: %s (%w)", out, err)
103 }
104
105 _, hostPort, err := net.SplitHostPort(config.LocalAddr)
106 if err != nil {
107 return err
108 }
109
110 gitRoot, err := findGitRoot(ctx, config.Path)
111 if err != nil {
112 return err
113 }
114
115 imgName, err := findOrBuildDockerImage(ctx, stdout, stderr, config.Path, gitRoot, config.AntURL, config.AntAPIKey, config.ForceRebuild)
116 if err != nil {
117 return err
118 }
119
120 linuxSketchBin := config.SketchBinaryLinux
121 if linuxSketchBin == "" {
122 linuxSketchBin, err = buildLinuxSketchBin(ctx, config.Path)
123 if err != nil {
124 return err
125 }
126 defer os.Remove(linuxSketchBin)
127 }
128
129 cntrName := imgName + "-" + config.SessionID
130 defer func() {
131 if config.NoCleanup {
132 return
133 }
134 if out, err := combinedOutput(ctx, "docker", "kill", cntrName); err != nil {
135 // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
136 _ = out
137 }
138 if out, err := combinedOutput(ctx, "docker", "rm", cntrName); err != nil {
139 // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
140 _ = out
141 }
142 }()
143
144 // errCh receives errors from operations that this function calls in separate goroutines.
145 errCh := make(chan error)
146
147 // Start the git server
148 gitSrv, err := newGitServer(gitRoot)
149 if err != nil {
150 return fmt.Errorf("failed to start git server: %w", err)
151 }
152 defer gitSrv.shutdown(ctx)
153
154 go func() {
155 errCh <- gitSrv.serve(ctx)
156 }()
157
158 // Get the current host git commit
159 var commit string
160 if out, err := combinedOutput(ctx, "git", "rev-parse", "HEAD"); err != nil {
161 return fmt.Errorf("git rev-parse HEAD: %w", err)
162 } else {
163 commit = strings.TrimSpace(string(out))
164 }
165 if out, err := combinedOutput(ctx, "git", "config", "http.receivepack", "true"); err != nil {
166 return fmt.Errorf("git config http.receivepack true: %s: %w", out, err)
167 }
168
169 relPath, err := filepath.Rel(gitRoot, config.Path)
170 if err != nil {
171 return err
172 }
173
174 // Create the sketch container
175 if err := createDockerContainer(ctx, cntrName, hostPort, relPath, imgName, config); err != nil {
176 return err
177 }
178
179 // Copy the sketch linux binary into the container
180 if out, err := combinedOutput(ctx, "docker", "cp", linuxSketchBin, cntrName+":/bin/sketch"); err != nil {
181 return fmt.Errorf("docker cp: %s, %w", out, err)
182 }
Sean McCulloughf5bb3d32025-04-18 10:47:59 -0700183
184 // Make sure that the webui is built so we can copy the results to the container.
185 _, err = webui.Build()
186 if err != nil {
187 return fmt.Errorf("failed to build webui: %w", err)
188 }
189
David Crawshaw8bff16a2025-04-18 01:16:49 -0700190 webuiZipPath, err := webui.ZipPath()
191 if err != nil {
192 return err
193 }
194 if out, err := combinedOutput(ctx, "docker", "cp", webuiZipPath, cntrName+":/root/.cache/sketch/webui/"+filepath.Base(webuiZipPath)); err != nil {
195 return fmt.Errorf("docker cp: %s, %w", out, err)
196 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700197
198 fmt.Printf("starting container %s\ncommits made by the agent will be pushed to \033[1msketch/*\033[0m\n", cntrName)
199
200 // Start the sketch container
201 if out, err := combinedOutput(ctx, "docker", "start", cntrName); err != nil {
202 return fmt.Errorf("docker start: %s, %w", out, err)
203 }
204
205 // Copies structured logs from the container to the host.
206 copyLogs := func() {
207 if config.ContainerLogDest == "" {
208 return
209 }
210 out, err := combinedOutput(ctx, "docker", "logs", cntrName)
211 if err != nil {
212 fmt.Fprintf(os.Stderr, "docker logs failed: %v\n", err)
213 return
214 }
215 logLines := strings.Split(string(out), "\n")
216 for _, logLine := range logLines {
217 if !strings.HasPrefix(logLine, "structured logs:") {
218 continue
219 }
220 logFile := strings.TrimSpace(strings.TrimPrefix(logLine, "structured logs:"))
221 srcPath := fmt.Sprintf("%s:%s", cntrName, logFile)
222 logFileName := filepath.Base(logFile)
223 dstPath := filepath.Join(config.ContainerLogDest, logFileName)
224 _, err := combinedOutput(ctx, "docker", "cp", srcPath, dstPath)
225 if err != nil {
226 fmt.Fprintf(os.Stderr, "docker cp %s %s failed: %v\n", srcPath, dstPath, err)
227 }
228 fmt.Fprintf(os.Stderr, "\ncopied container log %s to %s\n", srcPath, dstPath)
229 }
230 }
231
232 // NOTE: we want to see what the internal sketch binary prints
233 // regardless of the setting of the verbosity flag on the external
234 // binary, so reading "docker logs", which is the stdout/stderr of
235 // the internal binary is not conditional on the verbose flag.
236 appendInternalErr := func(err error) error {
237 if err == nil {
238 return nil
239 }
240 out, logsErr := combinedOutput(ctx, "docker", "logs", cntrName)
Philip Zeyligerd1402952025-04-23 03:54:37 +0000241 if logsErr != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700242 return fmt.Errorf("%w; and docker logs failed: %s, %v", err, out, logsErr)
243 }
244 out = bytes.TrimSpace(out)
245 if len(out) > 0 {
246 return fmt.Errorf("docker logs: %s;\n%w", out, err)
247 }
248 return err
249 }
250
251 // Get the sketch server port from the container
252 localAddr, err := getContainerPort(ctx, cntrName)
253 if err != nil {
254 return appendInternalErr(err)
255 }
256
257 // Tell the sketch container which git server port and commit to initialize with.
258 go func() {
259 // TODO: Why is this called in a goroutine? I have found that when I pull this out
260 // of the goroutine and call it inline, then the terminal UI clears itself and all
261 // the scrollback (which is not good, but also not fatal). I can't see why it does this
262 // though, since none of the calls in postContainerInitConfig obviously write to stdout
263 // or stderr.
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700264 if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort, gitSrv.pass, config.SSHServerIdentity, config.SSHAuthorizedKeys); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700265 slog.ErrorContext(ctx, "LaunchContainer.postContainerInitConfig", slog.String("err", err.Error()))
266 errCh <- appendInternalErr(err)
267 }
268 }()
269
270 if config.OpenBrowser {
271 OpenBrowser(ctx, "http://"+localAddr)
272 }
273
274 go func() {
275 cmd := exec.CommandContext(ctx, "docker", "attach", cntrName)
276 cmd.Stdin = os.Stdin
277 cmd.Stdout = os.Stdout
278 cmd.Stderr = os.Stderr
279 errCh <- run(ctx, "docker attach", cmd)
280 }()
281
282 defer copyLogs()
283
284 for {
285 select {
286 case <-ctx.Done():
287 return ctx.Err()
288 case err := <-errCh:
289 if err != nil {
290 return appendInternalErr(fmt.Errorf("container process: %w", err))
291 }
292 return nil
293 }
294 }
295}
296
297func combinedOutput(ctx context.Context, cmdName string, args ...string) ([]byte, error) {
298 cmd := exec.CommandContext(ctx, cmdName, args...)
299 // Really only needed for the "go build" command for the linux sketch binary
300 cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
301 start := time.Now()
302
303 out, err := cmd.CombinedOutput()
304 if err != nil {
305 slog.ErrorContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
306 } else {
307 slog.DebugContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
308 }
309 return out, err
310}
311
312func run(ctx context.Context, cmdName string, cmd *exec.Cmd) error {
313 start := time.Now()
314 err := cmd.Run()
315 if err != nil {
316 slog.ErrorContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
317 } else {
318 slog.DebugContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
319 }
320 return err
321}
322
323type gitServer struct {
324 gitLn net.Listener
325 gitPort string
326 srv *http.Server
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700327 pass string
Earl Lee2e463fb2025-04-17 11:22:22 -0700328}
329
330func (gs *gitServer) shutdown(ctx context.Context) {
331 gs.srv.Shutdown(ctx)
332 gs.gitLn.Close()
333}
334
335// Serve a git remote from the host for the container to fetch from and push to.
336func (gs *gitServer) serve(ctx context.Context) error {
337 slog.DebugContext(ctx, "starting git server", slog.String("git_remote_addr", "http://host.docker.internal:"+gs.gitPort+"/.git"))
338 return gs.srv.Serve(gs.gitLn)
339}
340
341func newGitServer(gitRoot string) (*gitServer, error) {
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -0700342 ret := &gitServer{
343 pass: rand.Text(),
344 }
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700345
Earl Lee2e463fb2025-04-17 11:22:22 -0700346 gitLn, err := net.Listen("tcp4", ":0")
347 if err != nil {
348 return nil, fmt.Errorf("git listen: %w", err)
349 }
350 ret.gitLn = gitLn
351
352 srv := http.Server{
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -0700353 Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: []byte(ret.pass)},
Earl Lee2e463fb2025-04-17 11:22:22 -0700354 }
355 ret.srv = &srv
356
357 _, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
358 if err != nil {
359 return nil, fmt.Errorf("git port: %w", err)
360 }
361 ret.gitPort = gitPort
362 return ret, nil
363}
364
365func createDockerContainer(ctx context.Context, cntrName, hostPort, relPath, imgName string, config ContainerConfig) error {
366 //, config.SessionID, config.GitUsername, config.GitEmail, config.SkabandAddr
367 // sessionID, gitUsername, gitEmail, skabandAddr string
David Crawshaw69c67312025-04-17 13:42:00 -0700368 cmdArgs := []string{
369 "create",
Earl Lee2e463fb2025-04-17 11:22:22 -0700370 "-it",
371 "--name", cntrName,
372 "-p", hostPort + ":80", // forward container port 80 to a host port
373 "-e", "ANTHROPIC_API_KEY=" + config.AntAPIKey,
374 }
375 if config.AntURL != "" {
376 cmdArgs = append(cmdArgs, "-e", "ANT_URL="+config.AntURL)
377 }
378 if config.SketchPubKey != "" {
379 cmdArgs = append(cmdArgs, "-e", "SKETCH_PUB_KEY="+config.SketchPubKey)
380 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700381 if config.SSHPort != 0 {
382 cmdArgs = append(cmdArgs, "-p", fmt.Sprintf("%d:2022", config.SSHPort)) // forward container ssh port to host ssh port
383 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700384 if relPath != "." {
385 cmdArgs = append(cmdArgs, "-w", "/app/"+relPath)
386 }
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700387 // colima does this by default, but Linux docker seems to need this set explicitly
388 cmdArgs = append(cmdArgs, "--add-host", "host.docker.internal:host-gateway")
Earl Lee2e463fb2025-04-17 11:22:22 -0700389 cmdArgs = append(
390 cmdArgs,
391 imgName,
392 "/bin/sketch",
393 "-unsafe",
394 "-addr=:80",
395 "-session-id="+config.SessionID,
Philip Zeyligerd1402952025-04-23 03:54:37 +0000396 "-git-username="+config.GitUsername,
397 "-git-email="+config.GitEmail,
398 "-host-hostname="+config.HostHostname,
399 "-host-os="+config.HostOS,
400 "-host-working-dir="+config.HostWorkingDir,
Earl Lee2e463fb2025-04-17 11:22:22 -0700401 )
402 if config.SkabandAddr != "" {
403 cmdArgs = append(cmdArgs, "-skaband-addr="+config.SkabandAddr)
404 }
405 if out, err := combinedOutput(ctx, "docker", cmdArgs...); err != nil {
406 return fmt.Errorf("docker create: %s, %w", out, err)
407 }
408 return nil
409}
410
411func buildLinuxSketchBin(ctx context.Context, path string) (string, error) {
David Crawshaw8a617cb2025-04-18 01:28:43 -0700412 homeDir, err := os.UserHomeDir()
David Crawshaw69c67312025-04-17 13:42:00 -0700413 if err != nil {
414 return "", err
415 }
David Crawshaw8a617cb2025-04-18 01:28:43 -0700416 linuxGopath := filepath.Join(homeDir, ".cache", "sketch", "linuxgo")
417 if err := os.MkdirAll(linuxGopath, 0o777); err != nil {
418 return "", err
419 }
420
421 verToInstall := "@latest"
422 if out, err := exec.Command("go", "list", "-m").CombinedOutput(); err != nil {
423 return "", fmt.Errorf("failed to run go list -m: %s: %v", out, err)
424 } else {
425 if strings.TrimSpace(string(out)) == "sketch.dev" {
426 fmt.Printf("building linux agent from currently checked out module\n")
427 verToInstall = ""
428 }
429 }
David Crawshaw69c67312025-04-17 13:42:00 -0700430
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 start := time.Now()
David Crawshaw8a617cb2025-04-18 01:28:43 -0700432 cmd := exec.CommandContext(ctx, "go", "install", "sketch.dev/cmd/sketch"+verToInstall)
David Crawshawb9eaef52025-04-17 15:23:18 -0700433 cmd.Env = append(
434 os.Environ(),
435 "GOOS=linux",
436 "CGO_ENABLED=0",
437 "GOTOOLCHAIN=auto",
David Crawshaw8a617cb2025-04-18 01:28:43 -0700438 "GOPATH="+linuxGopath,
Josh Bleecher Snyderfae17572025-04-21 11:48:05 -0700439 "GOBIN=",
David Crawshawb9eaef52025-04-17 15:23:18 -0700440 )
Earl Lee2e463fb2025-04-17 11:22:22 -0700441
442 fmt.Printf("building linux agent binary...\n")
443 out, err := cmd.CombinedOutput()
444 if err != nil {
445 slog.ErrorContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
446 return "", fmt.Errorf("failed to build linux sketch binary: %s: %w", out, err)
447 } else {
448 slog.DebugContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
449 }
450
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700451 var src string
452 if runtime.GOOS != "linux" {
453 src = filepath.Join(linuxGopath, "bin", "linux_"+runtime.GOARCH, "sketch")
454 } else {
455 // If we are already on Linux, there's no extra platform name in the path
456 src = filepath.Join(linuxGopath, "bin", "sketch")
457 }
458
David Crawshaw69c67312025-04-17 13:42:00 -0700459 dst := filepath.Join(path, "tmp-sketch-binary-linux")
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700460 if err := moveFile(src, dst); err != nil {
David Crawshaw69c67312025-04-17 13:42:00 -0700461 return "", err
462 }
463
Earl Lee2e463fb2025-04-17 11:22:22 -0700464 fmt.Printf("built linux agent binary in %s\n", time.Since(start).Round(100*time.Millisecond))
465
David Crawshaw69c67312025-04-17 13:42:00 -0700466 return dst, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700467}
468
469func getContainerPort(ctx context.Context, cntrName string) (string, error) {
470 localAddr := ""
471 if out, err := combinedOutput(ctx, "docker", "port", cntrName, "80"); err != nil {
472 return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
473 } else {
474 v4, _, found := strings.Cut(string(out), "\n")
475 if !found {
476 return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
477 }
478 localAddr = v4
479 if strings.HasPrefix(localAddr, "0.0.0.0") {
480 localAddr = "127.0.0.1" + strings.TrimPrefix(localAddr, "0.0.0.0")
481 }
482 }
483 return localAddr, nil
484}
485
486// Contact the container and configure it.
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700487func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort, gitPass string, sshServerIdentity, sshAuthorizedKeys []byte) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700488 localURL := "http://" + localAddr
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700489
490 initMsg, err := json.Marshal(
491 server.InitRequest{
492 Commit: commit,
493 GitRemoteAddr: fmt.Sprintf("http://sketch:%s@host.docker.internal:%s/.git", gitPass, gitPort),
494 HostAddr: localAddr,
495 SSHAuthorizedKeys: sshAuthorizedKeys,
496 SSHServerIdentity: sshServerIdentity,
497 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700498 if err != nil {
499 return fmt.Errorf("init msg: %w", err)
500 }
501
502 slog.DebugContext(ctx, "/init POST", slog.String("initMsg", string(initMsg)))
503
504 // Note: this /init POST is handled in loop/server/loophttp.go:
505 initMsgByteReader := bytes.NewReader(initMsg)
506 req, err := http.NewRequest("POST", localURL+"/init", initMsgByteReader)
507 if err != nil {
508 return err
509 }
510
511 var res *http.Response
512 for i := 0; ; i++ {
513 time.Sleep(100 * time.Millisecond)
514 // If you DON'T reset this byteReader, then subsequent retries may end up sending 0 bytes.
515 initMsgByteReader.Reset(initMsg)
516 res, err = http.DefaultClient.Do(req)
517 if err != nil {
518 // In addition to "connection refused", we also occasionally see "EOF" errors that can succeed on retries.
519 if i < 100 && (strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF")) {
520 slog.DebugContext(ctx, "postContainerInitConfig retrying", slog.Int("retry", i), slog.String("err", err.Error()))
521 continue
522 }
523 return fmt.Errorf("failed to %s/init sketch in container, NOT retrying: err: %v", localURL, err)
524 }
525 break
526 }
527 resBytes, _ := io.ReadAll(res.Body)
528 if res.StatusCode != http.StatusOK {
529 return fmt.Errorf("failed to initialize sketch in container, response status code %d: %s", res.StatusCode, resBytes)
530 }
531 return nil
532}
533
534func findOrBuildDockerImage(ctx context.Context, stdout, stderr io.Writer, cwd, gitRoot, antURL, antAPIKey string, forceRebuild bool) (imgName string, err error) {
535 h := sha256.Sum256([]byte(gitRoot))
536 imgName = "sketch-" + hex.EncodeToString(h[:6])
537
538 var curImgInitFilesHash string
539 if out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{json .Config.Labels}}", imgName); err != nil {
540 if strings.Contains(string(out), "No such object") {
541 // Image does not exist, continue and build it.
542 curImgInitFilesHash = ""
543 } else {
544 return "", fmt.Errorf("docker inspect failed: %s, %v", out, err)
545 }
546 } else {
547 m := map[string]string{}
548 if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil {
549 return "", fmt.Errorf("docker inspect output unparsable: %s, %v", out, err)
550 }
551 curImgInitFilesHash = m["sketch_context"]
552 }
553
554 candidates, err := findRepoDockerfiles(cwd, gitRoot)
555 if err != nil {
556 return "", fmt.Errorf("find dockerfile: %w", err)
557 }
558
559 var initFiles map[string]string
560 var dockerfilePath string
561
562 // TODO: prefer a "Dockerfile.sketch" so users can tailor any env to this tool.
563 if len(candidates) == 1 && strings.ToLower(filepath.Base(candidates[0])) == "dockerfile" {
564 dockerfilePath = candidates[0]
565 contents, err := os.ReadFile(dockerfilePath)
566 if err != nil {
567 return "", err
568 }
569 fmt.Printf("using %s as dev env\n", candidates[0])
570 if hashInitFiles(map[string]string{dockerfilePath: string(contents)}) == curImgInitFilesHash && !forceRebuild {
571 fmt.Printf("using existing docker image %s\n", imgName)
572 return imgName, nil
573 }
574 } else {
575 initFiles, err = readInitFiles(os.DirFS(gitRoot))
576 if err != nil {
577 return "", err
578 }
579 subPathWorkingDir, err := filepath.Rel(gitRoot, cwd)
580 if err != nil {
581 return "", err
582 }
583 initFileHash := hashInitFiles(initFiles)
584 if curImgInitFilesHash == initFileHash && !forceRebuild {
585 fmt.Printf("using existing docker image %s\n", imgName)
586 return imgName, nil
587 }
588
589 start := time.Now()
590 dockerfile, err := createDockerfile(ctx, http.DefaultClient, antURL, antAPIKey, initFiles, subPathWorkingDir)
591 if err != nil {
592 return "", fmt.Errorf("create dockerfile: %w", err)
593 }
594 dockerfilePath = filepath.Join(cwd, "tmp-sketch-dockerfile")
595 if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0o666); err != nil {
596 return "", err
597 }
598 defer os.Remove(dockerfilePath)
599
600 fmt.Fprintf(stderr, "generated Dockerfile in %s:\n\t%s\n\n", time.Since(start).Round(time.Millisecond), strings.Replace(dockerfile, "\n", "\n\t", -1))
601 }
602
603 var gitUserEmail, gitUserName string
604 if out, err := combinedOutput(ctx, "git", "config", "--get", "user.email"); err != nil {
605 return "", fmt.Errorf("git config: %s: %v", out, err)
606 } else {
607 gitUserEmail = strings.TrimSpace(string(out))
608 }
609 if out, err := combinedOutput(ctx, "git", "config", "--get", "user.name"); err != nil {
610 return "", fmt.Errorf("git config: %s: %v", out, err)
611 } else {
612 gitUserName = strings.TrimSpace(string(out))
613 }
614
615 start := time.Now()
616 cmd := exec.CommandContext(ctx,
617 "docker", "build",
618 "-t", imgName,
619 "-f", dockerfilePath,
620 "--build-arg", "GIT_USER_EMAIL="+gitUserEmail,
621 "--build-arg", "GIT_USER_NAME="+gitUserName,
622 ".",
623 )
624 cmd.Dir = gitRoot
625 cmd.Stdout = stdout
626 cmd.Stderr = stderr
627 fmt.Printf("building docker image %s...\n", imgName)
628
629 err = run(ctx, "docker build", cmd)
630 if err != nil {
631 return "", fmt.Errorf("docker build failed: %v", err)
632 }
633 fmt.Printf("built docker image %s in %s\n", imgName, time.Since(start).Round(time.Millisecond))
634 return imgName, nil
635}
636
637func findRepoDockerfiles(cwd, gitRoot string) ([]string, error) {
638 files, err := findDirDockerfiles(cwd)
639 if err != nil {
640 return nil, err
641 }
642 if len(files) > 0 {
643 return files, nil
644 }
645
646 path := cwd
647 for path != gitRoot {
648 path = filepath.Dir(path)
649 files, err := findDirDockerfiles(path)
650 if err != nil {
651 return nil, err
652 }
653 if len(files) > 0 {
654 return files, nil
655 }
656 }
657 return files, nil
658}
659
660// findDirDockerfiles finds all "Dockerfile*" files in a directory.
661func findDirDockerfiles(root string) (res []string, err error) {
662 err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
663 if err != nil {
664 return err
665 }
666 if info.IsDir() && root != path {
667 return filepath.SkipDir
668 }
669 name := strings.ToLower(info.Name())
670 if name == "dockerfile" || strings.HasPrefix(name, "dockerfile.") {
671 res = append(res, path)
672 }
673 return nil
674 })
675 if err != nil {
676 return nil, err
677 }
678 return res, nil
679}
680
681func findGitRoot(ctx context.Context, path string) (string, error) {
682 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
683 cmd.Dir = path
684 out, err := cmd.CombinedOutput()
685 if err != nil {
686 if strings.Contains(string(out), "not a git repository") {
687 return "", fmt.Errorf(`sketch needs to run from within a git repo, but %s is not part of a git repo.
688Consider one of the following options:
689 - cd to a different dir that is already part of a git repo first, or
690 - to create a new git repo from this directory (%s), run this command:
691
692 git init . && git commit --allow-empty -m "initial commit"
693
694and try running sketch again.
695`, path, path)
696 }
697 return "", fmt.Errorf("git rev-parse --git-common-dir: %s: %w", out, err)
698 }
699 gitDir := strings.TrimSpace(string(out)) // location of .git dir, often as a relative path
700 absGitDir := filepath.Join(path, gitDir)
701 return filepath.Dir(absGitDir), err
702}
703
704func OpenBrowser(ctx context.Context, url string) {
705 var cmd *exec.Cmd
706 switch runtime.GOOS {
707 case "darwin":
708 cmd = exec.CommandContext(ctx, "open", url)
709 case "windows":
710 cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
711 default: // Linux and other Unix-like systems
712 cmd = exec.CommandContext(ctx, "xdg-open", url)
713 }
714 if b, err := cmd.CombinedOutput(); err != nil {
715 fmt.Fprintf(os.Stderr, "failed to open browser: %v: %s\n", err, b)
716 }
717}
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700718
719// moveFile is like Python's shutil.move, in that it tries a rename, and, if that fails,
720// copies and deletes
721func moveFile(src, dst string) error {
722 if err := os.Rename(src, dst); err == nil {
723 return nil
724 }
725
726 stat, err := os.Stat(src)
727 if err != nil {
728 return err
729 }
730
731 sourceFile, err := os.Open(src)
732 if err != nil {
733 return err
734 }
735 defer sourceFile.Close()
736
737 destFile, err := os.Create(dst)
738 if err != nil {
739 return err
740 }
741 defer destFile.Close()
742
743 _, err = io.Copy(destFile, sourceFile)
744 if err != nil {
745 return err
746 }
747
748 sourceFile.Close()
749 destFile.Close()
750
751 os.Chmod(dst, stat.Mode())
752
753 return os.Remove(src)
754}