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  }