Files
pack/buildkit/build.go
T

273 lines
6.6 KiB
Go

package buildkit
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/log"
"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/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/railwayapp/railpack/core/plan"
"github.com/tonistiigi/fsutil"
)
type BuildWithBuildkitClientOptions struct {
ImageName string
DumpLLB bool
OutputDir string
ProgressMode string
SecretsHash string
Secrets map[string]string
Platform BuildPlatform
ImportCache string
ExportCache string
CacheKey string
}
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 == "" {
log.Error("BUILDKIT_HOST environment variable is not set")
return fmt.Errorf("BUILDKIT_HOST environment variable is not set")
}
log.Debugf("Connecting to buildkit host: %s", buildkitHost)
c, err := client.New(ctx, buildkitHost)
if err != nil {
return fmt.Errorf("failed to connect to buildkit: %w", err)
}
defer 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 {
return fmt.Errorf("failed to get buildkit info: %w", err)
}
buildPlatform := opts.Platform
if (buildPlatform == BuildPlatform{}) {
buildPlatform = DetermineBuildPlatformFromHost()
}
llbState, image, err := ConvertPlanToLLB(plan, ConvertPlanOptions{
BuildPlatform: buildPlatform,
SecretsHash: opts.SecretsHash,
CacheKey: opts.CacheKey,
})
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 {
log.Info("Dumping LLB to stdout")
err = llb.WriteTo(def, os.Stdout)
if err != nil {
return fmt.Errorf("error writing LLB definition: %w", err)
}
return nil
}
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 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
if opts.ProgressMode == "plain" {
progressMode = progressui.PlainMode
} else if opts.ProgressMode == "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", buildPlatform.String(), info.BuildkitVersion.Version)
secretsMap := make(map[string][]byte)
for k, v := range opts.Secrets {
secretsMap[k] = []byte(v)
}
secrets := secretsprovider.FromMap(secretsMap)
solveOpts := client.SolveOpt{
LocalMounts: map[string]fsutil.FS{
"context": appFS,
},
Session: []session.Attachable{secrets},
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
},
},
},
}
// Add cache import if specified
if opts.ImportCache != "" {
solveOpts.CacheImports = append(solveOpts.CacheImports, client.CacheOptionsEntry{
Type: "gha",
Attrs: parseKeyValue(opts.ImportCache),
})
}
// Add cache export if specified
if opts.ExportCache != "" {
solveOpts.CacheExports = append(solveOpts.CacheExports, client.CacheOptionsEntry{
Type: "gha",
Attrs: parseKeyValue(opts.ExportCache),
})
}
// 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 = client.SolveOpt{
LocalMounts: map[string]fsutil.FS{
"context": appFS,
},
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)
}
}
buildDuration := time.Since(startTime)
log.Infof("Successfully built image in %.2fs", buildDuration.Seconds())
if opts.OutputDir != "" {
log.Infof("Saved image filesystem to directory `%s`", opts.OutputDir)
} else {
log.Infof("Run with `docker run -it %s`", imageName)
}
return nil
}
func getImageName(appDir string) string {
parts := strings.Split(appDir, string(os.PathSeparator))
name := parts[len(parts)-1]
if name == "" {
name = "railpack-app" // Fallback if path ends in separator
}
return name
}
// Helper function to parse key=value strings into a map
func parseKeyValue(s string) map[string]string {
attrs := make(map[string]string)
parts := strings.Split(s, ",")
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) == 2 {
attrs[kv[0]] = kv[1]
}
}
return attrs
}