buildkit/build.go
buildkit/build.goBrowse 1970 files
2,498 tokens
9,437 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1// called by the build CLI entrypoint and runs the build using the buildkit client2// also used by the integration tests to run builds in a test environment3 4package buildkit5 6import (7 "encoding/json"8 "errors"9 "fmt"10 "io"11 "maps"12 "os"13 "os/exec"14 "strings"15 "time"16 17 "github.com/charmbracelet/log"18 "github.com/containerd/platforms"19 "github.com/docker/cli/cli/config"20 "github.com/moby/buildkit/client"21 _ "github.com/moby/buildkit/client/connhelper/dockercontainer"22 _ "github.com/moby/buildkit/client/connhelper/nerdctlcontainer"23 "github.com/moby/buildkit/client/llb"24 "github.com/moby/buildkit/session"25 "github.com/moby/buildkit/session/auth/authprovider"26 "github.com/moby/buildkit/session/secrets/secretsprovider"27 "github.com/moby/buildkit/util/appcontext"28 _ "github.com/moby/buildkit/util/grpcutil/encoding/proto"29 "github.com/moby/buildkit/util/progress/progressui"30 "github.com/railwayapp/railpack/core"31 "github.com/railwayapp/railpack/core/plan"32 "github.com/tonistiigi/fsutil"33)34 35const (36 buildkitHostNotSetError = `BUILDKIT_HOST environment variable is not set.37 38To start a local BuildKit daemon and set the environment variable run:39 40 docker run --rm --privileged -d --name buildkit moby/buildkit41 export BUILDKIT_HOST='docker-container://buildkit'`42 43 buildkitInfoError = `failed to get buildkit information.44 45Most likely the $BUILDKIT_HOST is not running. Here's an example of how to start the build container:46 47 docker run --rm --privileged -d --name buildkit moby/buildkit48 49Use 'railpack --verbose' to view more error details`50)51 52type BuildWithBuildkitClientOptions struct {53 ImageName string54 DumpLLB bool55 OutputDir string56 ProgressMode string57 SecretsHash string58 Secrets map[string]string59 Platform string60 ImportCache []string61 ExportCache []string62 CacheKey string63 GitHubToken string64 NoCache bool65}66 67func BuildWithBuildkitClient(appDir string, plan *plan.BuildPlan, opts BuildWithBuildkitClientOptions) error {68 ctx := appcontext.Context()69 70 imageName := opts.ImageName71 if imageName == "" {72 imageName = getImageName(appDir)73 }74 75 buildkitHost := os.Getenv("BUILDKIT_HOST")76 if buildkitHost == "" {77 return errors.New(buildkitHostNotSetError)78 }79 80 log.Debugf("Connecting to buildkit host: %s", buildkitHost)81 82 // connecting to the buildkit host does *not* mean the specified build container is running83 c, err := client.New(ctx, buildkitHost)84 if err != nil {85 return fmt.Errorf("failed to connect to buildkit: %w", err)86 }87 defer func() { _ = c.Close() }()88 89 // Get the buildkit info early so we can ensure we can connect to the buildkit host90 info, err := c.Info(ctx)91 if err != nil {92 log.Debugf("error getting buildkit info: %v", err)93 return errors.New(buildkitInfoError)94 }95 96 // Parse the platform string using our helper function97 buildPlatform, err := ParsePlatformWithDefaults(opts.Platform)98 if err != nil {99 return fmt.Errorf("failed to parse platform '%s': %w", opts.Platform, err)100 }101 102 llbState, image, err := ConvertPlanToLLB(plan, ConvertPlanOptions{103 BuildPlatform: buildPlatform,104 SecretsHash: opts.SecretsHash,105 CacheKey: opts.CacheKey,106 GitHubToken: opts.GitHubToken,107 NoCache: opts.NoCache,108 })109 if err != nil {110 return fmt.Errorf("error converting plan to LLB: %w", err)111 }112 113 imageBytes, err := json.Marshal(image)114 if err != nil {115 return fmt.Errorf("error marshalling image: %w", err)116 }117 118 def, err := llbState.Marshal(ctx, llb.LinuxAmd64)119 if err != nil {120 return fmt.Errorf("error marshaling LLB state: %w", err)121 }122 123 if opts.DumpLLB {124 err = llb.WriteTo(def, os.Stdout)125 if err != nil {126 return fmt.Errorf("error writing LLB definition: %w", err)127 }128 return nil129 }130 131 core.PrettyPrintSectionHeader(os.Stdout, "Starting Docker Build...")132 133 ch := make(chan *client.SolveStatus)134 135 var pipeR *io.PipeReader136 var pipeW *io.PipeWriter137 errCh := make(chan error, 1)138 139 // Only set up pipe and docker load if we're not saving to a directory140 if opts.OutputDir == "" {141 // Create a pipe to connect buildkit output to docker load142 pipeR, pipeW = io.Pipe()143 defer func() { _ = pipeR.Close() }()144 145 // Pipe the image into `docker load`146 go func() {147 cmd := exec.Command("docker", "load")148 cmd.Stdin = pipeR149 cmd.Stdout = os.Stdout150 cmd.Stderr = os.Stderr151 errCh <- cmd.Run()152 }()153 }154 155 progressDone := make(chan bool)156 go func() {157 displayCh := make(chan *client.SolveStatus)158 go func() {159 for s := range ch {160 displayCh <- s161 }162 close(displayCh)163 }()164 165 progressMode := progressui.AutoMode166 switch opts.ProgressMode {167 case "plain":168 progressMode = progressui.PlainMode169 case "tty":170 progressMode = progressui.TtyMode171 }172 173 display, err := progressui.NewDisplay(os.Stdout, progressMode)174 if err != nil {175 log.Error("failed to create progress display", "error", err)176 }177 178 _, err = display.UpdateFrom(ctx, displayCh)179 if err != nil {180 log.Error("failed to update progress display", "error", err)181 }182 progressDone <- true183 }()184 185 appFS, err := fsutil.NewFS(appDir)186 if err != nil {187 return fmt.Errorf("error creating FS: %w", err)188 }189 190 log.Debugf("Building image for %s with BuildKit %s", platforms.Format(buildPlatform), info.BuildkitVersion.Version)191 192 secretsMap := make(map[string][]byte)193 for k, v := range opts.Secrets {194 secretsMap[k] = []byte(v)195 }196 secrets := secretsprovider.FromMap(secretsMap)197 198 dockerConfig := config.LoadDefaultConfigFile(os.Stderr)199 sessionAttachables := []session.Attachable{200 secrets,201 // buildkit does not use the local auth arguments by default, which prevents private repo access when running `railpack build`202 authprovider.NewDockerAuthProvider(authprovider.DockerAuthProviderConfig{203 AuthConfigProvider: authprovider.LoadAuthConfig(dockerConfig),204 }),205 }206 207 solveOpts := client.SolveOpt{208 LocalMounts: map[string]fsutil.FS{209 "context": appFS,210 },211 Session: sessionAttachables,212 Exports: []client.ExportEntry{213 {214 Type: client.ExporterDocker,215 Attrs: map[string]string{216 "name": imageName,217 "containerimage.config": string(imageBytes),218 },219 Output: func(_ map[string]string) (io.WriteCloser, error) {220 return pipeW, nil221 },222 },223 },224 }225 226 solveOpts.CacheImports = cacheEntriesFromFlags(opts.ImportCache)227 solveOpts.CacheExports = cacheEntriesFromFlags(opts.ExportCache)228 229 log.Infof("cache imports: %v", solveOpts.CacheImports)230 log.Infof("cache exports: %v", solveOpts.CacheExports)231 232 // Save the resulting filesystem to a directory233 if opts.OutputDir != "" {234 err = os.MkdirAll(opts.OutputDir, 0755)235 if err != nil {236 return fmt.Errorf("error creating output directory: %w", err)237 }238 239 solveOpts.Exports = []client.ExportEntry{240 {241 Type: client.ExporterLocal,242 OutputDir: opts.OutputDir,243 },244 }245 }246 247 startTime := time.Now()248 _, err = c.Solve(ctx, def, solveOpts, ch)249 250 // Wait for progress monitoring to complete251 <-progressDone252 253 if pipeW != nil {254 _ = pipeW.Close()255 }256 257 if err != nil {258 return fmt.Errorf("failed to solve: %w", err)259 }260 261 // Only wait for docker load if we used it262 if opts.OutputDir == "" {263 if err := <-errCh; err != nil {264 return fmt.Errorf("docker load failed: %w", err)265 }266 }267 268 // output nice build output269 buildDuration := time.Since(startTime)270 buildOutput := fmt.Sprintf("Successfully built image in %.2fs", buildDuration.Seconds())271 if opts.OutputDir != "" {272 buildOutput += fmt.Sprintf("\n\nSaved to:\n%s", core.FormatHighlight(opts.OutputDir))273 } else {274 command := fmt.Sprintf("docker run -it %s", imageName)275 buildOutput += fmt.Sprintf("\n\nRun:\n%s", core.FormatHighlight(command))276 }277 core.PrettyPrintBox(buildOutput)278 279 return nil280}281 282// determine the image name from the app dir path283func getImageName(appDir string) string {284 parts := strings.Split(appDir, string(os.PathSeparator))285 name := parts[len(parts)-1]286 287 // TODO how could this happen in practice?288 if name == "" {289 name = "railpack-app" // Fallback if path ends in separator290 }291 292 // Docker requires image names to be lowercase293 return strings.ToLower(name)294}295 296// Converts docker buildx-style cache flag values (e.g. type=registry,ref=...)297// into BuildKit CacheOptionsEntry values. Empty strings are skipped.298//299// Intentionally hand-rolled instead of using github.com/docker/buildx/util/buildflags300// (or pulling buildx solely for ParseCacheEntry). BuildKit has no public parser for301// these strings; buildx does, but the dependency cost outweighs the small amount of302// logic we need for the type=... form we document.303func cacheEntriesFromFlags(entries []string) []client.CacheOptionsEntry {304 var out []client.CacheOptionsEntry305 for _, entry := range entries {306 if entry == "" {307 continue308 }309 cacheType, attrs := extractCacheType(parseKeyValue(entry))310 out = append(out, client.CacheOptionsEntry{311 Type: cacheType,312 Attrs: attrs,313 })314 }315 return out316}317 318// parse comma-separated key=value strings into a map, ignoring entries without an "="319func parseKeyValue(s string) map[string]string {320 attrs := make(map[string]string)321 parts := strings.SplitSeq(s, ",")322 for part := range parts {323 key, value, found := strings.Cut(part, "=")324 if !found {325 continue326 }327 attrs[strings.TrimSpace(key)] = strings.TrimSpace(value)328 }329 return attrs330}331 332func extractCacheType(attrs map[string]string) (string, map[string]string) {333 cacheType := attrs["type"]334 335 cleanedAttrs := maps.Clone(attrs)336 delete(cleanedAttrs, "type")337 338 return cacheType, cleanedAttrs339}340