github.com/buildtool/build-tools@v0.2.29-0.20240322150259-6a1d0a553c23/pkg/build/build.go (about) 1 // MIT License 2 // 3 // Copyright (c) 2018 buildtool 4 // 5 // Permission is hereby granted, free of charge, to any person obtaining a copy 6 // of this software and associated documentation files (the "Software"), to deal 7 // in the Software without restriction, including without limitation the rights 8 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 // copies of the Software, and to permit persons to whom the Software is 10 // furnished to do so, subject to the following conditions: 11 // 12 // The above copyright notice and this permission notice shall be included in all 13 // copies or substantial portions of the Software. 14 // 15 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 // SOFTWARE. 22 23 package build 24 25 import ( 26 "bytes" 27 "context" 28 "crypto/rand" 29 "crypto/sha256" 30 "encoding/hex" 31 "encoding/json" 32 "fmt" 33 "net" 34 "os" 35 "path/filepath" 36 "strings" 37 38 "github.com/apex/log" 39 "github.com/aws/aws-sdk-go-v2/aws" 40 "github.com/docker/docker/api/types" 41 "github.com/docker/docker/pkg/jsonmessage" 42 "github.com/docker/docker/pkg/stringid" 43 controlapi "github.com/moby/buildkit/api/services/control" 44 "github.com/moby/buildkit/client" 45 "github.com/moby/buildkit/session" 46 "github.com/moby/buildkit/session/filesync" 47 "github.com/moby/buildkit/util/progress/progressui" 48 "github.com/tonistiigi/fsutil" 49 "golang.org/x/sync/errgroup" 50 "gopkg.in/yaml.v3" 51 52 "github.com/buildtool/build-tools/pkg/args" 53 "github.com/buildtool/build-tools/pkg/ci" 54 "github.com/buildtool/build-tools/pkg/config" 55 "github.com/buildtool/build-tools/pkg/docker" 56 ) 57 58 type Args struct { 59 args.Globals 60 Dockerfile string `name:"file" short:"f" help:"name of the Dockerfile to use." default:"Dockerfile"` 61 BuildArgs []string `name:"build-arg" type:"list" help:"additional docker build-args to use, see https://docs.docker.com/engine/reference/commandline/build/ for more information."` 62 NoLogin bool `help:"disable login to docker registry" default:"false" ` 63 NoPull bool `help:"disable pulling latest from docker registry" default:"false"` 64 Platform string `help:"specify target platform to build" default:""` 65 } 66 67 func DoBuild(dir string, buildArgs Args) error { 68 dkrClient, err := dockerClient() 69 if err != nil { 70 return err 71 } 72 return build(dkrClient, dir, buildArgs) 73 } 74 75 var dockerClient = docker.DefaultClient 76 77 var setupSession = provideSession 78 79 func provideSession(dir string) Session { 80 s, err := session.NewSession(context.Background(), filepath.Base(dir), getBuildSharedKey(dir)) 81 if err != nil { 82 panic("session.NewSession changed behaviour and returned an error. Create an issue at https://github.com/buildtool/build-tools/issues/new") 83 } 84 if s == nil { 85 panic("session.NewSession changed behaviour and did not return a session. Create an issue at https://github.com/buildtool/build-tools/issues/new") 86 } 87 return s 88 } 89 90 func build(client docker.Client, dir string, buildVars Args) error { 91 cfg, err := config.Load(dir) 92 if err != nil { 93 return err 94 } 95 currentCI := cfg.CurrentCI() 96 if buildVars.Platform != "" { 97 log.Infof("building for platform <green>%s</green>\n", buildVars.Platform) 98 } 99 100 log.Debugf("Using CI <green>%s</green>\n", currentCI.Name()) 101 102 currentRegistry := cfg.CurrentRegistry() 103 log.Debugf("Using registry <green>%s</green>\n", currentRegistry.Name()) 104 var authenticator docker.Authenticator 105 if buildVars.NoLogin { 106 log.Debugf("Login <yellow>disabled</yellow>\n") 107 } else { 108 log.Debugf("Authenticating against registry <green>%s</green>\n", currentRegistry.Name()) 109 if err := currentRegistry.Login(client); err != nil { 110 return err 111 } 112 authenticator = docker.NewAuthenticator(currentRegistry.RegistryUrl(), currentRegistry.GetAuthConfig()) 113 } 114 115 content, err := os.ReadFile(filepath.Join(dir, buildVars.Dockerfile)) 116 if err != nil { 117 log.Error(fmt.Sprintf("<red>%s</red>", err.Error())) 118 return err 119 } 120 stages := docker.FindStages(string(content)) 121 if !ci.IsValid(currentCI) { 122 return fmt.Errorf("commit and/or branch information is <red>missing</red> (perhaps you're not in a Git repository or forgot to set environment variables?)") 123 } 124 125 commit := currentCI.Commit() 126 branch := currentCI.BranchReplaceSlash() 127 log.Debugf("Using build variables commit <green>%s</green> on branch <green>%s</green>\n", commit, branch) 128 var tags []string 129 branchTag := docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), branch) 130 latestTag := docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), "latest") 131 tags = append(tags, []string{ 132 docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), commit), 133 branchTag, 134 }...) 135 if currentCI.Branch() == "master" || currentCI.Branch() == "main" { 136 tags = append(tags, latestTag) 137 } 138 139 caches := []string{branchTag, latestTag} 140 141 buildArgs := map[string]*string{ 142 "BUILDKIT_INLINE_CACHE": aws.String("1"), 143 "CI_COMMIT": &commit, 144 "CI_BRANCH": &branch, 145 } 146 for _, arg := range buildVars.BuildArgs { 147 split := strings.Split(arg, "=") 148 key := split[0] 149 value := strings.Join(split[1:], "=") 150 if len(split) > 1 && len(value) > 0 { 151 buildArgs[key] = &value 152 } else { 153 if env, exists := os.LookupEnv(key); exists { 154 buildArgs[key] = &env 155 } else { 156 log.Debugf("ignoring build-arg %s\n", key) 157 } 158 } 159 } 160 161 for _, stage := range stages { 162 tag := docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), stage) 163 caches = append([]string{tag}, caches...) 164 err := buildStage(client, dir, buildVars, buildArgs, []string{tag}, caches, stage, authenticator) 165 if err != nil { 166 return err 167 } 168 } 169 170 return buildStage(client, dir, buildVars, buildArgs, tags, caches, "", authenticator) 171 } 172 173 func buildStage(client docker.Client, dir string, buildVars Args, buildArgs map[string]*string, tags []string, caches []string, stage string, authenticator docker.Authenticator) error { 174 s := setupSession(dir) 175 if authenticator != nil { 176 s.Allow(authenticator) 177 } 178 fs, err := fsutil.NewFS(dir) 179 if err != nil { 180 return err 181 } 182 s.Allow(filesync.NewFSSyncProvider(filesync.StaticDirSource{ 183 "context": fs, 184 "dockerfile": fs, 185 })) 186 s.Allow(filesync.NewFSSyncTarget(filesync.WithFSSyncDir(0, "exported"))) 187 188 eg, ctx := errgroup.WithContext(context.Background()) 189 dialSession := func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) { 190 return client.DialHijack(ctx, "/session", proto, meta) 191 } 192 eg.Go(func() error { 193 return s.Run(context.TODO(), dialSession) 194 }) 195 eg.Go(func() error { 196 defer func() { // make sure the Status ends cleanly on build errors 197 _ = s.Close() 198 }() 199 var outputs []types.ImageBuildOutput 200 if strings.HasPrefix(stage, "export") { 201 outputs = append(outputs, types.ImageBuildOutput{ 202 Type: "local", 203 Attrs: map[string]string{}, 204 }) 205 } 206 sessionID := s.ID() 207 return doBuild(ctx, client, eg, buildVars.Dockerfile, buildArgs, tags, caches, stage, !buildVars.NoPull, sessionID, outputs, buildVars.Platform) 208 }) 209 return eg.Wait() 210 } 211 212 func doBuild(ctx context.Context, dkrClient docker.Client, eg *errgroup.Group, dockerfile string, args map[string]*string, tags, caches []string, target string, pullParent bool, sessionID string, outputs []types.ImageBuildOutput, platform string) (finalErr error) { 213 buildID := stringid.GenerateRandomID() 214 options := types.ImageBuildOptions{ 215 BuildArgs: args, 216 BuildID: buildID, 217 CacheFrom: caches, 218 Dockerfile: dockerfile, 219 Outputs: outputs, 220 PullParent: pullParent, 221 MemorySwap: -1, 222 RemoteContext: "client-session", 223 Remove: true, 224 SessionID: sessionID, 225 ShmSize: 256 * 1024 * 1024, 226 Tags: tags, 227 Target: target, 228 Version: types.BuilderBuildKit, 229 Platform: platform, 230 } 231 logVerbose(options) 232 var response types.ImageBuildResponse 233 var err error 234 response, err = dkrClient.ImageBuild(context.Background(), nil, options) 235 if err != nil { 236 return err 237 } 238 defer func() { _ = response.Body.Close() }() 239 240 done := make(chan struct{}) 241 defer close(done) 242 eg.Go(func() error { 243 select { 244 case <-ctx.Done(): 245 return dkrClient.BuildCancel(context.TODO(), buildID) 246 case <-done: 247 } 248 return nil 249 }) 250 251 tracer := newTracer() 252 253 displayStatus(os.Stderr, tracer.displayCh, eg) 254 defer close(tracer.displayCh) 255 256 buf := &bytes.Buffer{} 257 imageID := "" 258 writeAux := func(msg jsonmessage.JSONMessage) { 259 if msg.ID == "moby.image.id" { 260 var result types.BuildResult 261 if err := json.Unmarshal(*msg.Aux, &result); err != nil { 262 log.Errorf("failed to parse aux message: %v", err) 263 } 264 imageID = result.ID 265 return 266 } 267 tracer.write(msg) 268 } 269 270 err = jsonmessage.DisplayJSONMessagesStream(response.Body, buf, os.Stdout.Fd(), true, writeAux) 271 if err != nil { 272 if jerr, ok := err.(*jsonmessage.JSONError); ok { 273 // If no error code is set, default to 1 274 if jerr.Code == 0 { 275 jerr.Code = 1 276 } 277 return fmt.Errorf("code: %d, status: %s", jerr.Code, jerr.Message) 278 } 279 } 280 281 imageID = buf.String() 282 log.Info(imageID) 283 284 return nil 285 } 286 287 func displayStatus(out *os.File, displayCh chan *client.SolveStatus, eg *errgroup.Group) { 288 // not using shared context to not disrupt display but let it finish reporting errors 289 display, err := progressui.NewDisplay(out, progressui.AutoMode) 290 if err != nil { 291 eg.Go(func() error { 292 return err 293 }) 294 } 295 eg.Go(func() error { 296 _, err := display.UpdateFrom(context.TODO(), displayCh) 297 return err 298 }) 299 } 300 301 func logVerbose(options types.ImageBuildOptions) { 302 loggableOptions := options 303 loggableOptions.AuthConfigs = nil 304 loggableOptions.BuildID = "" 305 loggableOptions.SessionID = "" 306 marshal, _ := yaml.Marshal(loggableOptions) 307 log.Debugf("performing docker build with options (auths removed):\n%s\n", marshal) 308 } 309 310 func getBuildSharedKey(dir string) string { 311 // build session id hash of build dir with node based randomness 312 s := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", tryNodeIdentifier(), dir))) 313 return hex.EncodeToString(s[:]) 314 } 315 316 func tryNodeIdentifier() string { 317 out := filepath.Join(os.TempDir(), ".buildtools") // return config dir as default on permission error 318 if err := os.MkdirAll(out, 0700); err == nil { 319 sessionFile := filepath.Join(out, ".buildNodeID") 320 if _, err := os.Lstat(sessionFile); err != nil { 321 if os.IsNotExist(err) { // create a new file with stored randomness 322 b := make([]byte, 32) 323 if _, err := rand.Read(b); err != nil { 324 return out 325 } 326 if err := os.WriteFile(sessionFile, []byte(hex.EncodeToString(b)), 0600); err != nil { 327 return out 328 } 329 } 330 } 331 332 dt, err := os.ReadFile(sessionFile) 333 if err == nil { 334 return string(dt) 335 } 336 } 337 return out 338 } 339 340 type tracer struct { 341 displayCh chan *client.SolveStatus 342 } 343 344 func newTracer() *tracer { 345 return &tracer{ 346 displayCh: make(chan *client.SolveStatus), 347 } 348 } 349 350 func (t *tracer) write(msg jsonmessage.JSONMessage) { 351 var resp controlapi.StatusResponse 352 353 if msg.ID != "moby.buildkit.trace" { 354 return 355 } 356 357 var dt []byte 358 // ignoring all messages that are not understood 359 if err := json.Unmarshal(*msg.Aux, &dt); err != nil { 360 return 361 } 362 if err := (&resp).Unmarshal(dt); err != nil { 363 return 364 } 365 366 s := client.SolveStatus{} 367 for _, v := range resp.Vertexes { 368 s.Vertexes = append(s.Vertexes, &client.Vertex{ 369 Digest: v.Digest, 370 Inputs: v.Inputs, 371 Name: v.Name, 372 Started: v.Started, 373 Completed: v.Completed, 374 Error: v.Error, 375 Cached: v.Cached, 376 }) 377 } 378 for _, v := range resp.Statuses { 379 s.Statuses = append(s.Statuses, &client.VertexStatus{ 380 ID: v.ID, 381 Vertex: v.Vertex, 382 Name: v.Name, 383 Total: v.Total, 384 Current: v.Current, 385 Timestamp: v.Timestamp, 386 Started: v.Started, 387 Completed: v.Completed, 388 }) 389 } 390 for _, v := range resp.Logs { 391 s.Logs = append(s.Logs, &client.VertexLog{ 392 Vertex: v.Vertex, 393 Stream: int(v.Stream), 394 Data: v.Msg, 395 Timestamp: v.Timestamp, 396 }) 397 } 398 399 t.displayCh <- &s 400 }