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  }