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