github.com/justincormack/cli@v0.0.0-20201215022714-831ebeae9675/cli/command/image/build_buildkit.go (about) 1 package image 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/csv" 7 "encoding/json" 8 "fmt" 9 "io" 10 "io/ioutil" 11 "net" 12 "os" 13 "path/filepath" 14 "strings" 15 16 "github.com/containerd/console" 17 "github.com/containerd/containerd/platforms" 18 "github.com/docker/cli/cli" 19 "github.com/docker/cli/cli/command" 20 "github.com/docker/cli/cli/command/image/build" 21 "github.com/docker/cli/opts" 22 "github.com/docker/docker/api/types" 23 "github.com/docker/docker/pkg/jsonmessage" 24 "github.com/docker/docker/pkg/stringid" 25 "github.com/docker/docker/pkg/urlutil" 26 controlapi "github.com/moby/buildkit/api/services/control" 27 "github.com/moby/buildkit/client" 28 "github.com/moby/buildkit/session" 29 "github.com/moby/buildkit/session/auth/authprovider" 30 "github.com/moby/buildkit/session/filesync" 31 "github.com/moby/buildkit/session/secrets/secretsprovider" 32 "github.com/moby/buildkit/session/sshforward/sshprovider" 33 "github.com/moby/buildkit/util/appcontext" 34 "github.com/moby/buildkit/util/progress/progressui" 35 "github.com/moby/buildkit/util/progress/progresswriter" 36 "github.com/pkg/errors" 37 fsutiltypes "github.com/tonistiigi/fsutil/types" 38 "github.com/tonistiigi/go-rosetta" 39 "golang.org/x/sync/errgroup" 40 ) 41 42 const uploadRequestRemote = "upload-request" 43 44 var errDockerfileConflict = errors.New("ambiguous Dockerfile source: both stdin and flag correspond to Dockerfiles") 45 46 //nolint: gocyclo 47 func runBuildBuildKit(dockerCli command.Cli, options buildOptions) error { 48 ctx := appcontext.Context() 49 50 s, err := trySession(dockerCli, options.context, false) 51 if err != nil { 52 return err 53 } 54 if s == nil { 55 return errors.Errorf("buildkit not supported by daemon") 56 } 57 58 if options.imageIDFile != "" { 59 // Avoid leaving a stale file if we eventually fail 60 if err := os.Remove(options.imageIDFile); err != nil && !os.IsNotExist(err) { 61 return errors.Wrap(err, "removing image ID file") 62 } 63 } 64 65 var ( 66 remote string 67 body io.Reader 68 dockerfileName = options.dockerfileName 69 dockerfileReader io.ReadCloser 70 dockerfileDir string 71 contextDir string 72 ) 73 74 stdoutUsed := false 75 76 switch { 77 case options.contextFromStdin(): 78 if options.dockerfileFromStdin() { 79 return errStdinConflict 80 } 81 rc, isArchive, err := build.DetectArchiveReader(os.Stdin) 82 if err != nil { 83 return err 84 } 85 if isArchive { 86 body = rc 87 remote = uploadRequestRemote 88 } else { 89 if options.dockerfileName != "" { 90 return errDockerfileConflict 91 } 92 dockerfileReader = rc 93 remote = clientSessionRemote 94 // TODO: make fssync handle empty contextdir 95 contextDir, _ = ioutil.TempDir("", "empty-dir") 96 defer os.RemoveAll(contextDir) 97 } 98 case isLocalDir(options.context): 99 contextDir = options.context 100 if options.dockerfileFromStdin() { 101 dockerfileReader = os.Stdin 102 } else if options.dockerfileName != "" { 103 dockerfileName = filepath.Base(options.dockerfileName) 104 dockerfileDir = filepath.Dir(options.dockerfileName) 105 } else { 106 dockerfileDir = options.context 107 } 108 remote = clientSessionRemote 109 case urlutil.IsGitURL(options.context): 110 remote = options.context 111 case urlutil.IsURL(options.context): 112 remote = options.context 113 default: 114 return errors.Errorf("unable to prepare context: path %q not found", options.context) 115 } 116 117 if dockerfileReader != nil { 118 dockerfileName = build.DefaultDockerfileName 119 dockerfileDir, err = build.WriteTempDockerfile(dockerfileReader) 120 if err != nil { 121 return err 122 } 123 defer os.RemoveAll(dockerfileDir) 124 } 125 126 outputs, err := parseOutputs(options.outputs) 127 if err != nil { 128 return errors.Wrapf(err, "failed to parse outputs") 129 } 130 131 for _, out := range outputs { 132 switch out.Type { 133 case "local": 134 // dest is handled on client side for local exporter 135 outDir, ok := out.Attrs["dest"] 136 if !ok { 137 return errors.Errorf("dest is required for local output") 138 } 139 delete(out.Attrs, "dest") 140 s.Allow(filesync.NewFSSyncTargetDir(outDir)) 141 case "tar": 142 // dest is handled on client side for tar exporter 143 outFile, ok := out.Attrs["dest"] 144 if !ok { 145 return errors.Errorf("dest is required for tar output") 146 } 147 var w io.WriteCloser 148 if outFile == "-" { 149 if _, err := console.ConsoleFromFile(os.Stdout); err == nil { 150 return errors.Errorf("refusing to write output to console") 151 } 152 w = os.Stdout 153 stdoutUsed = true 154 } else { 155 f, err := os.Create(outFile) 156 if err != nil { 157 return errors.Wrapf(err, "failed to open %s", outFile) 158 } 159 w = f 160 } 161 output := func(map[string]string) (io.WriteCloser, error) { return w, nil } 162 s.Allow(filesync.NewFSSyncTarget(output)) 163 } 164 } 165 166 if dockerfileDir != "" { 167 s.Allow(filesync.NewFSSyncProvider([]filesync.SyncedDir{ 168 { 169 Name: "context", 170 Dir: contextDir, 171 Map: resetUIDAndGID, 172 }, 173 { 174 Name: "dockerfile", 175 Dir: dockerfileDir, 176 }, 177 })) 178 } 179 180 dockerAuthProvider := authprovider.NewDockerAuthProvider(os.Stderr) 181 s.Allow(dockerAuthProvider) 182 if len(options.secrets) > 0 { 183 sp, err := parseSecretSpecs(options.secrets) 184 if err != nil { 185 return errors.Wrapf(err, "could not parse secrets: %v", options.secrets) 186 } 187 s.Allow(sp) 188 } 189 if len(options.ssh) > 0 { 190 sshp, err := parseSSHSpecs(options.ssh) 191 if err != nil { 192 return errors.Wrapf(err, "could not parse ssh: %v", options.ssh) 193 } 194 s.Allow(sshp) 195 } 196 197 eg, ctx := errgroup.WithContext(ctx) 198 199 dialSession := func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) { 200 return dockerCli.Client().DialHijack(ctx, "/session", proto, meta) 201 } 202 eg.Go(func() error { 203 return s.Run(context.TODO(), dialSession) 204 }) 205 206 buildID := stringid.GenerateRandomID() 207 if body != nil { 208 eg.Go(func() error { 209 buildOptions := types.ImageBuildOptions{ 210 Version: types.BuilderBuildKit, 211 BuildID: uploadRequestRemote + ":" + buildID, 212 } 213 214 response, err := dockerCli.Client().ImageBuild(context.Background(), body, buildOptions) 215 if err != nil { 216 return err 217 } 218 defer response.Body.Close() 219 return nil 220 }) 221 } 222 223 if v := os.Getenv("BUILDKIT_PROGRESS"); v != "" && options.progress == "auto" { 224 options.progress = v 225 } 226 227 if strings.EqualFold(options.platform, "local") { 228 p := platforms.DefaultSpec() 229 p.Architecture = rosetta.NativeArch() // current binary architecture might be emulated 230 options.platform = platforms.Format(p) 231 } 232 233 eg.Go(func() error { 234 defer func() { // make sure the Status ends cleanly on build errors 235 s.Close() 236 }() 237 238 buildOptions := imageBuildOptions(dockerCli, options) 239 buildOptions.Version = types.BuilderBuildKit 240 buildOptions.Dockerfile = dockerfileName 241 // buildOptions.AuthConfigs = authConfigs // handled by session 242 buildOptions.RemoteContext = remote 243 buildOptions.SessionID = s.ID() 244 buildOptions.BuildID = buildID 245 buildOptions.Outputs = outputs 246 return doBuild(ctx, eg, dockerCli, stdoutUsed, options, buildOptions, dockerAuthProvider) 247 }) 248 249 return eg.Wait() 250 } 251 252 //nolint: gocyclo 253 func doBuild(ctx context.Context, eg *errgroup.Group, dockerCli command.Cli, stdoutUsed bool, options buildOptions, buildOptions types.ImageBuildOptions, at session.Attachable) (finalErr error) { 254 response, err := dockerCli.Client().ImageBuild(context.Background(), nil, buildOptions) 255 if err != nil { 256 return err 257 } 258 defer response.Body.Close() 259 260 done := make(chan struct{}) 261 defer close(done) 262 eg.Go(func() error { 263 select { 264 case <-ctx.Done(): 265 return dockerCli.Client().BuildCancel(context.TODO(), buildOptions.BuildID) 266 case <-done: 267 } 268 return nil 269 }) 270 271 t := newTracer() 272 ssArr := []*client.SolveStatus{} 273 274 if err := opts.ValidateProgressOutput(options.progress); err != nil { 275 return err 276 } 277 278 displayStatus := func(out *os.File, displayCh chan *client.SolveStatus) { 279 var c console.Console 280 // TODO: Handle tty output in non-tty environment. 281 if cons, err := console.ConsoleFromFile(out); err == nil && (options.progress == "auto" || options.progress == "tty") { 282 c = cons 283 } 284 // not using shared context to not disrupt display but let it finish reporting errors 285 eg.Go(func() error { 286 return progressui.DisplaySolveStatus(context.TODO(), "", c, out, displayCh) 287 }) 288 if s, ok := at.(interface { 289 SetLogger(progresswriter.Logger) 290 }); ok { 291 s.SetLogger(func(s *client.SolveStatus) { 292 displayCh <- s 293 }) 294 } 295 } 296 297 if options.quiet { 298 eg.Go(func() error { 299 // TODO: make sure t.displayCh closes 300 for ss := range t.displayCh { 301 ssArr = append(ssArr, ss) 302 } 303 <-done 304 // TODO: verify that finalErr is indeed set when error occurs 305 if finalErr != nil { 306 displayCh := make(chan *client.SolveStatus) 307 go func() { 308 for _, ss := range ssArr { 309 displayCh <- ss 310 } 311 close(displayCh) 312 }() 313 displayStatus(os.Stderr, displayCh) 314 } 315 return nil 316 }) 317 } else { 318 displayStatus(os.Stderr, t.displayCh) 319 } 320 defer close(t.displayCh) 321 322 buf := bytes.NewBuffer(nil) 323 324 imageID := "" 325 writeAux := func(msg jsonmessage.JSONMessage) { 326 if msg.ID == "moby.image.id" { 327 var result types.BuildResult 328 if err := json.Unmarshal(*msg.Aux, &result); err != nil { 329 fmt.Fprintf(dockerCli.Err(), "failed to parse aux message: %v", err) 330 } 331 imageID = result.ID 332 return 333 } 334 t.write(msg) 335 } 336 337 err = jsonmessage.DisplayJSONMessagesStream(response.Body, buf, dockerCli.Out().FD(), dockerCli.Out().IsTerminal(), writeAux) 338 if err != nil { 339 if jerr, ok := err.(*jsonmessage.JSONError); ok { 340 // If no error code is set, default to 1 341 if jerr.Code == 0 { 342 jerr.Code = 1 343 } 344 return cli.StatusError{Status: jerr.Message, StatusCode: jerr.Code} 345 } 346 } 347 348 // Everything worked so if -q was provided the output from the daemon 349 // should be just the image ID and we'll print that to stdout. 350 // 351 // TODO: we may want to use Aux messages with ID "moby.image.id" regardless of options.quiet (i.e. don't send HTTP param q=1) 352 // instead of assuming that output is image ID if options.quiet. 353 if options.quiet && !stdoutUsed { 354 imageID = buf.String() 355 fmt.Fprint(dockerCli.Out(), imageID) 356 } 357 358 if options.imageIDFile != "" { 359 if imageID == "" { 360 return errors.Errorf("cannot write %s because server did not provide an image ID", options.imageIDFile) 361 } 362 imageID = strings.TrimSpace(imageID) 363 if err := ioutil.WriteFile(options.imageIDFile, []byte(imageID), 0666); err != nil { 364 return errors.Wrap(err, "cannot write image ID file") 365 } 366 } 367 return err 368 } 369 370 func resetUIDAndGID(_ string, s *fsutiltypes.Stat) bool { 371 s.Uid = 0 372 s.Gid = 0 373 return true 374 } 375 376 type tracer struct { 377 displayCh chan *client.SolveStatus 378 } 379 380 func newTracer() *tracer { 381 return &tracer{ 382 displayCh: make(chan *client.SolveStatus), 383 } 384 } 385 386 func (t *tracer) write(msg jsonmessage.JSONMessage) { 387 var resp controlapi.StatusResponse 388 389 if msg.ID != "moby.buildkit.trace" { 390 return 391 } 392 393 var dt []byte 394 // ignoring all messages that are not understood 395 if err := json.Unmarshal(*msg.Aux, &dt); err != nil { 396 return 397 } 398 if err := (&resp).Unmarshal(dt); err != nil { 399 return 400 } 401 402 s := client.SolveStatus{} 403 for _, v := range resp.Vertexes { 404 s.Vertexes = append(s.Vertexes, &client.Vertex{ 405 Digest: v.Digest, 406 Inputs: v.Inputs, 407 Name: v.Name, 408 Started: v.Started, 409 Completed: v.Completed, 410 Error: v.Error, 411 Cached: v.Cached, 412 }) 413 } 414 for _, v := range resp.Statuses { 415 s.Statuses = append(s.Statuses, &client.VertexStatus{ 416 ID: v.ID, 417 Vertex: v.Vertex, 418 Name: v.Name, 419 Total: v.Total, 420 Current: v.Current, 421 Timestamp: v.Timestamp, 422 Started: v.Started, 423 Completed: v.Completed, 424 }) 425 } 426 for _, v := range resp.Logs { 427 s.Logs = append(s.Logs, &client.VertexLog{ 428 Vertex: v.Vertex, 429 Stream: int(v.Stream), 430 Data: v.Msg, 431 Timestamp: v.Timestamp, 432 }) 433 } 434 435 t.displayCh <- &s 436 } 437 438 func parseSecretSpecs(sl []string) (session.Attachable, error) { 439 fs := make([]secretsprovider.Source, 0, len(sl)) 440 for _, v := range sl { 441 s, err := parseSecret(v) 442 if err != nil { 443 return nil, err 444 } 445 fs = append(fs, *s) 446 } 447 store, err := secretsprovider.NewStore(fs) 448 if err != nil { 449 return nil, err 450 } 451 return secretsprovider.NewSecretProvider(store), nil 452 } 453 454 func parseSecret(value string) (*secretsprovider.Source, error) { 455 csvReader := csv.NewReader(strings.NewReader(value)) 456 fields, err := csvReader.Read() 457 if err != nil { 458 return nil, errors.Wrap(err, "failed to parse csv secret") 459 } 460 461 fs := secretsprovider.Source{} 462 463 var typ string 464 for _, field := range fields { 465 parts := strings.SplitN(field, "=", 2) 466 key := strings.ToLower(parts[0]) 467 468 if len(parts) != 2 { 469 return nil, errors.Errorf("invalid field '%s' must be a key=value pair", field) 470 } 471 472 value := parts[1] 473 switch key { 474 case "type": 475 if value != "file" && value != "env" { 476 return nil, errors.Errorf("unsupported secret type %q", value) 477 } 478 typ = value 479 case "id": 480 fs.ID = value 481 case "source", "src": 482 fs.FilePath = value 483 case "env": 484 fs.Env = value 485 default: 486 return nil, errors.Errorf("unexpected key '%s' in '%s'", key, field) 487 } 488 } 489 if typ == "env" && fs.Env == "" { 490 fs.Env = fs.FilePath 491 fs.FilePath = "" 492 } 493 return &fs, nil 494 } 495 496 func parseSSHSpecs(sl []string) (session.Attachable, error) { 497 configs := make([]sshprovider.AgentConfig, 0, len(sl)) 498 for _, v := range sl { 499 c := parseSSH(v) 500 configs = append(configs, *c) 501 } 502 return sshprovider.NewSSHAgentProvider(configs) 503 } 504 505 func parseSSH(value string) *sshprovider.AgentConfig { 506 parts := strings.SplitN(value, "=", 2) 507 cfg := sshprovider.AgentConfig{ 508 ID: parts[0], 509 } 510 if len(parts) > 1 { 511 cfg.Paths = strings.Split(parts[1], ",") 512 } 513 return &cfg 514 }