blob: a787114724c860d923134ddf16e6ec12aa210c8d [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"
Earl Lee2e463fb2025-04-17 11:22:22 -070024 "sketch.dev/skribe"
Philip Zeyliger5d6af872025-04-23 19:48:34 -070025 "sketch.dev/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070026)
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
Philip Zeyliger18532b22025-04-23 21:11:46 +000075 // Outside information to pass to the container
76 OutsideHostname string
77 OutsideOS string
78 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -070079}
80
81// LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
82// It writes status to stdout.
83func LaunchContainer(ctx context.Context, stdout, stderr io.Writer, config ContainerConfig) error {
84 if _, err := exec.LookPath("docker"); err != nil {
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070085 if runtime.GOOS == "darwin" {
86 return fmt.Errorf("cannot find `docker` binary; run: brew install docker colima && colima start")
87 } else {
88 return fmt.Errorf("cannot find `docker` binary; install docker (e.g., apt-get install docker.io)")
89 }
Earl Lee2e463fb2025-04-17 11:22:22 -070090 }
91
92 if out, err := combinedOutput(ctx, "docker", "ps"); err != nil {
93 // `docker ps` provides a good error message here that can be
94 // easily chatgpt'ed by users, so send it to the user as-is:
95 // Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
96 return fmt.Errorf("docker ps: %s (%w)", out, err)
97 }
98
99 _, hostPort, err := net.SplitHostPort(config.LocalAddr)
100 if err != nil {
101 return err
102 }
103
104 gitRoot, err := findGitRoot(ctx, config.Path)
105 if err != nil {
106 return err
107 }
108
109 imgName, err := findOrBuildDockerImage(ctx, stdout, stderr, config.Path, gitRoot, config.AntURL, config.AntAPIKey, config.ForceRebuild)
110 if err != nil {
111 return err
112 }
113
114 linuxSketchBin := config.SketchBinaryLinux
115 if linuxSketchBin == "" {
116 linuxSketchBin, err = buildLinuxSketchBin(ctx, config.Path)
117 if err != nil {
118 return err
119 }
Josh Bleecher Snyder5544d142025-04-23 14:15:45 -0700120 defer os.Remove(linuxSketchBin) // in case of errors
Earl Lee2e463fb2025-04-17 11:22:22 -0700121 }
122
123 cntrName := imgName + "-" + config.SessionID
124 defer func() {
125 if config.NoCleanup {
126 return
127 }
128 if out, err := combinedOutput(ctx, "docker", "kill", cntrName); err != nil {
129 // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
130 _ = out
131 }
132 if out, err := combinedOutput(ctx, "docker", "rm", cntrName); err != nil {
133 // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
134 _ = out
135 }
136 }()
137
138 // errCh receives errors from operations that this function calls in separate goroutines.
139 errCh := make(chan error)
140
141 // Start the git server
142 gitSrv, err := newGitServer(gitRoot)
143 if err != nil {
144 return fmt.Errorf("failed to start git server: %w", err)
145 }
146 defer gitSrv.shutdown(ctx)
147
148 go func() {
149 errCh <- gitSrv.serve(ctx)
150 }()
151
152 // Get the current host git commit
153 var commit string
154 if out, err := combinedOutput(ctx, "git", "rev-parse", "HEAD"); err != nil {
155 return fmt.Errorf("git rev-parse HEAD: %w", err)
156 } else {
157 commit = strings.TrimSpace(string(out))
158 }
159 if out, err := combinedOutput(ctx, "git", "config", "http.receivepack", "true"); err != nil {
160 return fmt.Errorf("git config http.receivepack true: %s: %w", out, err)
161 }
162
163 relPath, err := filepath.Rel(gitRoot, config.Path)
164 if err != nil {
165 return err
166 }
167
168 // Create the sketch container
169 if err := createDockerContainer(ctx, cntrName, hostPort, relPath, imgName, config); err != nil {
170 return err
171 }
172
173 // Copy the sketch linux binary into the container
174 if out, err := combinedOutput(ctx, "docker", "cp", linuxSketchBin, cntrName+":/bin/sketch"); err != nil {
175 return fmt.Errorf("docker cp: %s, %w", out, err)
176 }
Josh Bleecher Snyder5544d142025-04-23 14:15:45 -0700177 os.Remove(linuxSketchBin) // in normal operations, the code below blocks, so actively delete now
Sean McCulloughf5bb3d32025-04-18 10:47:59 -0700178
179 // Make sure that the webui is built so we can copy the results to the container.
180 _, err = webui.Build()
181 if err != nil {
182 return fmt.Errorf("failed to build webui: %w", err)
183 }
184
David Crawshaw8bff16a2025-04-18 01:16:49 -0700185 webuiZipPath, err := webui.ZipPath()
186 if err != nil {
187 return err
188 }
189 if out, err := combinedOutput(ctx, "docker", "cp", webuiZipPath, cntrName+":/root/.cache/sketch/webui/"+filepath.Base(webuiZipPath)); err != nil {
190 return fmt.Errorf("docker cp: %s, %w", out, err)
191 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700192
David Crawshaw53786ef2025-04-24 12:52:51 -0700193 fmt.Printf("📦 running in container %s\n", cntrName)
Earl Lee2e463fb2025-04-17 11:22:22 -0700194
195 // Start the sketch container
196 if out, err := combinedOutput(ctx, "docker", "start", cntrName); err != nil {
197 return fmt.Errorf("docker start: %s, %w", out, err)
198 }
199
200 // Copies structured logs from the container to the host.
201 copyLogs := func() {
202 if config.ContainerLogDest == "" {
203 return
204 }
205 out, err := combinedOutput(ctx, "docker", "logs", cntrName)
206 if err != nil {
207 fmt.Fprintf(os.Stderr, "docker logs failed: %v\n", err)
208 return
209 }
Josh Bleecher Snyder7660e4e2025-04-24 10:34:17 -0700210 prefix := []byte("structured logs:")
211 for line := range bytes.Lines(out) {
212 rest, ok := bytes.CutPrefix(line, prefix)
213 if !ok {
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 continue
215 }
Josh Bleecher Snyder7660e4e2025-04-24 10:34:17 -0700216 logFile := string(bytes.TrimSpace(rest))
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 srcPath := fmt.Sprintf("%s:%s", cntrName, logFile)
218 logFileName := filepath.Base(logFile)
219 dstPath := filepath.Join(config.ContainerLogDest, logFileName)
220 _, err := combinedOutput(ctx, "docker", "cp", srcPath, dstPath)
221 if err != nil {
222 fmt.Fprintf(os.Stderr, "docker cp %s %s failed: %v\n", srcPath, dstPath, err)
223 }
224 fmt.Fprintf(os.Stderr, "\ncopied container log %s to %s\n", srcPath, dstPath)
225 }
226 }
227
228 // NOTE: we want to see what the internal sketch binary prints
229 // regardless of the setting of the verbosity flag on the external
230 // binary, so reading "docker logs", which is the stdout/stderr of
231 // the internal binary is not conditional on the verbose flag.
232 appendInternalErr := func(err error) error {
233 if err == nil {
234 return nil
235 }
236 out, logsErr := combinedOutput(ctx, "docker", "logs", cntrName)
Philip Zeyligerd1402952025-04-23 03:54:37 +0000237 if logsErr != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700238 return fmt.Errorf("%w; and docker logs failed: %s, %v", err, out, logsErr)
239 }
240 out = bytes.TrimSpace(out)
241 if len(out) > 0 {
242 return fmt.Errorf("docker logs: %s;\n%w", out, err)
243 }
244 return err
245 }
246
247 // Get the sketch server port from the container
Sean McCulloughae3480f2025-04-23 15:28:20 -0700248 localAddr, err := getContainerPort(ctx, cntrName, "80")
Earl Lee2e463fb2025-04-17 11:22:22 -0700249 if err != nil {
250 return appendInternalErr(err)
251 }
252
Sean McCulloughae3480f2025-04-23 15:28:20 -0700253 localSSHAddr, err := getContainerPort(ctx, cntrName, "22")
254 if err != nil {
255 return appendInternalErr(err)
256 }
257 sshHost, sshPort, err := net.SplitHostPort(localSSHAddr)
258 if err != nil {
Sean McCullough4854c652025-04-24 18:37:02 -0700259 return appendInternalErr(fmt.Errorf("Error splitting ssh host and port: %w", err))
Sean McCulloughae3480f2025-04-23 15:28:20 -0700260 }
Sean McCullough4854c652025-04-24 18:37:02 -0700261
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700262 var sshServerIdentity, sshUserIdentity []byte
Sean McCullough4854c652025-04-24 18:37:02 -0700263
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700264 if err := CheckForInclude(); err != nil {
265 fmt.Println(err.Error())
266 // continue - ssh config is not required for the rest of sketch to function locally.
267 } else {
268 cst, err := NewSSHTheather(cntrName, sshHost, sshPort)
269 if err != nil {
270 return appendInternalErr(fmt.Errorf("NewContainerSSHTheather: %w", err))
271 }
272
273 fmt.Printf(`Connect to this container via any of these methods:
Sean McCullough4854c652025-04-24 18:37:02 -0700274🖥️ ssh %s
275🖥️ code --remote ssh-remote+root@%s /app -n
276🔗 vscode://vscode-remote/ssh-remote+root@%s/app?n=true
277`, cntrName, cntrName, cntrName)
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700278 sshUserIdentity = cst.userIdentity
279 sshServerIdentity = cst.serverIdentity
280 defer func() {
281 if err := cst.Cleanup(); err != nil {
282 appendInternalErr(err)
283 }
284 }()
285 }
Sean McCulloughae3480f2025-04-23 15:28:20 -0700286
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 // Tell the sketch container which git server port and commit to initialize with.
288 go func() {
289 // TODO: Why is this called in a goroutine? I have found that when I pull this out
290 // of the goroutine and call it inline, then the terminal UI clears itself and all
291 // the scrollback (which is not good, but also not fatal). I can't see why it does this
292 // though, since none of the calls in postContainerInitConfig obviously write to stdout
293 // or stderr.
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700294 if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort, gitSrv.pass, sshServerIdentity, sshUserIdentity); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700295 slog.ErrorContext(ctx, "LaunchContainer.postContainerInitConfig", slog.String("err", err.Error()))
296 errCh <- appendInternalErr(err)
297 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700298
Philip Zeyliger6ed6adb2025-04-23 19:56:38 -0700299 // We open the browser after the init config because the above waits for the web server to be serving.
300 if config.OpenBrowser {
301 if config.SkabandAddr != "" {
302 OpenBrowser(ctx, fmt.Sprintf("%s/s/%s", config.SkabandAddr, config.SessionID))
303 } else {
304 OpenBrowser(ctx, "http://"+localAddr)
305 }
306 }
307 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700308
309 go func() {
310 cmd := exec.CommandContext(ctx, "docker", "attach", cntrName)
311 cmd.Stdin = os.Stdin
312 cmd.Stdout = os.Stdout
313 cmd.Stderr = os.Stderr
314 errCh <- run(ctx, "docker attach", cmd)
315 }()
316
317 defer copyLogs()
318
319 for {
320 select {
321 case <-ctx.Done():
322 return ctx.Err()
323 case err := <-errCh:
324 if err != nil {
325 return appendInternalErr(fmt.Errorf("container process: %w", err))
326 }
327 return nil
328 }
329 }
330}
331
332func combinedOutput(ctx context.Context, cmdName string, args ...string) ([]byte, error) {
333 cmd := exec.CommandContext(ctx, cmdName, args...)
334 // Really only needed for the "go build" command for the linux sketch binary
335 cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
336 start := time.Now()
337
338 out, err := cmd.CombinedOutput()
339 if err != nil {
340 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))))
341 } else {
342 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))))
343 }
344 return out, err
345}
346
347func run(ctx context.Context, cmdName string, cmd *exec.Cmd) error {
348 start := time.Now()
349 err := cmd.Run()
350 if err != nil {
351 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))))
352 } else {
353 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))))
354 }
355 return err
356}
357
358type gitServer struct {
359 gitLn net.Listener
360 gitPort string
361 srv *http.Server
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700362 pass string
Earl Lee2e463fb2025-04-17 11:22:22 -0700363}
364
365func (gs *gitServer) shutdown(ctx context.Context) {
366 gs.srv.Shutdown(ctx)
367 gs.gitLn.Close()
368}
369
370// Serve a git remote from the host for the container to fetch from and push to.
371func (gs *gitServer) serve(ctx context.Context) error {
372 slog.DebugContext(ctx, "starting git server", slog.String("git_remote_addr", "http://host.docker.internal:"+gs.gitPort+"/.git"))
373 return gs.srv.Serve(gs.gitLn)
374}
375
376func newGitServer(gitRoot string) (*gitServer, error) {
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -0700377 ret := &gitServer{
378 pass: rand.Text(),
379 }
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700380
Earl Lee2e463fb2025-04-17 11:22:22 -0700381 gitLn, err := net.Listen("tcp4", ":0")
382 if err != nil {
383 return nil, fmt.Errorf("git listen: %w", err)
384 }
385 ret.gitLn = gitLn
386
387 srv := http.Server{
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -0700388 Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: []byte(ret.pass)},
Earl Lee2e463fb2025-04-17 11:22:22 -0700389 }
390 ret.srv = &srv
391
392 _, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
393 if err != nil {
394 return nil, fmt.Errorf("git port: %w", err)
395 }
396 ret.gitPort = gitPort
397 return ret, nil
398}
399
400func createDockerContainer(ctx context.Context, cntrName, hostPort, relPath, imgName string, config ContainerConfig) error {
401 //, config.SessionID, config.GitUsername, config.GitEmail, config.SkabandAddr
402 // sessionID, gitUsername, gitEmail, skabandAddr string
David Crawshaw69c67312025-04-17 13:42:00 -0700403 cmdArgs := []string{
404 "create",
Earl Lee2e463fb2025-04-17 11:22:22 -0700405 "-it",
406 "--name", cntrName,
407 "-p", hostPort + ":80", // forward container port 80 to a host port
408 "-e", "ANTHROPIC_API_KEY=" + config.AntAPIKey,
409 }
410 if config.AntURL != "" {
411 cmdArgs = append(cmdArgs, "-e", "ANT_URL="+config.AntURL)
412 }
413 if config.SketchPubKey != "" {
414 cmdArgs = append(cmdArgs, "-e", "SKETCH_PUB_KEY="+config.SketchPubKey)
415 }
Sean McCulloughae3480f2025-04-23 15:28:20 -0700416 if config.SSHPort > 0 {
417 cmdArgs = append(cmdArgs, "-p", fmt.Sprintf("%d:22", config.SSHPort)) // forward container ssh port to host ssh port
418 } else {
419 cmdArgs = append(cmdArgs, "-p", "22") // use an ephemeral host port for ssh.
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700420 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700421 if relPath != "." {
422 cmdArgs = append(cmdArgs, "-w", "/app/"+relPath)
423 }
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700424 // colima does this by default, but Linux docker seems to need this set explicitly
425 cmdArgs = append(cmdArgs, "--add-host", "host.docker.internal:host-gateway")
Earl Lee2e463fb2025-04-17 11:22:22 -0700426 cmdArgs = append(
427 cmdArgs,
428 imgName,
429 "/bin/sketch",
430 "-unsafe",
431 "-addr=:80",
432 "-session-id="+config.SessionID,
Philip Zeyligerd1402952025-04-23 03:54:37 +0000433 "-git-username="+config.GitUsername,
434 "-git-email="+config.GitEmail,
Philip Zeyliger18532b22025-04-23 21:11:46 +0000435 "-outside-hostname="+config.OutsideHostname,
436 "-outside-os="+config.OutsideOS,
437 "-outside-working-dir="+config.OutsideWorkingDir,
Earl Lee2e463fb2025-04-17 11:22:22 -0700438 )
439 if config.SkabandAddr != "" {
440 cmdArgs = append(cmdArgs, "-skaband-addr="+config.SkabandAddr)
441 }
442 if out, err := combinedOutput(ctx, "docker", cmdArgs...); err != nil {
443 return fmt.Errorf("docker create: %s, %w", out, err)
444 }
445 return nil
446}
447
448func buildLinuxSketchBin(ctx context.Context, path string) (string, error) {
David Crawshaw8a617cb2025-04-18 01:28:43 -0700449 homeDir, err := os.UserHomeDir()
David Crawshaw69c67312025-04-17 13:42:00 -0700450 if err != nil {
451 return "", err
452 }
David Crawshaw8a617cb2025-04-18 01:28:43 -0700453 linuxGopath := filepath.Join(homeDir, ".cache", "sketch", "linuxgo")
454 if err := os.MkdirAll(linuxGopath, 0o777); err != nil {
455 return "", err
456 }
457
458 verToInstall := "@latest"
459 if out, err := exec.Command("go", "list", "-m").CombinedOutput(); err != nil {
460 return "", fmt.Errorf("failed to run go list -m: %s: %v", out, err)
461 } else {
462 if strings.TrimSpace(string(out)) == "sketch.dev" {
David Crawshaw094e4d22025-04-24 11:35:14 -0700463 slog.DebugContext(ctx, "built linux agent from currently checked out module")
David Crawshaw8a617cb2025-04-18 01:28:43 -0700464 verToInstall = ""
465 }
466 }
David Crawshaw69c67312025-04-17 13:42:00 -0700467
Earl Lee2e463fb2025-04-17 11:22:22 -0700468 start := time.Now()
David Crawshaw8a617cb2025-04-18 01:28:43 -0700469 cmd := exec.CommandContext(ctx, "go", "install", "sketch.dev/cmd/sketch"+verToInstall)
David Crawshawb9eaef52025-04-17 15:23:18 -0700470 cmd.Env = append(
471 os.Environ(),
472 "GOOS=linux",
473 "CGO_ENABLED=0",
474 "GOTOOLCHAIN=auto",
David Crawshaw8a617cb2025-04-18 01:28:43 -0700475 "GOPATH="+linuxGopath,
Josh Bleecher Snyderfae17572025-04-21 11:48:05 -0700476 "GOBIN=",
David Crawshawb9eaef52025-04-17 15:23:18 -0700477 )
Earl Lee2e463fb2025-04-17 11:22:22 -0700478
Earl Lee2e463fb2025-04-17 11:22:22 -0700479 out, err := cmd.CombinedOutput()
480 if err != nil {
481 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))))
482 return "", fmt.Errorf("failed to build linux sketch binary: %s: %w", out, err)
483 } else {
484 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))))
485 }
486
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700487 var src string
488 if runtime.GOOS != "linux" {
489 src = filepath.Join(linuxGopath, "bin", "linux_"+runtime.GOARCH, "sketch")
490 } else {
491 // If we are already on Linux, there's no extra platform name in the path
492 src = filepath.Join(linuxGopath, "bin", "sketch")
493 }
494
David Crawshaw69c67312025-04-17 13:42:00 -0700495 dst := filepath.Join(path, "tmp-sketch-binary-linux")
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700496 if err := moveFile(src, dst); err != nil {
David Crawshaw69c67312025-04-17 13:42:00 -0700497 return "", err
498 }
499
David Crawshaw69c67312025-04-17 13:42:00 -0700500 return dst, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700501}
502
Sean McCulloughae3480f2025-04-23 15:28:20 -0700503func getContainerPort(ctx context.Context, cntrName, cntrPort string) (string, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700504 localAddr := ""
Sean McCulloughae3480f2025-04-23 15:28:20 -0700505 if out, err := combinedOutput(ctx, "docker", "port", cntrName, cntrPort); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700506 return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
507 } else {
508 v4, _, found := strings.Cut(string(out), "\n")
509 if !found {
510 return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
511 }
512 localAddr = v4
513 if strings.HasPrefix(localAddr, "0.0.0.0") {
514 localAddr = "127.0.0.1" + strings.TrimPrefix(localAddr, "0.0.0.0")
515 }
516 }
517 return localAddr, nil
518}
519
520// Contact the container and configure it.
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700521func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort, gitPass string, sshServerIdentity, sshAuthorizedKeys []byte) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700522 localURL := "http://" + localAddr
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700523
524 initMsg, err := json.Marshal(
525 server.InitRequest{
526 Commit: commit,
527 GitRemoteAddr: fmt.Sprintf("http://sketch:%s@host.docker.internal:%s/.git", gitPass, gitPort),
528 HostAddr: localAddr,
529 SSHAuthorizedKeys: sshAuthorizedKeys,
530 SSHServerIdentity: sshServerIdentity,
531 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700532 if err != nil {
533 return fmt.Errorf("init msg: %w", err)
534 }
535
Earl Lee2e463fb2025-04-17 11:22:22 -0700536 // Note: this /init POST is handled in loop/server/loophttp.go:
537 initMsgByteReader := bytes.NewReader(initMsg)
538 req, err := http.NewRequest("POST", localURL+"/init", initMsgByteReader)
539 if err != nil {
540 return err
541 }
542
543 var res *http.Response
544 for i := 0; ; i++ {
545 time.Sleep(100 * time.Millisecond)
546 // If you DON'T reset this byteReader, then subsequent retries may end up sending 0 bytes.
547 initMsgByteReader.Reset(initMsg)
548 res, err = http.DefaultClient.Do(req)
549 if err != nil {
550 // In addition to "connection refused", we also occasionally see "EOF" errors that can succeed on retries.
551 if i < 100 && (strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF")) {
552 slog.DebugContext(ctx, "postContainerInitConfig retrying", slog.Int("retry", i), slog.String("err", err.Error()))
553 continue
554 }
555 return fmt.Errorf("failed to %s/init sketch in container, NOT retrying: err: %v", localURL, err)
556 }
557 break
558 }
559 resBytes, _ := io.ReadAll(res.Body)
560 if res.StatusCode != http.StatusOK {
561 return fmt.Errorf("failed to initialize sketch in container, response status code %d: %s", res.StatusCode, resBytes)
562 }
563 return nil
564}
565
566func findOrBuildDockerImage(ctx context.Context, stdout, stderr io.Writer, cwd, gitRoot, antURL, antAPIKey string, forceRebuild bool) (imgName string, err error) {
567 h := sha256.Sum256([]byte(gitRoot))
568 imgName = "sketch-" + hex.EncodeToString(h[:6])
569
570 var curImgInitFilesHash string
571 if out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{json .Config.Labels}}", imgName); err != nil {
572 if strings.Contains(string(out), "No such object") {
573 // Image does not exist, continue and build it.
574 curImgInitFilesHash = ""
575 } else {
576 return "", fmt.Errorf("docker inspect failed: %s, %v", out, err)
577 }
578 } else {
579 m := map[string]string{}
580 if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil {
581 return "", fmt.Errorf("docker inspect output unparsable: %s, %v", out, err)
582 }
583 curImgInitFilesHash = m["sketch_context"]
584 }
585
586 candidates, err := findRepoDockerfiles(cwd, gitRoot)
587 if err != nil {
588 return "", fmt.Errorf("find dockerfile: %w", err)
589 }
590
591 var initFiles map[string]string
592 var dockerfilePath string
593
594 // TODO: prefer a "Dockerfile.sketch" so users can tailor any env to this tool.
595 if len(candidates) == 1 && strings.ToLower(filepath.Base(candidates[0])) == "dockerfile" {
596 dockerfilePath = candidates[0]
597 contents, err := os.ReadFile(dockerfilePath)
598 if err != nil {
599 return "", err
600 }
601 fmt.Printf("using %s as dev env\n", candidates[0])
602 if hashInitFiles(map[string]string{dockerfilePath: string(contents)}) == curImgInitFilesHash && !forceRebuild {
Earl Lee2e463fb2025-04-17 11:22:22 -0700603 return imgName, nil
604 }
605 } else {
606 initFiles, err = readInitFiles(os.DirFS(gitRoot))
607 if err != nil {
608 return "", err
609 }
610 subPathWorkingDir, err := filepath.Rel(gitRoot, cwd)
611 if err != nil {
612 return "", err
613 }
614 initFileHash := hashInitFiles(initFiles)
615 if curImgInitFilesHash == initFileHash && !forceRebuild {
Earl Lee2e463fb2025-04-17 11:22:22 -0700616 return imgName, nil
617 }
618
619 start := time.Now()
620 dockerfile, err := createDockerfile(ctx, http.DefaultClient, antURL, antAPIKey, initFiles, subPathWorkingDir)
621 if err != nil {
622 return "", fmt.Errorf("create dockerfile: %w", err)
623 }
624 dockerfilePath = filepath.Join(cwd, "tmp-sketch-dockerfile")
625 if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0o666); err != nil {
626 return "", err
627 }
628 defer os.Remove(dockerfilePath)
629
630 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))
631 }
632
633 var gitUserEmail, gitUserName string
634 if out, err := combinedOutput(ctx, "git", "config", "--get", "user.email"); err != nil {
635 return "", fmt.Errorf("git config: %s: %v", out, err)
636 } else {
637 gitUserEmail = strings.TrimSpace(string(out))
638 }
639 if out, err := combinedOutput(ctx, "git", "config", "--get", "user.name"); err != nil {
640 return "", fmt.Errorf("git config: %s: %v", out, err)
641 } else {
642 gitUserName = strings.TrimSpace(string(out))
643 }
644
645 start := time.Now()
646 cmd := exec.CommandContext(ctx,
647 "docker", "build",
648 "-t", imgName,
649 "-f", dockerfilePath,
650 "--build-arg", "GIT_USER_EMAIL="+gitUserEmail,
651 "--build-arg", "GIT_USER_NAME="+gitUserName,
652 ".",
653 )
654 cmd.Dir = gitRoot
655 cmd.Stdout = stdout
656 cmd.Stderr = stderr
Josh Bleecher Snyderdf2d3dc2025-04-25 12:31:35 -0700657 fmt.Printf("🏗️ building docker image %s... (use -verbose to see build output)\n", imgName)
Philip Zeyligere4fa0e32025-04-23 14:15:55 -0700658 dockerfileContent, err := os.ReadFile(dockerfilePath)
659 if err != nil {
660 return "", fmt.Errorf("failed to read Dockerfile: %w", err)
661 }
Philip Zeyliger5d6af872025-04-23 19:48:34 -0700662 fmt.Fprintf(stdout, "Dockerfile:\n%s\n", string(dockerfileContent))
Earl Lee2e463fb2025-04-17 11:22:22 -0700663
664 err = run(ctx, "docker build", cmd)
665 if err != nil {
666 return "", fmt.Errorf("docker build failed: %v", err)
667 }
668 fmt.Printf("built docker image %s in %s\n", imgName, time.Since(start).Round(time.Millisecond))
669 return imgName, nil
670}
671
672func findRepoDockerfiles(cwd, gitRoot string) ([]string, error) {
673 files, err := findDirDockerfiles(cwd)
674 if err != nil {
675 return nil, err
676 }
677 if len(files) > 0 {
678 return files, nil
679 }
680
681 path := cwd
682 for path != gitRoot {
683 path = filepath.Dir(path)
684 files, err := findDirDockerfiles(path)
685 if err != nil {
686 return nil, err
687 }
688 if len(files) > 0 {
689 return files, nil
690 }
691 }
692 return files, nil
693}
694
695// findDirDockerfiles finds all "Dockerfile*" files in a directory.
696func findDirDockerfiles(root string) (res []string, err error) {
697 err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
698 if err != nil {
699 return err
700 }
701 if info.IsDir() && root != path {
702 return filepath.SkipDir
703 }
704 name := strings.ToLower(info.Name())
705 if name == "dockerfile" || strings.HasPrefix(name, "dockerfile.") {
706 res = append(res, path)
707 }
708 return nil
709 })
710 if err != nil {
711 return nil, err
712 }
713 return res, nil
714}
715
716func findGitRoot(ctx context.Context, path string) (string, error) {
717 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
718 cmd.Dir = path
719 out, err := cmd.CombinedOutput()
720 if err != nil {
721 if strings.Contains(string(out), "not a git repository") {
722 return "", fmt.Errorf(`sketch needs to run from within a git repo, but %s is not part of a git repo.
723Consider one of the following options:
724 - cd to a different dir that is already part of a git repo first, or
725 - to create a new git repo from this directory (%s), run this command:
726
727 git init . && git commit --allow-empty -m "initial commit"
728
729and try running sketch again.
730`, path, path)
731 }
732 return "", fmt.Errorf("git rev-parse --git-common-dir: %s: %w", out, err)
733 }
734 gitDir := strings.TrimSpace(string(out)) // location of .git dir, often as a relative path
735 absGitDir := filepath.Join(path, gitDir)
736 return filepath.Dir(absGitDir), err
737}
738
739func OpenBrowser(ctx context.Context, url string) {
740 var cmd *exec.Cmd
741 switch runtime.GOOS {
742 case "darwin":
743 cmd = exec.CommandContext(ctx, "open", url)
744 case "windows":
745 cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
746 default: // Linux and other Unix-like systems
747 cmd = exec.CommandContext(ctx, "xdg-open", url)
748 }
749 if b, err := cmd.CombinedOutput(); err != nil {
750 fmt.Fprintf(os.Stderr, "failed to open browser: %v: %s\n", err, b)
751 }
752}
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700753
754// moveFile is like Python's shutil.move, in that it tries a rename, and, if that fails,
755// copies and deletes
756func moveFile(src, dst string) error {
757 if err := os.Rename(src, dst); err == nil {
758 return nil
759 }
760
761 stat, err := os.Stat(src)
762 if err != nil {
763 return err
764 }
765
766 sourceFile, err := os.Open(src)
767 if err != nil {
768 return err
769 }
770 defer sourceFile.Close()
771
772 destFile, err := os.Create(dst)
773 if err != nil {
774 return err
775 }
776 defer destFile.Close()
777
778 _, err = io.Copy(destFile, sourceFile)
779 if err != nil {
780 return err
781 }
782
783 sourceFile.Close()
784 destFile.Close()
785
786 os.Chmod(dst, stat.Mode())
787
788 return os.Remove(src)
789}