Merge pull request #2283 from crazy-max/update-compose

vendor: update compose-go to v2.0.0-rc.8
This commit is contained in:
CrazyMax
2024-02-27 08:55:04 +01:00
committed by GitHub
36 changed files with 1185 additions and 496 deletions

View File

@ -37,8 +37,6 @@ import (
// ProjectOptions provides common configuration for loading a project.
type ProjectOptions struct {
ctx context.Context
// Name is a valid Compose project name to be used or empty.
//
// If empty, the project loader will automatically infer a reasonable
@ -80,6 +78,10 @@ type ProjectOptions struct {
EnvFiles []string
loadOptions []func(*loader.Options)
// Callbacks to retrieve metadata information during parse defined before
// creating the project
Listeners []loader.Listener
}
type ProjectOptionsFn func(*ProjectOptions) error
@ -89,6 +91,7 @@ func NewProjectOptions(configs []string, opts ...ProjectOptionsFn) (*ProjectOpti
options := &ProjectOptions{
ConfigPaths: configs,
Environment: map[string]string{},
Listeners: []loader.Listener{},
}
for _, o := range opts {
err := o(options)
@ -334,14 +337,6 @@ func WithResolvedPaths(resolve bool) ProjectOptionsFn {
}
}
// WithContext sets the context used to load model and resources
func WithContext(ctx context.Context) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.ctx = ctx
return nil
}
}
// WithResourceLoader register support for ResourceLoader to manage remote resources
func WithResourceLoader(r loader.ResourceLoader) ProjectOptionsFn {
return func(o *ProjectOptions) error {
@ -352,6 +347,24 @@ func WithResourceLoader(r loader.ResourceLoader) ProjectOptionsFn {
}
}
// WithExtension register a know extension `x-*` with the go struct type to decode into
func WithExtension(name string, typ any) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
if options.KnownExtensions == nil {
options.KnownExtensions = map[string]any{}
}
options.KnownExtensions[name] = typ
})
return nil
}
}
// Append listener to event
func (o *ProjectOptions) WithListeners(listeners ...loader.Listener) {
o.Listeners = append(o.Listeners, listeners...)
}
// WithoutEnvironmentResolution disable environment resolution
func WithoutEnvironmentResolution(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
@ -368,7 +381,7 @@ var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.overrid
func (o ProjectOptions) GetWorkingDir() (string, error) {
if o.WorkingDir != "" {
return o.WorkingDir, nil
return filepath.Abs(o.WorkingDir)
}
for _, path := range o.ConfigPaths {
if path != "-" {
@ -382,9 +395,8 @@ func (o ProjectOptions) GetWorkingDir() (string, error) {
return os.Getwd()
}
// ProjectFromOptions load a compose project based on command line options
func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
configPaths, err := getConfigPathsFromOptions(options)
func (o ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
configPaths, err := o.getConfigPaths()
if err != nil {
return nil, err
}
@ -412,24 +424,25 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
Content: b,
})
}
return configs, err
}
// ProjectFromOptions load a compose project based on command line options
func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Project, error) {
configs, err := options.GeConfigFiles()
if err != nil {
return nil, err
}
workingDir, err := options.GetWorkingDir()
if err != nil {
return nil, err
}
absWorkingDir, err := filepath.Abs(workingDir)
if err != nil {
return nil, err
}
options.loadOptions = append(options.loadOptions,
withNamePrecedenceLoad(absWorkingDir, options),
withConvertWindowsPaths(options))
ctx := options.ctx
if ctx == nil {
ctx = context.Background()
}
withNamePrecedenceLoad(workingDir, options),
withConvertWindowsPaths(options),
withListeners(options))
project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
ConfigFiles: configs,
@ -440,7 +453,10 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
return nil, err
}
project.ComposeFiles = configPaths
for _, config := range configs {
project.ComposeFiles = append(project.ComposeFiles, config.Filename)
}
return project, nil
}
@ -467,10 +483,17 @@ func withConvertWindowsPaths(options *ProjectOptions) func(*loader.Options) {
}
}
// getConfigPathsFromOptions retrieves the config files for project based on project options
func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
if len(options.ConfigPaths) != 0 {
return absolutePaths(options.ConfigPaths)
// save listeners from ProjectOptions (compose) to loader.Options
func withListeners(options *ProjectOptions) func(*loader.Options) {
return func(opts *loader.Options) {
opts.Listeners = append(opts.Listeners, options.Listeners...)
}
}
// getConfigPaths retrieves the config files for project based on project options
func (o *ProjectOptions) getConfigPaths() ([]string, error) {
if len(o.ConfigPaths) != 0 {
return absolutePaths(o.ConfigPaths)
}
return nil, fmt.Errorf("no configuration file provided: %w", errdefs.ErrNotFound)
}

View File

@ -0,0 +1,59 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"fmt"
"github.com/compose-spec/compose-go/v2/types"
)
// Will update the environment variables for the format {- VAR} (without interpolation)
// This function should resolve context environment vars for include (passed in env_file)
func resolveServicesEnvironment(dict map[string]any, config types.ConfigDetails) {
services, ok := dict["services"].(map[string]any)
if !ok {
return
}
for service, cfg := range services {
serviceConfig, ok := cfg.(map[string]any)
if !ok {
continue
}
serviceEnv, ok := serviceConfig["environment"].([]any)
if !ok {
continue
}
envs := []any{}
for _, env := range serviceEnv {
varEnv, ok := env.(string)
if !ok {
continue
}
if found, ok := config.Environment[varEnv]; ok {
envs = append(envs, fmt.Sprintf("%s=%s", varEnv, found))
} else {
// either does not exist or it was already resolved in interpolation
envs = append(envs, varEnv)
}
}
serviceConfig["environment"] = envs
services[service] = serviceConfig
}
dict["services"] = services
}

View File

@ -20,6 +20,7 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/override"
@ -60,11 +61,8 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
return s, nil
}
filename := ctx.Value(consts.ComposeFileKey{}).(string)
tracker, err := tracker.Add(filename, name)
if err != nil {
return nil, err
}
var (
err error
ref string
file any
)
@ -72,14 +70,16 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
case map[string]any:
ref = v["service"].(string)
file = v["file"]
opts.ProcessEvent("extends", v)
case string:
ref = v
opts.ProcessEvent("extends", map[string]any{"service": ref})
}
var base any
if file != nil {
path := file.(string)
services, err = getExtendsBaseFromFile(ctx, ref, path, opts, tracker)
filename = file.(string)
services, err = getExtendsBaseFromFile(ctx, ref, filename, opts, tracker)
if err != nil {
return nil, err
}
@ -89,6 +89,12 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
return nil, fmt.Errorf("cannot extend service %q in %s: service not found", name, filename)
}
}
tracker, err = tracker.Add(filename, name)
if err != nil {
return nil, err
}
// recursively apply `extends`
base, err = applyServiceExtends(ctx, ref, services, opts, tracker, post...)
if err != nil {
@ -99,6 +105,12 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
return service, nil
}
source := deepClone(base).(map[string]any)
err = validateExtendSource(source, ref)
if err != nil {
return nil, err
}
for _, processor := range post {
processor.Apply(map[string]any{
"services": map[string]any{
@ -111,9 +123,34 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
return nil, err
}
delete(merged, "extends")
services[name] = merged
return merged, nil
}
// validateExtendSource check the source for `extends` doesn't refer to another container/service
func validateExtendSource(source map[string]any, ref string) error {
forbidden := []string{"links", "volumes_from", "depends_on"}
for _, key := range forbidden {
if _, ok := source[key]; ok {
return fmt.Errorf("service %q can't be used with `extends` as it declare `%s`", ref, key)
}
}
sharedNamespace := []string{"network_mode", "ipc", "pid", "net", "cgroup", "userns_mode", "uts"}
for _, key := range sharedNamespace {
if v, ok := source[key]; ok {
val := v.(string)
if strings.HasPrefix(val, types.ContainerPrefix) {
return fmt.Errorf("service %q can't be used with `extends` as it shares `%s` with another container", ref, key)
}
if strings.HasPrefix(val, types.ServicePrefix) {
return fmt.Errorf("service %q can't be used with `extends` as it shares `%s` with another service", ref, key)
}
}
}
return nil
}
func getExtendsBaseFromFile(ctx context.Context, name string, path string, opts *Options, ct *cycleTracker) (map[string]any, error) {
for _, loader := range opts.ResourceLoaders {
if !loader.Accept(path) {
@ -137,6 +174,7 @@ func getExtendsBaseFromFile(ctx context.Context, name string, path string, opts
extendsOpts.SkipInclude = true
extendsOpts.SkipExtends = true // we manage extends recursively based on raw service definition
extendsOpts.SkipValidation = true // we validate the merge result
extendsOpts.SkipDefaultValues = true
source, err := loadYamlModel(ctx, types.ConfigDetails{
WorkingDir: relworkingdir,
ConfigFiles: []types.ConfigFile{

View File

@ -26,7 +26,8 @@ services:
additional_contexts:
foo: ./bar
secrets:
- secret1
- source: secret1
target: /run/secrets/secret1
- source: secret2
target: my_secret
uid: '103'
@ -257,7 +258,8 @@ services:
restart: always
secrets:
- secret1
- source: secret1
target: /run/secrets/secret1
- source: secret2
target: my_secret
uid: '103'

View File

@ -34,6 +34,14 @@ func loadIncludeConfig(source any) ([]types.IncludeConfig, error) {
if source == nil {
return nil, nil
}
configs := source.([]any)
for i, config := range configs {
if v, ok := config.(string); ok {
configs[i] = map[string]any{
"path": v,
}
}
}
var requires []types.IncludeConfig
err := Transform(source, &requires)
return requires, err
@ -45,6 +53,13 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
return err
}
for _, r := range includeConfig {
for _, listener := range options.Listeners {
listener("include", map[string]any{
"path": r.Path,
"workingdir": configDetails.WorkingDir,
})
}
for i, p := range r.Path {
for _, loader := range options.ResourceLoaders {
if loader.Accept(p) {
@ -56,7 +71,7 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
break
}
}
r.Path[i] = absPath(configDetails.WorkingDir, p)
r.Path[i] = p
}
mainFile := r.Path[0]
@ -70,17 +85,41 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
if r.ProjectDirectory == "" {
r.ProjectDirectory = filepath.Dir(mainFile)
}
relworkingdir, err := filepath.Rel(configDetails.WorkingDir, r.ProjectDirectory)
if err != nil {
// included file path is not inside project working directory => use absolute path
relworkingdir = r.ProjectDirectory
}
loadOptions := options.clone()
loadOptions.ResolvePaths = true
loadOptions.SkipNormalization = true
loadOptions.SkipConsistencyCheck = true
loadOptions.ResourceLoaders = append(loadOptions.RemoteResourceLoaders(), localResourceLoader{
WorkingDir: relworkingdir,
})
if len(r.EnvFile) == 0 {
f := filepath.Join(r.ProjectDirectory, ".env")
if s, err := os.Stat(f); err == nil && !s.IsDir() {
r.EnvFile = types.StringList{f}
}
} else {
envFile := []string{}
for _, f := range r.EnvFile {
if !filepath.IsAbs(f) {
f = filepath.Join(configDetails.WorkingDir, f)
s, err := os.Stat(f)
if err != nil {
return err
}
if s.IsDir() {
return fmt.Errorf("%s is not a file", f)
}
}
envFile = append(envFile, f)
}
r.EnvFile = envFile
}
envFromFile, err := dotenv.GetEnvFromFile(configDetails.Environment, r.EnvFile)
@ -89,7 +128,7 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
}
config := types.ConfigDetails{
WorkingDir: r.ProjectDirectory,
WorkingDir: relworkingdir,
ConfigFiles: types.ToConfigFiles(r.Path),
Environment: configDetails.Environment.Clone().Merge(envFromFile),
}

View File

@ -64,6 +64,8 @@ type Options struct {
SkipInclude bool
// SkipResolveEnvironment will ignore computing `environment` for services
SkipResolveEnvironment bool
// SkipDefaultValues will ignore missing required attributes
SkipDefaultValues bool
// Interpolation options
Interpolate *interp.Options
// Discard 'env_file' entries after resolving to 'environment' section
@ -76,6 +78,19 @@ type Options struct {
Profiles []string
// ResourceLoaders manages support for remote resources
ResourceLoaders []ResourceLoader
// KnownExtensions manages x-* attribute we know and the corresponding go structs
KnownExtensions map[string]any
// Metada for telemetry
Listeners []Listener
}
type Listener = func(event string, metadata map[string]any)
// Invoke all listeners for an event
func (o *Options) ProcessEvent(event string, metadata map[string]any) {
for _, l := range o.Listeners {
l(event, metadata)
}
}
// ResourceLoader is a plugable remote resource resolver
@ -148,6 +163,8 @@ func (o *Options) clone() *Options {
projectNameImperativelySet: o.projectNameImperativelySet,
Profiles: o.Profiles,
ResourceLoaders: o.ResourceLoaders,
KnownExtensions: o.KnownExtensions,
Listeners: o.Listeners,
}
}
@ -288,18 +305,17 @@ func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, opt
}
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{configDetails.WorkingDir})
projectName, err := projectName(configDetails, opts)
err := projectName(configDetails, opts)
if err != nil {
return nil, err
}
opts.projectName = projectName
// TODO(milas): this should probably ALWAYS set (overriding any existing)
if _, ok := configDetails.Environment[consts.ComposeProjectName]; !ok && projectName != "" {
if _, ok := configDetails.Environment[consts.ComposeProjectName]; !ok && opts.projectName != "" {
if configDetails.Environment == nil {
configDetails.Environment = map[string]string{}
}
configDetails.Environment[consts.ComposeProjectName] = projectName
configDetails.Environment[consts.ComposeProjectName] = opts.projectName
}
return load(ctx, configDetails, opts, nil)
@ -312,7 +328,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
)
for _, file := range config.ConfigFiles {
fctx := context.WithValue(ctx, consts.ComposeFileKey{}, file.Filename)
if len(file.Content) == 0 && file.Config == nil {
if file.Content == nil && file.Config == nil {
content, err := os.ReadFile(file.Filename)
if err != nil {
return nil, err
@ -352,6 +368,14 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
}
}
if !opts.SkipInclude {
included = append(included, config.ConfigFiles[0].Filename)
err = ApplyInclude(ctx, config, cfg, opts, included)
if err != nil {
return err
}
}
dict, err = override.Merge(dict, cfg)
if err != nil {
return err
@ -400,9 +424,14 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
return nil, err
}
if !opts.SkipInclude {
included = append(included, config.ConfigFiles[0].Filename)
err = ApplyInclude(ctx, config, dict, opts, included)
// Canonical transformation can reveal duplicates, typically as ports can be a range and conflict with an override
dict, err = override.EnforceUnicity(dict)
if err != nil {
return nil, err
}
if !opts.SkipDefaultValues {
dict, err = transform.SetDefaultValues(dict)
if err != nil {
return nil, err
}
@ -424,6 +453,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
return nil, err
}
}
resolveServicesEnvironment(dict, config)
return dict, nil
}
@ -438,8 +468,6 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
}
loaded = append(loaded, mainFile)
includeRefs := make(map[string][]types.IncludeConfig)
dict, err := loadYamlModel(ctx, configDetails, opts, &cycleTracker{}, nil)
if err != nil {
return nil, err
@ -449,6 +477,10 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
return nil, errors.New("empty compose file")
}
if opts.projectName == "" {
return nil, errors.New("project name must not be empty")
}
project := &types.Project{
Name: opts.projectName,
WorkingDir: configDetails.WorkingDir,
@ -456,14 +488,14 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
}
delete(dict, "name") // project name set by yaml must be identified by caller as opts.projectName
dict = groupXFieldsIntoExtensions(dict, tree.NewPath())
err = Transform(dict, project)
dict, err = processExtensions(dict, tree.NewPath(), opts.KnownExtensions)
if err != nil {
return nil, err
}
if len(includeRefs) != 0 {
project.IncludeReferences = includeRefs
err = Transform(dict, project)
if err != nil {
return nil, err
}
if !opts.SkipNormalization {
@ -516,69 +548,68 @@ func InvalidProjectNameErr(v string) error {
//
// TODO(milas): restructure loading so that we don't need to re-parse the YAML
// here, as it's both wasteful and makes this code error-prone.
func projectName(details types.ConfigDetails, opts *Options) (string, error) {
projectName, projectNameImperativelySet := opts.GetProjectName()
func projectName(details types.ConfigDetails, opts *Options) error {
if opts.projectNameImperativelySet {
if NormalizeProjectName(opts.projectName) != opts.projectName {
return InvalidProjectNameErr(opts.projectName)
}
return nil
}
type named struct {
Name string `yaml:"name"`
}
// if user did NOT provide a name explicitly, then see if one is defined
// in any of the config files
if !projectNameImperativelySet {
var pjNameFromConfigFile string
for _, configFile := range details.ConfigFiles {
content := configFile.Content
if content == nil {
// This can be hit when Filename is set but Content is not. One
// example is when using ToConfigFiles().
d, err := os.ReadFile(configFile.Filename)
if err != nil {
return "", fmt.Errorf("failed to read file %q: %w", configFile.Filename, err)
}
content = d
var pjNameFromConfigFile string
for _, configFile := range details.ConfigFiles {
content := configFile.Content
if content == nil {
// This can be hit when Filename is set but Content is not. One
// example is when using ToConfigFiles().
d, err := os.ReadFile(configFile.Filename)
if err != nil {
return fmt.Errorf("failed to read file %q: %w", configFile.Filename, err)
}
content = d
configFile.Content = d
}
var n named
r := bytes.NewReader(content)
decoder := yaml.NewDecoder(r)
for {
err := decoder.Decode(&n)
if err != nil && errors.Is(err, io.EOF) {
break
}
yml, err := ParseYAML(content)
if err != nil {
// HACK: the way that loading is currently structured, this is
// a duplicative parse just for the `name`. if it fails, we
// give up but don't return the error, knowing that it'll get
// caught downstream for us
return "", nil
break
}
if val, ok := yml["name"]; ok && val != "" {
sVal, ok := val.(string)
if !ok {
// HACK: see above - this is a temporary parsed version
// that hasn't been schema-validated, but we don't want
// to be the ones to actually report that, so give up,
// knowing that it'll get caught downstream for us
return "", nil
}
pjNameFromConfigFile = sVal
if n.Name != "" {
pjNameFromConfigFile = n.Name
}
}
if !opts.SkipInterpolation {
interpolated, err := interp.Interpolate(
map[string]interface{}{"name": pjNameFromConfigFile},
*opts.Interpolate,
)
if err != nil {
return "", err
}
pjNameFromConfigFile = interpolated["name"].(string)
}
pjNameFromConfigFile = NormalizeProjectName(pjNameFromConfigFile)
if pjNameFromConfigFile != "" {
projectName = pjNameFromConfigFile
}
if !opts.SkipInterpolation {
interpolated, err := interp.Interpolate(
map[string]interface{}{"name": pjNameFromConfigFile},
*opts.Interpolate,
)
if err != nil {
return err
}
pjNameFromConfigFile = interpolated["name"].(string)
}
if projectName == "" {
return "", errors.New("project name must not be empty")
pjNameFromConfigFile = NormalizeProjectName(pjNameFromConfigFile)
if pjNameFromConfigFile != "" {
opts.projectName = pjNameFromConfigFile
}
if NormalizeProjectName(projectName) != projectName {
return "", InvalidProjectNameErr(projectName)
}
return projectName, nil
return nil
}
func NormalizeProjectName(s string) string {
@ -596,8 +627,9 @@ var userDefinedKeys = []tree.Path{
"configs",
}
func groupXFieldsIntoExtensions(dict map[string]interface{}, p tree.Path) map[string]interface{} {
extras := map[string]interface{}{}
func processExtensions(dict map[string]any, p tree.Path, extensions map[string]any) (map[string]interface{}, error) {
extras := map[string]any{}
var err error
for key, value := range dict {
skip := false
for _, uk := range userDefinedKeys {
@ -613,19 +645,35 @@ func groupXFieldsIntoExtensions(dict map[string]interface{}, p tree.Path) map[st
}
switch v := value.(type) {
case map[string]interface{}:
dict[key] = groupXFieldsIntoExtensions(v, p.Next(key))
dict[key], err = processExtensions(v, p.Next(key), extensions)
if err != nil {
return nil, err
}
case []interface{}:
for i, e := range v {
if m, ok := e.(map[string]interface{}); ok {
v[i] = groupXFieldsIntoExtensions(m, p.Next(strconv.Itoa(i)))
v[i], err = processExtensions(m, p.Next(strconv.Itoa(i)), extensions)
if err != nil {
return nil, err
}
}
}
}
}
for name, val := range extras {
if typ, ok := extensions[name]; ok {
target := reflect.New(reflect.TypeOf(typ)).Elem().Interface()
err = Transform(val, &target)
if err != nil {
return nil, err
}
extras[name] = target
}
}
if len(extras) > 0 {
dict[consts.Extensions] = extras
}
return dict
return dict, nil
}
// Transform converts the source into the target struct with compose types transformer

View File

@ -37,25 +37,6 @@ func ResolveRelativePaths(project *types.Project) error {
return err
}
project.ComposeFiles = absComposeFiles
// don't coerce a nil map to an empty map
if project.IncludeReferences != nil {
absIncludes := make(map[string][]types.IncludeConfig, len(project.IncludeReferences))
for filename, config := range project.IncludeReferences {
filename = absPath(project.WorkingDir, filename)
absConfigs := make([]types.IncludeConfig, len(config))
for i, c := range config {
absConfigs[i] = types.IncludeConfig{
Path: resolvePaths(project.WorkingDir, c.Path),
ProjectDirectory: absPath(project.WorkingDir, c.ProjectDirectory),
EnvFile: resolvePaths(project.WorkingDir, c.EnvFile),
}
}
absIncludes[filename] = absConfigs
}
project.IncludeReferences = absIncludes
}
return nil
}

View File

@ -29,6 +29,7 @@ import (
// checkConsistency validate a compose model is consistent
func checkConsistency(project *types.Project) error {
containerNames := map[string]string{}
for _, s := range project.Services {
if s.Build == nil && s.Image == "" {
return fmt.Errorf("service %q has neither an image nor a build context specified: %w", s.Name, errdefs.ErrInvalid)
@ -123,6 +124,13 @@ func checkConsistency(project *types.Project) error {
s.Deploy.Replicas = s.Scale
}
if s.ContainerName != "" {
if existing, ok := containerNames[s.ContainerName]; ok {
return fmt.Errorf(`"services.%s": container name "%s" is already in use by "services.%s": %w`, s.Name, s.ContainerName, existing, errdefs.ErrInvalid)
}
containerNames[s.ContainerName] = s.Name
}
if s.GetScale() > 1 && s.ContainerName != "" {
attr := "scale"
if s.Scale == nil {
@ -131,6 +139,15 @@ func checkConsistency(project *types.Project) error {
return fmt.Errorf("services.%s: can't set container_name and %s as container name must be unique: %w", attr,
s.Name, errdefs.ErrInvalid)
}
if s.Develop != nil && s.Develop.Watch != nil {
for _, watch := range s.Develop.Watch {
if watch.Action != types.WatchActionRebuild && watch.Target == "" {
return fmt.Errorf("services.%s.develop.watch: target is required for non-rebuild actions: %w", s.Name, errdefs.ErrInvalid)
}
}
}
}
for name, secret := range project.Secrets {

View File

@ -17,6 +17,7 @@
package override
import (
"cmp"
"fmt"
"strings"
@ -40,10 +41,13 @@ var mergeSpecials = map[tree.Path]merger{}
func init() {
mergeSpecials["networks.*.ipam.config"] = mergeIPAMConfig
mergeSpecials["networks.*.labels"] = mergeToSequence
mergeSpecials["volumes.*.labels"] = mergeToSequence
mergeSpecials["services.*.annotations"] = mergeToSequence
mergeSpecials["services.*.build"] = mergeBuild
mergeSpecials["services.*.build.args"] = mergeToSequence
mergeSpecials["services.*.build.additional_contexts"] = mergeToSequence
mergeSpecials["services.*.build.extra_hosts"] = mergeToSequence
mergeSpecials["services.*.build.labels"] = mergeToSequence
mergeSpecials["services.*.command"] = override
mergeSpecials["services.*.depends_on"] = mergeDependsOn
@ -178,8 +182,8 @@ func convertIntoSequence(value any) []any {
}
i++
}
slices.SortFunc(seq, func(a, b any) bool {
return a.(string) < b.(string)
slices.SortFunc(seq, func(a, b any) int {
return cmp.Compare(a.(string), b.(string))
})
return seq
case []any:

View File

@ -107,13 +107,17 @@ func enforceUnicity(value any, p tree.Path) (any, error) {
return value, nil
}
func keyValueIndexer(y any, _ tree.Path) (string, error) {
value := y.(string)
key, _, found := strings.Cut(value, "=")
if !found {
return value, nil
func keyValueIndexer(y any, p tree.Path) (string, error) {
switch value := y.(type) {
case string:
key, _, found := strings.Cut(value, "=")
if !found {
return value, nil
}
return key, nil
default:
return "", fmt.Errorf("%s: unexpected type %T", p, y)
}
return key, nil
}
func volumeIndexer(y any, p tree.Path) (string, error) {

View File

@ -455,6 +455,7 @@
"type": "array",
"items": {
"type": "object",
"required": ["path", "action"],
"properties": {
"ignore": {"type": "array", "items": {"type": "string"}},
"path": {"type": "string"},
@ -462,7 +463,6 @@
"target": {"type": "string"}
}
},
"required": ["path", "action"],
"additionalProperties": false,
"patternProperties": {"^x-": {}}
}

View File

@ -258,7 +258,7 @@ func getFirstBraceClosingIndex(s string) int {
return i
}
}
if strings.HasPrefix(s[i:], "${") {
if s[i] == '{' {
openVariableBraces++
i++
}

View File

@ -25,9 +25,6 @@ import (
func transformBuild(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
if _, ok := v["context"]; !ok {
v["context"] = "." // TODO(ndeloof) maybe we miss an explicit "set-defaults" loading phase
}
return transformMapping(v, p)
case string:
return map[string]any{
@ -37,3 +34,15 @@ func transformBuild(data any, p tree.Path) (any, error) {
return data, fmt.Errorf("%s: invalid type %T for build", p, v)
}
}
func defaultBuildContext(data any, _ tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
if _, ok := v["context"]; !ok {
v["context"] = "."
}
return v, nil
default:
return data, nil
}
}

View File

@ -0,0 +1,87 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package transform
import (
"github.com/compose-spec/compose-go/v2/tree"
)
var defaultValues = map[tree.Path]transformFunc{}
func init() {
defaultValues["services.*.build"] = defaultBuildContext
defaultValues["services.*.secrets.*"] = defaultSecretMount
}
// SetDefaultValues transforms a compose model to set default values to missing attributes
func SetDefaultValues(yaml map[string]any) (map[string]any, error) {
result, err := setDefaults(yaml, tree.NewPath())
if err != nil {
return nil, err
}
return result.(map[string]any), nil
}
func setDefaults(data any, p tree.Path) (any, error) {
for pattern, transformer := range defaultValues {
if p.Matches(pattern) {
t, err := transformer(data, p)
if err != nil {
return nil, err
}
return t, nil
}
}
switch v := data.(type) {
case map[string]any:
a, err := setDefaultsMapping(v, p)
if err != nil {
return a, err
}
return v, nil
case []any:
a, err := setDefaultsSequence(v, p)
if err != nil {
return a, err
}
return v, nil
default:
return data, nil
}
}
func setDefaultsSequence(v []any, p tree.Path) ([]any, error) {
for i, e := range v {
t, err := setDefaults(e, p.Next("[]"))
if err != nil {
return nil, err
}
v[i] = t
}
return v, nil
}
func setDefaultsMapping(v map[string]any, p tree.Path) (map[string]any, error) {
for k, e := range v {
t, err := setDefaults(e, p.Next(k))
if err != nil {
return nil, err
}
v[k] = t
}
return v, nil
}

View File

@ -48,7 +48,7 @@ func transformPorts(data any, p tree.Path) (any, error) {
case string:
parsed, err := types.ParsePortConfig(value)
if err != nil {
return data, err
return data, nil
}
if err != nil {
return nil, err

View File

@ -34,3 +34,16 @@ func transformFileMount(data any, p tree.Path) (any, error) {
return nil, fmt.Errorf("%s: unsupported type %T", p, data)
}
}
func defaultSecretMount(data any, p tree.Path) (any, error) {
switch v := data.(type) {
case map[string]any:
source := v["source"]
if _, ok := v["target"]; !ok {
v["target"] = fmt.Sprintf("/run/secrets/%s", source)
}
return v, nil
default:
return nil, fmt.Errorf("%s: unsupported type %T", p, data)
}
}

View File

@ -17,7 +17,7 @@
package types
type DevelopConfig struct {
Watch []Trigger `json:"watch,omitempty"`
Watch []Trigger `yaml:"watch,omitempty" json:"watch,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
@ -31,8 +31,8 @@ const (
)
type Trigger struct {
Path string `json:"path,omitempty"`
Action WatchAction `json:"action,omitempty"`
Target string `json:"target,omitempty"`
Ignore []string `json:"ignore,omitempty"`
Path string `yaml:"path" json:"path"`
Action WatchAction `yaml:"action" json:"action"`
Target string `yaml:"target,omitempty" json:"target,omitempty"`
Ignore []string `yaml:"ignore,omitempty" json:"ignore,omitempty"`
}

View File

@ -24,7 +24,33 @@ import (
)
// HostsList is a list of colon-separated host-ip mappings
type HostsList map[string]string
type HostsList map[string][]string
// NewHostsList creates a HostsList from a list of `host=ip` strings
func NewHostsList(hosts []string) (HostsList, error) {
list := HostsList{}
for _, s := range hosts {
var found bool
for _, sep := range hostListSerapators {
host, ip, ok := strings.Cut(s, sep)
if ok {
// Mapping found with this separator, stop here.
if ips, ok := list[host]; ok {
list[host] = append(ips, ip)
} else {
list[host] = []string{ip}
}
found = true
break
}
}
if !found {
return nil, fmt.Errorf("invalid additional host, missing IP: %s", s)
}
}
err := list.cleanup()
return list, err
}
// AsList returns host-ip mappings as a list of strings, using the given
// separator. The Docker Engine API expects ':' separators, the original format
@ -34,7 +60,9 @@ type HostsList map[string]string
func (h HostsList) AsList(sep string) []string {
l := make([]string, 0, len(h))
for k, v := range h {
l = append(l, fmt.Sprintf("%s%s%s", k, sep, v))
for _, ip := range v {
l = append(l, fmt.Sprintf("%s%s%s", k, sep, ip))
}
}
return l
}
@ -51,6 +79,8 @@ func (h HostsList) MarshalJSON() ([]byte, error) {
return json.Marshal(list)
}
var hostListSerapators = []string{"=", ":"}
func (h *HostsList) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
@ -59,25 +89,45 @@ func (h *HostsList) DecodeMapstructure(value interface{}) error {
if e == nil {
e = ""
}
list[i] = fmt.Sprint(e)
list[i] = []string{fmt.Sprint(e)}
}
err := list.cleanup()
if err != nil {
return err
}
*h = list
return nil
case []interface{}:
*h = decodeMapping(v, "=", ":")
s := make([]string, len(v))
for i, e := range v {
s[i] = fmt.Sprint(e)
}
list, err := NewHostsList(s)
if err != nil {
return err
}
*h = list
return nil
default:
return fmt.Errorf("unexpected value type %T for mapping", value)
}
for host, ip := range *h {
}
func (h HostsList) cleanup() error {
for host, ips := range h {
// Check that there is a hostname and that it doesn't contain either
// of the allowed separators, to generate a clearer error than the
// engine would do if it splits the string differently.
if host == "" || strings.ContainsAny(host, ":=") {
return fmt.Errorf("bad host name '%s'", host)
}
// Remove brackets from IP addresses (for example "[::1]" -> "::1").
if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
(*h)[host] = ip[1 : len(ip)-1]
for i, ip := range ips {
// Remove brackets from IP addresses (for example "[::1]" -> "::1").
if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
ips[i] = ip[1 : len(ip)-1]
}
}
h[host] = ips
}
return nil
}

View File

@ -29,6 +29,7 @@ import (
"github.com/distribution/reference"
"github.com/mitchellh/copystructure"
godigest "github.com/opencontainers/go-digest"
"golang.org/x/exp/maps"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)
@ -46,13 +47,8 @@ type Project struct {
Configs Configs `yaml:"configs,omitempty" json:"configs,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"` // https://github.com/golang/go/issues/6213
// IncludeReferences is keyed by Compose YAML filename and contains config for
// other Compose YAML files it directly triggered a load of via `include`.
//
// Note: this is
IncludeReferences map[string][]IncludeConfig `yaml:"-" json:"-"`
ComposeFiles []string `yaml:"-" json:"-"`
Environment Mapping `yaml:"-" json:"-"`
ComposeFiles []string `yaml:"-" json:"-"`
Environment Mapping `yaml:"-" json:"-"`
// DisabledServices track services which have been disable as profile is not active
DisabledServices Services `yaml:"-" json:"-"`
@ -119,6 +115,58 @@ func (p *Project) ConfigNames() []string {
return names
}
func (p *Project) ServicesWithBuild() []string {
servicesBuild := p.Services.Filter(func(s ServiceConfig) bool {
return s.Build != nil && s.Build.Context != ""
})
return maps.Keys(servicesBuild)
}
func (p *Project) ServicesWithExtends() []string {
servicesExtends := p.Services.Filter(func(s ServiceConfig) bool {
return s.Extends != nil && *s.Extends != (ExtendsConfig{})
})
return maps.Keys(servicesExtends)
}
func (p *Project) ServicesWithDependsOn() []string {
servicesDependsOn := p.Services.Filter(func(s ServiceConfig) bool {
return len(s.DependsOn) > 0
})
return maps.Keys(servicesDependsOn)
}
func (p *Project) ServicesWithCapabilities() ([]string, []string, []string) {
capabilities := []string{}
gpu := []string{}
tpu := []string{}
for _, service := range p.Services {
deploy := service.Deploy
if deploy == nil {
continue
}
reservation := deploy.Resources.Reservations
if reservation == nil {
continue
}
devices := reservation.Devices
for _, d := range devices {
if len(d.Capabilities) > 0 {
capabilities = append(capabilities, service.Name)
}
for _, c := range d.Capabilities {
if c == "gpu" {
gpu = append(gpu, service.Name)
} else if c == "tpu" {
tpu = append(tpu, service.Name)
}
}
}
}
return utils.RemoveDuplicates(capabilities), utils.RemoveDuplicates(gpu), utils.RemoveDuplicates(tpu)
}
// GetServices retrieve services by names, or return all services if no name specified
func (p *Project) GetServices(names ...string) (Services, error) {
if len(names) == 0 {

View File

@ -33,3 +33,13 @@ func (s Services) GetProfiles() []string {
}
return profiles
}
func (s Services) Filter(predicate func(ServiceConfig) bool) Services {
services := Services{}
for name, service := range s {
if predicate(service) {
services[name] = service
}
}
return services
}

View File

@ -162,6 +162,9 @@ func (s *ServiceConfig) NetworksByPriority() []string {
})
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].priority == keys[j].priority {
return keys[i].name < keys[j].name
}
return keys[i].priority > keys[j].priority
})
var sorted []string

View File

@ -51,3 +51,18 @@ func ArrayContains[T comparable](source []T, toCheck []T) bool {
}
return true
}
func RemoveDuplicates[T comparable](slice []T) []T {
// Create a map to store unique elements
seen := make(map[T]bool)
result := []T{}
// Loop through the slice, adding elements to the map if they haven't been seen before
for _, val := range slice {
if _, ok := seen[val]; !ok {
seen[val] = true
result = append(result, val)
}
}
return result
}