Re-work Sketch's Docker setup.
We were being fancy and creating Dockerfiles for folks. This sometimes
worked, but quite often didn't.
Instead, we you have -base-image and -force-rebuild-container, and the
"cache key" for images is just the base image and the working dir.
The layer cake is
(base image)
(customization) [optional]
(repo) [/app]
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index b63d0e3..826778a 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -23,9 +23,6 @@
"golang.org/x/crypto/ssh"
"sketch.dev/browser"
- "sketch.dev/llm"
- "sketch.dev/llm/ant"
- "sketch.dev/llm/gem"
"sketch.dev/loop/server"
"sketch.dev/skribe"
"sketch.dev/webui"
@@ -69,6 +66,9 @@
// ForceRebuild forces rebuilding of the Docker image even if it exists
ForceRebuild bool
+ // BaseImage is the base Docker image to use for layering the repo
+ BaseImage string
+
// Host directory to copy container logs into, if not set to ""
ContainerLogDest string
@@ -166,7 +166,7 @@
return err
}
- imgName, err := findOrBuildDockerImage(ctx, config.Path, gitRoot, config.Model, config.ModelURL, config.ModelAPIKey, config.ForceRebuild, config.Verbose)
+ imgName, err := findOrBuildDockerImage(ctx, gitRoot, config.BaseImage, config.ForceRebuild, config.Verbose)
if err != nil {
return err
}
@@ -792,106 +792,137 @@
return nil
}
-func findOrBuildDockerImage(ctx context.Context, cwd, gitRoot, model, modelURL, modelAPIKey string, forceRebuild, verbose bool) (imgName string, err error) {
- h := sha256.Sum256([]byte(gitRoot))
- imgName = "sketch-" + hex.EncodeToString(h[:6])
-
- var curImgInitFilesHash string
- if out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{json .Config.Labels}}", imgName); err != nil {
- if strings.Contains(strings.ToLower(string(out)), "no such object") {
- // Image does not exist, continue and build it.
- curImgInitFilesHash = ""
- } else {
- return "", fmt.Errorf("docker inspect failed: %s, %v", out, err)
- }
- } else {
- m := map[string]string{}
- if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil {
- return "", fmt.Errorf("docker inspect output unparsable: %s, %v", out, err)
- }
- curImgInitFilesHash = m["sketch_context"]
+func findOrBuildDockerImage(ctx context.Context, gitRoot, baseImage string, forceRebuild, verbose bool) (imgName string, err error) {
+ // Default to the published sketch image if no base image is specified
+ if baseImage == "" {
+ imageTag := dockerfileBaseHash()
+ baseImage = fmt.Sprintf("%s:%s", dockerImgName, imageTag)
}
- candidates, err := findRepoDockerfiles(cwd, gitRoot)
+ // Ensure the base image exists locally, pull if necessary
+ if err := ensureBaseImageExists(ctx, baseImage); err != nil {
+ return "", fmt.Errorf("failed to ensure base image %s exists: %w", baseImage, err)
+ }
+
+ // Get the base image container ID for caching
+ baseImageID, err := getDockerImageID(ctx, baseImage)
if err != nil {
- return "", fmt.Errorf("find dockerfile: %w", err)
+ return "", fmt.Errorf("failed to get base image ID for %s: %w", baseImage, err)
}
- var initFiles map[string]string
- var dockerfilePath string
- var generatedDockerfile string
+ // Create a cache key based on base image ID and working directory
+ // Docker naming conventions restrict you to 20 characters per path component
+ // and only allow lowercase letters, digits, underscores, and dashes, so encoding
+ // the hash and the repo directory is sadly a bit of a non-starter.
+ cacheKey := createCacheKey(baseImageID, gitRoot)
+ imgName = "sketch-" + cacheKey
- // Prioritize Dockerfile.sketch over Dockerfile, then fall back to generated dockerfile
- if len(candidates) > 0 {
- dockerfilePath = prioritizeDockerfiles(candidates)
- contents, err := os.ReadFile(dockerfilePath)
- if err != nil {
- return "", err
- }
- fmt.Printf("using %s as dev env\n", dockerfilePath)
- if hashInitFiles(map[string]string{dockerfilePath: string(contents)}) == curImgInitFilesHash && !forceRebuild {
- return imgName, nil
- }
- } else {
- initFiles, err = readInitFiles(os.DirFS(gitRoot))
- if err != nil {
- return "", err
- }
- subPathWorkingDir, err := filepath.Rel(gitRoot, cwd)
- if err != nil {
- return "", err
- }
- initFileHash := hashInitFiles(initFiles)
- if curImgInitFilesHash == initFileHash && !forceRebuild {
- return imgName, nil
- }
-
- start := time.Now()
-
- var service llm.Service
- if model == "gemini" {
- service = &gem.Service{
- URL: modelURL,
- APIKey: modelAPIKey,
- HTTPC: http.DefaultClient,
+ // Check if the cached image exists and is up to date
+ if !forceRebuild {
+ if exists, err := dockerImageExists(ctx, imgName); err != nil {
+ return "", fmt.Errorf("failed to check if image exists: %w", err)
+ } else if exists {
+ if verbose {
+ fmt.Printf("using cached image %s\n", imgName)
}
- } else {
- service = &ant.Service{
- URL: modelURL,
- APIKey: modelAPIKey,
- HTTPC: http.DefaultClient,
- }
- }
-
- generatedDockerfile, err = createDockerfile(ctx, service, initFiles, subPathWorkingDir, verbose)
- if err != nil {
- return "", fmt.Errorf("create dockerfile: %w", err)
- }
- // Create a unique temporary directory for the Dockerfile
- tmpDir, err := os.MkdirTemp("", "sketch-docker-*")
- if err != nil {
- return "", fmt.Errorf("failed to create temporary directory: %w", err)
- }
- dockerfilePath = filepath.Join(tmpDir, tmpSketchDockerfile)
- if err := os.WriteFile(dockerfilePath, []byte(generatedDockerfile), 0o666); err != nil {
- return "", err
- }
- // Remove the temporary directory and all contents when done
- defer os.RemoveAll(tmpDir)
-
- if verbose {
- fmt.Fprintf(os.Stderr, "generated Dockerfile in %s:\n\t%s\n\n", time.Since(start).Round(time.Millisecond), strings.Replace(generatedDockerfile, "\n", "\n\t", -1))
+ return imgName, nil
}
}
+ // Build the layered image
+ if err := buildLayeredImage(ctx, imgName, baseImage, gitRoot, verbose); err != nil {
+ return "", fmt.Errorf("failed to build layered image: %w", err)
+ }
+
+ return imgName, nil
+}
+
+// ensureBaseImageExists checks if the base image exists locally and pulls it if not
+func ensureBaseImageExists(ctx context.Context, imageName string) error {
+ exists, err := dockerImageExists(ctx, imageName)
+ if err != nil {
+ return fmt.Errorf("failed to check if image exists: %w", err)
+ }
+
+ if !exists {
+ fmt.Printf("🐋 pulling base image %s...\n", imageName)
+ if out, err := combinedOutput(ctx, "docker", "pull", imageName); err != nil {
+ return fmt.Errorf("docker pull %s failed: %s: %w", imageName, out, err)
+ }
+ fmt.Printf("✅ successfully pulled %s\n", imageName)
+ }
+
+ return nil
+}
+
+// getDockerImageID gets the container ID for a Docker image
+func getDockerImageID(ctx context.Context, imageName string) (string, error) {
+ out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{.Id}}", imageName)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// createCacheKey creates a cache key from base image ID and working directory
+func createCacheKey(baseImageID, gitRoot string) string {
+ h := sha256.New()
+ h.Write([]byte(baseImageID))
+ h.Write([]byte(gitRoot))
+ return hex.EncodeToString(h.Sum(nil))[:12] // Use first 12 chars for shorter name
+}
+
+// dockerImageExists checks if a Docker image exists locally
+func dockerImageExists(ctx context.Context, imageName string) (bool, error) {
+ out, err := combinedOutput(ctx, "docker", "inspect", imageName)
+ if err != nil {
+ if strings.Contains(strings.ToLower(string(out)), "no such object") ||
+ strings.Contains(strings.ToLower(string(out)), "no such image") {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+// buildLayeredImage builds a new Docker image by layering the repo on top of the base image
+// TODO: git config stuff could be environment variables at runtime for email and username.
+// The git docs seem to say that http.postBuffer is a bug in our git proxy more than a thing
+// that's needed, but we haven't found the bug yet!
+func buildLayeredImage(ctx context.Context, imgName, baseImage, gitRoot string, _ bool) error {
+ dockerfileContent := fmt.Sprintf(`FROM %s
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME" && \
+ git config --global http.postBuffer 524288000
+COPY . /app
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+CMD ["/bin/sketch"]
+`, baseImage)
+
+ // Create a temporary directory for the Dockerfile
+ tmpDir, err := os.MkdirTemp("", "sketch-docker-*")
+ if err != nil {
+ return fmt.Errorf("failed to create temporary directory: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
+ if err := os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0o666); err != nil {
+ return fmt.Errorf("failed to write Dockerfile: %w", err)
+ }
+
+ // Get git user info
var gitUserEmail, gitUserName string
if out, err := combinedOutput(ctx, "git", "config", "--get", "user.email"); err != nil {
- return "", fmt.Errorf("git user.email is not set. Please run 'git config --global user.email \"your.email@example.com\"' to set your email address")
+ return fmt.Errorf("git user.email is not set. Please run 'git config --global user.email \"your.email@example.com\"' to set your email address")
} else {
gitUserEmail = strings.TrimSpace(string(out))
}
if out, err := combinedOutput(ctx, "git", "config", "--get", "user.name"); err != nil {
- return "", fmt.Errorf("git user.name is not set. Please run 'git config --global user.name \"Your Name\"' to set your name")
+ return fmt.Errorf("git user.name is not set. Please run 'git config --global user.name \"Your Name\"' to set your name")
} else {
gitUserName = strings.TrimSpace(string(out))
}
@@ -903,24 +934,9 @@
"-f", dockerfilePath,
"--build-arg", "GIT_USER_EMAIL=" + gitUserEmail,
"--build-arg", "GIT_USER_NAME=" + gitUserName,
+ ".",
}
- // Add the sketch_context label for image reuse detection
- var contextHash string
- if len(candidates) > 0 {
- // Building from Dockerfile.sketch or similar static file
- contents, err := os.ReadFile(dockerfilePath)
- if err != nil {
- return "", err
- }
- contextHash = hashInitFiles(map[string]string{dockerfilePath: string(contents)})
- } else {
- // Building from generated dockerfile
- contextHash = hashInitFiles(initFiles)
- }
- cmdArgs = append(cmdArgs, "--label", "sketch_context="+contextHash)
- cmdArgs = append(cmdArgs, ".")
-
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
cmd.Dir = gitRoot
// We print the docker build output whether or not the user
@@ -928,95 +944,14 @@
// and this gives good context.
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
- fmt.Printf("🏗️ building docker image %s... (use -verbose to see build output)\n", imgName)
+ fmt.Printf("🏗️ building docker image %s from base %s...\n", imgName, baseImage)
err = run(ctx, "docker build", cmd)
if err != nil {
- var msg string
- if generatedDockerfile != "" {
- if !verbose {
- fmt.Fprintf(os.Stderr, "Generated Dockerfile:\n\t%s\n\n", strings.Replace(generatedDockerfile, "\n", "\n\t", -1))
- }
- msg = fmt.Sprintf("\n\nThe generated Dockerfile failed to build.\nYou can override it by committing a Dockerfile to your project.")
- }
- return "", fmt.Errorf("docker build failed: %v%s", err, msg)
+ return fmt.Errorf("docker build failed: %v", err)
}
fmt.Printf("built docker image %s in %s\n", imgName, time.Since(start).Round(time.Millisecond))
- return imgName, nil
-}
-
-func findRepoDockerfiles(cwd, gitRoot string) ([]string, error) {
- files, err := findDirDockerfiles(cwd)
- if err != nil {
- return nil, err
- }
- if len(files) > 0 {
- return files, nil
- }
-
- path := cwd
- for path != gitRoot {
- path = filepath.Dir(path)
- files, err := findDirDockerfiles(path)
- if err != nil {
- return nil, err
- }
- if len(files) > 0 {
- return files, nil
- }
- }
- return files, nil
-}
-
-// prioritizeDockerfiles returns the highest priority dockerfile from a list of candidates.
-// Priority order: Dockerfile.sketch > Dockerfile > other Dockerfile.*
-func prioritizeDockerfiles(candidates []string) string {
- if len(candidates) == 0 {
- return ""
- }
- if len(candidates) == 1 {
- return candidates[0]
- }
-
- // Look for Dockerfile.sketch first (case insensitive)
- for _, candidate := range candidates {
- basename := strings.ToLower(filepath.Base(candidate))
- if basename == "dockerfile.sketch" {
- return candidate
- }
- }
-
- // Look for Dockerfile second (case insensitive)
- for _, candidate := range candidates {
- basename := strings.ToLower(filepath.Base(candidate))
- if basename == "dockerfile" {
- return candidate
- }
- }
-
- // Return first remaining candidate
- return candidates[0]
-}
-
-// findDirDockerfiles finds all "Dockerfile*" files in a directory.
-func findDirDockerfiles(root string) (res []string, err error) {
- err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if info.IsDir() && root != path {
- return filepath.SkipDir
- }
- name := strings.ToLower(info.Name())
- if name == "dockerfile" || strings.HasPrefix(name, "dockerfile.") || strings.HasSuffix(name, ".dockerfile") {
- res = append(res, path)
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return res, nil
+ return nil
}
func checkForEmptyGitRepo(ctx context.Context, path string) error {