package docker import ( "archive/tar" "bufio" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "github.com/moby/moby/api/types/build" "github.com/moby/moby/client" ) // BuildImage builds a Docker image from a directory containing a Dockerfile // at the context root. Kept as a thin wrapper around BuildImageAt for the // static-site plugin which always emits its generated Dockerfile at the // context root. New code should prefer BuildImageAt so the Dockerfile path // is explicit. func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error { return c.BuildImageAt(ctx, contextDir, "Dockerfile", tag, nil) } // BuildImageAt builds a Docker image from a tar of contextDir, using the // Dockerfile at `dockerfile` *inside* the context (typically "Dockerfile" // but may be e.g. "docker/Dockerfile" when the user-supplied repo layout // keeps Dockerfiles in a subfolder). // // The dockerfile argument is the path *relative to contextDir*. Empty // strings are normalised to "Dockerfile" so callers can pass through a // user config value without sanitising twice. // // logFn, if non-nil, is invoked for every non-empty `stream` line the // daemon emits during the build. Callers use this to forward live build // progress (e.g. SSE bus). Errors from the daemon are NOT delivered via // logFn — they surface as the returned error so the caller's failure // path stays the single source of truth. func (c *Client) BuildImageAt(ctx context.Context, contextDir, dockerfile, tag string, logFn func(line string)) error { if dockerfile == "" { dockerfile = "Dockerfile" } // Normalise to forward slashes — the tar entry names use them and the // Docker daemon expects the same. dockerfile = filepath.ToSlash(dockerfile) // Defence-in-depth: the dockerfile path is relative to contextDir and // is increasingly user/config-supplied (subfolder Dockerfiles). Reject // absolute paths and any `..` traversal at the boundary so a value like // "../../etc/passwd" can never be handed to the daemon's build options, // regardless of which builder backend resolves it. if filepath.IsAbs(dockerfile) || strings.HasPrefix(dockerfile, "/") || dockerfile == ".." || strings.HasPrefix(dockerfile, "../") || strings.Contains(dockerfile, "/../") { return fmt.Errorf("docker build: invalid dockerfile path %q (must be relative to the build context, no traversal)", dockerfile) } // Create tar archive of the build context. pr, pw := io.Pipe() go func() { tw := tar.NewWriter(pw) err := filepath.Walk(contextDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(contextDir, path) if err != nil { return fmt.Errorf("rel path: %w", err) } relPath = filepath.ToSlash(relPath) if relPath == "." { return nil } header, err := tar.FileInfoHeader(info, "") if err != nil { return fmt.Errorf("tar header for %s: %w", relPath, err) } header.Name = relPath if err := tw.WriteHeader(header); err != nil { return fmt.Errorf("write tar header for %s: %w", relPath, err) } if info.IsDir() { return nil } // Per-file close, NOT defer. `defer file.Close()` inside the // WalkFunc only runs when the outer goroutine returns — for a // build context with thousands of files (node_modules-heavy // repo) that leaks one fd per file until the walk completes // and trips EMFILE on default ulimit=1024 systems. if err := streamFileIntoTar(tw, path, relPath); err != nil { return err } return nil }) if closeErr := tw.Close(); closeErr != nil && err == nil { err = closeErr } pw.CloseWithError(err) }() // Pin the legacy builder explicitly. On Docker Engine 23+ BuildKit // is the default for the CLI, but the daemon honours the explicit // Version field on ImageBuildOptions. Legacy builder does NOT support // `RUN --mount=type=bind,source=/host` so a malicious Dockerfile // cannot mount host paths into the build context. Switching to // BuildKit later requires (a) Dockerfile-content validation to // reject bind-mount hints, or (b) an explicit per-workload opt-in. resp, err := c.api.ImageBuild(ctx, pr, client.ImageBuildOptions{ Version: build.BuilderV1, Dockerfile: dockerfile, Tags: []string{tag}, Remove: true, ForceRemove: true, }) if err != nil { return fmt.Errorf("build image %s: %w", tag, err) } defer resp.Body.Close() // Drain the daemon's NDJSON stream to completion. The stream MUST // be read for the build to finish — closing the body early aborts // the build. We parse line-by-line into the {Stream, Error} shape // the daemon emits so an honest `{"error":"..."}` line surfaces // without false positives from informational `{"stream":"error // handling: retrying..."}` chatter that the old strings.Contains // path would have flagged. type buildLine struct { Stream string `json:"stream,omitempty"` Error string `json:"error,omitempty"` } scanner := bufio.NewScanner(resp.Body) // Some build steps emit single lines exceeding the default 64 KiB // (e.g. a fat go-mod-download dump). Bump to 1 MiB so we don't // silently truncate and miss the trailing error line. scanner.Buffer(make([]byte, 64*1024), 1024*1024) var firstErr string for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } var bl buildLine if err := json.Unmarshal(line, &bl); err != nil { // Non-JSON line — daemon shouldn't produce these, but // don't fail the build over a parse hiccup. continue } if bl.Error != "" && firstErr == "" { firstErr = bl.Error } if logFn != nil && bl.Stream != "" { logFn(bl.Stream) } } if err := scanner.Err(); err != nil { return fmt.Errorf("read build output for %s: %w", tag, err) } if firstErr != "" { return fmt.Errorf("build image %s: %s", tag, firstErr) } return nil } // streamFileIntoTar opens path, copies its contents into the tar writer // under the given relPath header, and closes the file *before returning* // — i.e. once per file, not deferred to the end of the entire walk. // Extracted so the per-iteration close discipline is obvious at the // callsite and the file handle isn't accidentally hoisted into the // caller's defer stack via a future refactor. func streamFileIntoTar(tw *tar.Writer, path, relPath string) error { file, err := os.Open(path) if err != nil { return fmt.Errorf("open %s: %w", path, err) } _, copyErr := io.Copy(tw, file) // Close BEFORE returning so the fd is released even on copy // failure. Capture both errors so the more-specific copy error // wins when both fire. if cerr := file.Close(); cerr != nil && copyErr == nil { copyErr = cerr } if copyErr != nil { return fmt.Errorf("copy %s to tar: %w", relPath, copyErr) } return nil }