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