Files
pack/buildkit/build.go
T
zeekay 7844a60aff refactor(pack): modernize to github.com/hanzoai/pack and remove upstream branding
- Migrate module path to github.com/hanzoai/pack across all Go packages
- Remove legacy upstream docs and competitor brand references
- Modernize LLM.md and mise.toml for pristine Hanzo Pack builder
2026-09-04 00:40:42 -07:00

340 lines
9.2 KiB
Go

// called by the build CLI entrypoint and runs the build using the buildkit client
// also used by the integration tests to run builds in a test environment
package buildkit
import (
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/containerd/platforms"
"github.com/docker/cli/cli/config"
"github.com/moby/buildkit/client"
_ "github.com/moby/buildkit/client/connhelper/dockercontainer"
_ "github.com/moby/buildkit/client/connhelper/nerdctlcontainer"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/moby/buildkit/session/secrets/secretsprovider"
"github.com/moby/buildkit/util/appcontext"
_ "github.com/moby/buildkit/util/grpcutil/encoding/proto"
"github.com/moby/buildkit/util/progress/progressui"
"github.com/hanzoai/pack/core"
"github.com/hanzoai/pack/core/plan"
"github.com/tonistiigi/fsutil"
)
const (
buildkitHostNotSetError = `BUILDKIT_HOST environment variable is not set.
To start a local BuildKit daemon and set the environment variable run:
docker run --rm --privileged -d --name buildkit moby/buildkit
export BUILDKIT_HOST='docker-container://buildkit'`
buildkitInfoError = `failed to get buildkit information.
Most likely the $BUILDKIT_HOST is not running. Here's an example of how to start the build container:
docker run --rm --privileged -d --name buildkit moby/buildkit
Use 'railpack --verbose' to view more error details`
)
type BuildWithBuildkitClientOptions struct {
ImageName string
DumpLLB bool
OutputDir string
ProgressMode string
SecretsHash string
Secrets map[string]string
Platform string
ImportCache []string
ExportCache []string
CacheKey string
GitHubToken string
NoCache bool
}
func BuildWithBuildkitClient(appDir string, plan *plan.BuildPlan, opts BuildWithBuildkitClientOptions) error {
ctx := appcontext.Context()
imageName := opts.ImageName
if imageName == "" {
imageName = getImageName(appDir)
}
buildkitHost := os.Getenv("BUILDKIT_HOST")
if buildkitHost == "" {
return errors.New(buildkitHostNotSetError)
}
log.Debugf("Connecting to buildkit host: %s", buildkitHost)
// connecting to the buildkit host does *not* mean the specified build container is running
c, err := client.New(ctx, buildkitHost)
if err != nil {
return fmt.Errorf("failed to connect to buildkit: %w", err)
}
defer func() { _ = c.Close() }()
// Get the buildkit info early so we can ensure we can connect to the buildkit host
info, err := c.Info(ctx)
if err != nil {
log.Debugf("error getting buildkit info: %v", err)
return errors.New(buildkitInfoError)
}
// Parse the platform string using our helper function
buildPlatform, err := ParsePlatformWithDefaults(opts.Platform)
if err != nil {
return fmt.Errorf("failed to parse platform '%s': %w", opts.Platform, err)
}
llbState, image, err := ConvertPlanToLLB(plan, ConvertPlanOptions{
BuildPlatform: buildPlatform,
SecretsHash: opts.SecretsHash,
CacheKey: opts.CacheKey,
GitHubToken: opts.GitHubToken,
NoCache: opts.NoCache,
})
if err != nil {
return fmt.Errorf("error converting plan to LLB: %w", err)
}
imageBytes, err := json.Marshal(image)
if err != nil {
return fmt.Errorf("error marshalling image: %w", err)
}
def, err := llbState.Marshal(ctx, llb.LinuxAmd64)
if err != nil {
return fmt.Errorf("error marshaling LLB state: %w", err)
}
if opts.DumpLLB {
err = llb.WriteTo(def, os.Stdout)
if err != nil {
return fmt.Errorf("error writing LLB definition: %w", err)
}
return nil
}
core.PrettyPrintSectionHeader(os.Stdout, "Starting Docker Build...")
ch := make(chan *client.SolveStatus)
var pipeR *io.PipeReader
var pipeW *io.PipeWriter
errCh := make(chan error, 1)
// Only set up pipe and docker load if we're not saving to a directory
if opts.OutputDir == "" {
// Create a pipe to connect buildkit output to docker load
pipeR, pipeW = io.Pipe()
defer func() { _ = pipeR.Close() }()
// Pipe the image into `docker load`
go func() {
cmd := exec.Command("docker", "load")
cmd.Stdin = pipeR
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
errCh <- cmd.Run()
}()
}
progressDone := make(chan bool)
go func() {
displayCh := make(chan *client.SolveStatus)
go func() {
for s := range ch {
displayCh <- s
}
close(displayCh)
}()
progressMode := progressui.AutoMode
switch opts.ProgressMode {
case "plain":
progressMode = progressui.PlainMode
case "tty":
progressMode = progressui.TtyMode
}
display, err := progressui.NewDisplay(os.Stdout, progressMode)
if err != nil {
log.Error("failed to create progress display", "error", err)
}
_, err = display.UpdateFrom(ctx, displayCh)
if err != nil {
log.Error("failed to update progress display", "error", err)
}
progressDone <- true
}()
appFS, err := fsutil.NewFS(appDir)
if err != nil {
return fmt.Errorf("error creating FS: %w", err)
}
log.Debugf("Building image for %s with BuildKit %s", platforms.Format(buildPlatform), info.BuildkitVersion.Version)
secretsMap := make(map[string][]byte)
for k, v := range opts.Secrets {
secretsMap[k] = []byte(v)
}
secrets := secretsprovider.FromMap(secretsMap)
dockerConfig := config.LoadDefaultConfigFile(os.Stderr)
sessionAttachables := []session.Attachable{
secrets,
// buildkit does not use the local auth arguments by default, which prevents private repo access when running `railpack build`
authprovider.NewDockerAuthProvider(authprovider.DockerAuthProviderConfig{
AuthConfigProvider: authprovider.LoadAuthConfig(dockerConfig),
}),
}
solveOpts := client.SolveOpt{
LocalMounts: map[string]fsutil.FS{
"context": appFS,
},
Session: sessionAttachables,
Exports: []client.ExportEntry{
{
Type: client.ExporterDocker,
Attrs: map[string]string{
"name": imageName,
"containerimage.config": string(imageBytes),
},
Output: func(_ map[string]string) (io.WriteCloser, error) {
return pipeW, nil
},
},
},
}
solveOpts.CacheImports = cacheEntriesFromFlags(opts.ImportCache)
solveOpts.CacheExports = cacheEntriesFromFlags(opts.ExportCache)
log.Infof("cache imports: %v", solveOpts.CacheImports)
log.Infof("cache exports: %v", solveOpts.CacheExports)
// Save the resulting filesystem to a directory
if opts.OutputDir != "" {
err = os.MkdirAll(opts.OutputDir, 0755)
if err != nil {
return fmt.Errorf("error creating output directory: %w", err)
}
solveOpts.Exports = []client.ExportEntry{
{
Type: client.ExporterLocal,
OutputDir: opts.OutputDir,
},
}
}
startTime := time.Now()
_, err = c.Solve(ctx, def, solveOpts, ch)
// Wait for progress monitoring to complete
<-progressDone
if pipeW != nil {
_ = pipeW.Close()
}
if err != nil {
return fmt.Errorf("failed to solve: %w", err)
}
// Only wait for docker load if we used it
if opts.OutputDir == "" {
if err := <-errCh; err != nil {
return fmt.Errorf("docker load failed: %w", err)
}
}
// output nice build output
buildDuration := time.Since(startTime)
buildOutput := fmt.Sprintf("Successfully built image in %.2fs", buildDuration.Seconds())
if opts.OutputDir != "" {
buildOutput += fmt.Sprintf("\n\nSaved to:\n%s", core.FormatHighlight(opts.OutputDir))
} else {
command := fmt.Sprintf("docker run -it %s", imageName)
buildOutput += fmt.Sprintf("\n\nRun:\n%s", core.FormatHighlight(command))
}
core.PrettyPrintBox(buildOutput)
return nil
}
// determine the image name from the app dir path
func getImageName(appDir string) string {
parts := strings.Split(appDir, string(os.PathSeparator))
name := parts[len(parts)-1]
// TODO how could this happen in practice?
if name == "" {
name = "railpack-app" // Fallback if path ends in separator
}
// Docker requires image names to be lowercase
return strings.ToLower(name)
}
// Converts docker buildx-style cache flag values (e.g. type=registry,ref=...)
// into BuildKit CacheOptionsEntry values. Empty strings are skipped.
//
// Intentionally hand-rolled instead of using github.com/docker/buildx/util/buildflags
// (or pulling buildx solely for ParseCacheEntry). BuildKit has no public parser for
// these strings; buildx does, but the dependency cost outweighs the small amount of
// logic we need for the type=... form we document.
func cacheEntriesFromFlags(entries []string) []client.CacheOptionsEntry {
var out []client.CacheOptionsEntry
for _, entry := range entries {
if entry == "" {
continue
}
cacheType, attrs := extractCacheType(parseKeyValue(entry))
out = append(out, client.CacheOptionsEntry{
Type: cacheType,
Attrs: attrs,
})
}
return out
}
// parse comma-separated key=value strings into a map, ignoring entries without an "="
func parseKeyValue(s string) map[string]string {
attrs := make(map[string]string)
parts := strings.SplitSeq(s, ",")
for part := range parts {
key, value, found := strings.Cut(part, "=")
if !found {
continue
}
attrs[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return attrs
}
func extractCacheType(attrs map[string]string) (string, map[string]string) {
cacheType := attrs["type"]
cleanedAttrs := maps.Clone(attrs)
delete(cleanedAttrs, "type")
return cacheType, cleanedAttrs
}