github.com/docker/buildx@v0.14.1-0.20240514123050-afcb609966dc/bake/compose.go (about) 1 package bake 2 3 import ( 4 "context" 5 "fmt" 6 "os" 7 "path/filepath" 8 "sort" 9 "strings" 10 11 "github.com/compose-spec/compose-go/v2/dotenv" 12 "github.com/compose-spec/compose-go/v2/loader" 13 composetypes "github.com/compose-spec/compose-go/v2/types" 14 dockeropts "github.com/docker/cli/opts" 15 "github.com/docker/go-units" 16 "github.com/pkg/errors" 17 "gopkg.in/yaml.v3" 18 ) 19 20 func ParseComposeFiles(fs []File) (*Config, error) { 21 envs, err := composeEnv() 22 if err != nil { 23 return nil, err 24 } 25 var cfgs []composetypes.ConfigFile 26 for _, f := range fs { 27 cfgs = append(cfgs, composetypes.ConfigFile{ 28 Filename: f.Name, 29 Content: f.Data, 30 }) 31 } 32 return ParseCompose(cfgs, envs) 33 } 34 35 func ParseCompose(cfgs []composetypes.ConfigFile, envs map[string]string) (*Config, error) { 36 if envs == nil { 37 envs = make(map[string]string) 38 } 39 cfg, err := loader.LoadWithContext(context.Background(), composetypes.ConfigDetails{ 40 ConfigFiles: cfgs, 41 Environment: envs, 42 }, func(options *loader.Options) { 43 options.SetProjectName("bake", false) 44 options.SkipNormalization = true 45 options.Profiles = []string{"*"} 46 }) 47 if err != nil { 48 return nil, err 49 } 50 51 var c Config 52 if len(cfg.Services) > 0 { 53 c.Groups = []*Group{} 54 c.Targets = []*Target{} 55 56 g := &Group{Name: "default"} 57 58 for _, s := range cfg.Services { 59 s := s 60 if s.Build == nil { 61 continue 62 } 63 64 targetName := sanitizeTargetName(s.Name) 65 if err = validateTargetName(targetName); err != nil { 66 return nil, errors.Wrapf(err, "invalid service name %q", targetName) 67 } 68 69 var contextPathP *string 70 if s.Build.Context != "" { 71 contextPath := s.Build.Context 72 contextPathP = &contextPath 73 } 74 var dockerfilePathP *string 75 if s.Build.Dockerfile != "" { 76 dockerfilePath := s.Build.Dockerfile 77 dockerfilePathP = &dockerfilePath 78 } 79 var dockerfileInlineP *string 80 if s.Build.DockerfileInline != "" { 81 dockerfileInline := s.Build.DockerfileInline 82 dockerfileInlineP = &dockerfileInline 83 } 84 85 var additionalContexts map[string]string 86 if s.Build.AdditionalContexts != nil { 87 additionalContexts = map[string]string{} 88 for k, v := range s.Build.AdditionalContexts { 89 additionalContexts[k] = v 90 } 91 } 92 93 var shmSize *string 94 if s.Build.ShmSize > 0 { 95 shmSizeBytes := dockeropts.MemBytes(s.Build.ShmSize) 96 shmSizeStr := shmSizeBytes.String() 97 shmSize = &shmSizeStr 98 } 99 100 var ulimits []string 101 if s.Build.Ulimits != nil { 102 for n, u := range s.Build.Ulimits { 103 ulimit, err := units.ParseUlimit(fmt.Sprintf("%s=%d:%d", n, u.Soft, u.Hard)) 104 if err != nil { 105 return nil, err 106 } 107 ulimits = append(ulimits, ulimit.String()) 108 } 109 } 110 111 var ssh []string 112 for _, bkey := range s.Build.SSH { 113 sshkey := composeToBuildkitSSH(bkey) 114 ssh = append(ssh, sshkey) 115 } 116 sort.Strings(ssh) 117 118 var secrets []string 119 for _, bs := range s.Build.Secrets { 120 secret, err := composeToBuildkitSecret(bs, cfg.Secrets[bs.Source]) 121 if err != nil { 122 return nil, err 123 } 124 secrets = append(secrets, secret) 125 } 126 127 // compose does not support nil values for labels 128 labels := map[string]*string{} 129 for k, v := range s.Build.Labels { 130 v := v 131 labels[k] = &v 132 } 133 134 g.Targets = append(g.Targets, targetName) 135 t := &Target{ 136 Name: targetName, 137 Context: contextPathP, 138 Contexts: additionalContexts, 139 Dockerfile: dockerfilePathP, 140 DockerfileInline: dockerfileInlineP, 141 Tags: s.Build.Tags, 142 Labels: labels, 143 Args: flatten(s.Build.Args.Resolve(func(val string) (string, bool) { 144 if val, ok := s.Environment[val]; ok && val != nil { 145 return *val, true 146 } 147 val, ok := cfg.Environment[val] 148 return val, ok 149 })), 150 CacheFrom: s.Build.CacheFrom, 151 CacheTo: s.Build.CacheTo, 152 NetworkMode: &s.Build.Network, 153 SSH: ssh, 154 Secrets: secrets, 155 ShmSize: shmSize, 156 Ulimits: ulimits, 157 } 158 if err = t.composeExtTarget(s.Build.Extensions); err != nil { 159 return nil, err 160 } 161 if s.Build.Target != "" { 162 target := s.Build.Target 163 t.Target = &target 164 } 165 if len(t.Tags) == 0 && s.Image != "" { 166 t.Tags = []string{s.Image} 167 } 168 c.Targets = append(c.Targets, t) 169 } 170 c.Groups = append(c.Groups, g) 171 172 } 173 174 return &c, nil 175 } 176 177 func validateComposeFile(dt []byte, fn string) (bool, error) { 178 envs, err := composeEnv() 179 if err != nil { 180 return true, err 181 } 182 fnl := strings.ToLower(fn) 183 if strings.HasSuffix(fnl, ".yml") || strings.HasSuffix(fnl, ".yaml") { 184 return true, validateCompose(dt, envs) 185 } 186 if strings.HasSuffix(fnl, ".json") || strings.HasSuffix(fnl, ".hcl") { 187 return false, nil 188 } 189 err = validateCompose(dt, envs) 190 return err == nil, err 191 } 192 193 func validateCompose(dt []byte, envs map[string]string) error { 194 _, err := loader.Load(composetypes.ConfigDetails{ 195 ConfigFiles: []composetypes.ConfigFile{ 196 { 197 Content: dt, 198 }, 199 }, 200 Environment: envs, 201 }, func(options *loader.Options) { 202 options.SetProjectName("bake", false) 203 options.SkipNormalization = true 204 // consistency is checked later in ParseCompose to ensure multiple 205 // compose files can be merged together 206 options.SkipConsistencyCheck = true 207 }) 208 return err 209 } 210 211 func composeEnv() (map[string]string, error) { 212 envs := sliceToMap(os.Environ()) 213 if wd, err := os.Getwd(); err == nil { 214 envs, err = loadDotEnv(envs, wd) 215 if err != nil { 216 return nil, err 217 } 218 } 219 return envs, nil 220 } 221 222 func loadDotEnv(curenv map[string]string, workingDir string) (map[string]string, error) { 223 if curenv == nil { 224 curenv = make(map[string]string) 225 } 226 227 ef, err := filepath.Abs(filepath.Join(workingDir, ".env")) 228 if err != nil { 229 return nil, err 230 } 231 232 if _, err = os.Stat(ef); os.IsNotExist(err) { 233 return curenv, nil 234 } else if err != nil { 235 return nil, err 236 } 237 238 dt, err := os.ReadFile(ef) 239 if err != nil { 240 return nil, err 241 } 242 243 envs, err := dotenv.UnmarshalBytesWithLookup(dt, nil) 244 if err != nil { 245 return nil, err 246 } 247 248 for k, v := range envs { 249 if _, set := curenv[k]; set { 250 continue 251 } 252 curenv[k] = v 253 } 254 255 return curenv, nil 256 } 257 258 func flatten(in composetypes.MappingWithEquals) map[string]*string { 259 if len(in) == 0 { 260 return nil 261 } 262 out := map[string]*string{} 263 for k, v := range in { 264 if v == nil { 265 continue 266 } 267 out[k] = v 268 } 269 return out 270 } 271 272 // xbake Compose build extension provides fields not (yet) available in 273 // Compose build specification: https://github.com/compose-spec/compose-spec/blob/master/build.md 274 type xbake struct { 275 Tags stringArray `yaml:"tags,omitempty"` 276 CacheFrom stringArray `yaml:"cache-from,omitempty"` 277 CacheTo stringArray `yaml:"cache-to,omitempty"` 278 Secrets stringArray `yaml:"secret,omitempty"` 279 SSH stringArray `yaml:"ssh,omitempty"` 280 Platforms stringArray `yaml:"platforms,omitempty"` 281 Outputs stringArray `yaml:"output,omitempty"` 282 Pull *bool `yaml:"pull,omitempty"` 283 NoCache *bool `yaml:"no-cache,omitempty"` 284 NoCacheFilter stringArray `yaml:"no-cache-filter,omitempty"` 285 Contexts stringMap `yaml:"contexts,omitempty"` 286 // don't forget to update documentation if you add a new field: 287 // https://github.com/docker/docs/blob/main/content/build/bake/compose-file.md#extension-field-with-x-bake 288 } 289 290 type stringMap map[string]string 291 type stringArray []string 292 293 func (sa *stringArray) UnmarshalYAML(unmarshal func(interface{}) error) error { 294 var multi []string 295 err := unmarshal(&multi) 296 if err != nil { 297 var single string 298 if err := unmarshal(&single); err != nil { 299 return err 300 } 301 *sa = strings.Fields(single) 302 } else { 303 *sa = multi 304 } 305 return nil 306 } 307 308 // composeExtTarget converts Compose build extension x-bake to bake Target 309 // https://github.com/compose-spec/compose-spec/blob/master/spec.md#extension 310 func (t *Target) composeExtTarget(exts map[string]interface{}) error { 311 var xb xbake 312 313 ext, ok := exts["x-bake"] 314 if !ok || ext == nil { 315 return nil 316 } 317 318 yb, _ := yaml.Marshal(ext) 319 if err := yaml.Unmarshal(yb, &xb); err != nil { 320 return err 321 } 322 323 if len(xb.Tags) > 0 { 324 t.Tags = dedupSlice(append(t.Tags, xb.Tags...)) 325 } 326 if len(xb.CacheFrom) > 0 { 327 t.CacheFrom = dedupSlice(append(t.CacheFrom, xb.CacheFrom...)) 328 } 329 if len(xb.CacheTo) > 0 { 330 t.CacheTo = dedupSlice(append(t.CacheTo, xb.CacheTo...)) 331 } 332 if len(xb.Secrets) > 0 { 333 t.Secrets = dedupSlice(append(t.Secrets, xb.Secrets...)) 334 } 335 if len(xb.SSH) > 0 { 336 t.SSH = dedupSlice(append(t.SSH, xb.SSH...)) 337 sort.Strings(t.SSH) 338 } 339 if len(xb.Platforms) > 0 { 340 t.Platforms = dedupSlice(append(t.Platforms, xb.Platforms...)) 341 } 342 if len(xb.Outputs) > 0 { 343 t.Outputs = dedupSlice(append(t.Outputs, xb.Outputs...)) 344 } 345 if xb.Pull != nil { 346 t.Pull = xb.Pull 347 } 348 if xb.NoCache != nil { 349 t.NoCache = xb.NoCache 350 } 351 if len(xb.NoCacheFilter) > 0 { 352 t.NoCacheFilter = dedupSlice(append(t.NoCacheFilter, xb.NoCacheFilter...)) 353 } 354 if len(xb.Contexts) > 0 { 355 t.Contexts = dedupMap(t.Contexts, xb.Contexts) 356 } 357 358 return nil 359 } 360 361 // composeToBuildkitSecret converts secret from compose format to buildkit's 362 // csv format. 363 func composeToBuildkitSecret(inp composetypes.ServiceSecretConfig, psecret composetypes.SecretConfig) (string, error) { 364 if psecret.External { 365 return "", errors.Errorf("unsupported external secret %s", psecret.Name) 366 } 367 368 var bkattrs []string 369 if inp.Source != "" { 370 bkattrs = append(bkattrs, "id="+inp.Source) 371 } 372 if psecret.File != "" { 373 bkattrs = append(bkattrs, "src="+psecret.File) 374 } 375 if psecret.Environment != "" { 376 bkattrs = append(bkattrs, "env="+psecret.Environment) 377 } 378 379 return strings.Join(bkattrs, ","), nil 380 } 381 382 // composeToBuildkitSSH converts secret from compose format to buildkit's 383 // csv format. 384 func composeToBuildkitSSH(sshKey composetypes.SSHKey) string { 385 var bkattrs []string 386 387 bkattrs = append(bkattrs, sshKey.ID) 388 389 if sshKey.Path != "" { 390 bkattrs = append(bkattrs, sshKey.Path) 391 } 392 393 return strings.Join(bkattrs, "=") 394 }