vendor: update compose-go to v2.0.2

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax 2024-04-09 09:20:12 +02:00
parent e7f2da9c4f
commit ca0b583f5a
No known key found for this signature in database
GPG Key ID: ADE44D8C9D44FBE4
16 changed files with 530 additions and 438 deletions

2
go.mod
View File

@ -6,7 +6,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.1
github.com/Microsoft/go-winio v0.6.1
github.com/aws/aws-sdk-go-v2/config v1.26.6
github.com/compose-spec/compose-go/v2 v2.0.0-rc.8
github.com/compose-spec/compose-go/v2 v2.0.2
github.com/containerd/console v1.0.4
github.com/containerd/containerd v1.7.14
github.com/containerd/continuity v0.4.3

4
go.sum
View File

@ -88,8 +88,8 @@ github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+g
github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/compose-spec/compose-go/v2 v2.0.0-rc.8 h1:b7l+GqFF+2W4M4kLQUDRTGhqmTiRwT3bYd9X7xrxp5Q=
github.com/compose-spec/compose-go/v2 v2.0.0-rc.8/go.mod h1:bEPizBkIojlQ20pi2vNluBa58tevvj0Y18oUSHPyfdc=
github.com/compose-spec/compose-go/v2 v2.0.2 h1:zhXMV7VWI00Su0LdKt8/sxeXxcjLWhmGmpEyw+ZYznI=
github.com/compose-spec/compose-go/v2 v2.0.2/go.mod h1:bEPizBkIojlQ20pi2vNluBa58tevvj0Y18oUSHPyfdc=
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=

View File

@ -217,7 +217,10 @@ func WithLoadOptions(loadOptions ...func(*loader.Options)) ProjectOptionsFn {
// profiles specified via the COMPOSE_PROFILES environment variable otherwise.
func WithDefaultProfiles(profile ...string) ProjectOptionsFn {
if len(profile) == 0 {
profile = strings.Split(os.Getenv(consts.ComposeProfiles), ",")
for _, s := range strings.Split(os.Getenv(consts.ComposeProfiles), ",") {
profile = append(profile, strings.TrimSpace(s))
}
}
return WithProfiles(profile)
}
@ -379,7 +382,7 @@ var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.y
// DefaultOverrideFileNames defines the Compose override file names for auto-discovery (in order of preference)
var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"}
func (o ProjectOptions) GetWorkingDir() (string, error) {
func (o *ProjectOptions) GetWorkingDir() (string, error) {
if o.WorkingDir != "" {
return filepath.Abs(o.WorkingDir)
}
@ -395,7 +398,7 @@ func (o ProjectOptions) GetWorkingDir() (string, error) {
return os.Getwd()
}
func (o ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
func (o *ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
configPaths, err := o.getConfigPaths()
if err != nil {
return nil, err
@ -427,39 +430,66 @@ func (o ProjectOptions) GeConfigFiles() ([]types.ConfigFile, error) {
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()
// LoadProject loads compose file according to options and bind to types.Project go structs
func (o *ProjectOptions) LoadProject(ctx context.Context) (*types.Project, error) {
configDetails, err := o.prepare()
if err != nil {
return nil, err
}
workingDir, err := options.GetWorkingDir()
project, err := loader.LoadWithContext(ctx, configDetails, o.loadOptions...)
if err != nil {
return nil, err
}
options.loadOptions = append(options.loadOptions,
withNamePrecedenceLoad(workingDir, options),
withConvertWindowsPaths(options),
withListeners(options))
project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
ConfigFiles: configs,
WorkingDir: workingDir,
Environment: options.Environment,
}, options.loadOptions...)
if err != nil {
return nil, err
}
for _, config := range configs {
for _, config := range configDetails.ConfigFiles {
project.ComposeFiles = append(project.ComposeFiles, config.Filename)
}
return project, nil
}
// LoadModel loads compose file according to options and returns a raw (yaml tree) model
func (o *ProjectOptions) LoadModel(ctx context.Context) (map[string]any, error) {
configDetails, err := o.prepare()
if err != nil {
return nil, err
}
return loader.LoadModelWithContext(ctx, configDetails, o.loadOptions...)
}
// prepare converts ProjectOptions into loader's types.ConfigDetails and configures default load options
func (o *ProjectOptions) prepare() (types.ConfigDetails, error) {
configs, err := o.GeConfigFiles()
if err != nil {
return types.ConfigDetails{}, err
}
workingDir, err := o.GetWorkingDir()
if err != nil {
return types.ConfigDetails{}, err
}
configDetails := types.ConfigDetails{
ConfigFiles: configs,
WorkingDir: workingDir,
Environment: o.Environment,
}
o.loadOptions = append(o.loadOptions,
withNamePrecedenceLoad(workingDir, o),
withConvertWindowsPaths(o),
withListeners(o))
return configDetails, nil
}
// ProjectFromOptions load a compose project based on command line options
// Deprecated: use ProjectOptions.LoadProject or ProjectOptions.LoadModel
func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Project, error) {
return options.LoadProject(ctx)
}
func withNamePrecedenceLoad(absWorkingDir string, options *ProjectOptions) func(*loader.Options) {
return func(opts *loader.Options) {
if options.Name != "" {

View File

@ -30,6 +30,9 @@ var (
// ErrIncompatible is returned when a compose project uses an incompatible attribute
ErrIncompatible = errors.New("incompatible attribute")
// ErrDisabled is returned when a resource was found in model but is disabled
ErrDisabled = errors.New("disabled")
)
// IsNotFoundError returns true if the unwrapped error is ErrNotFound

View File

@ -0,0 +1,63 @@
/*
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 graph
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/utils"
"golang.org/x/exp/slices"
)
// CheckCycle analyze project's depends_on relation and report an error on cycle detection
func CheckCycle(project *types.Project) error {
g, err := newGraph(project)
if err != nil {
return err
}
return g.checkCycle()
}
func (g *graph[T]) checkCycle() error {
// iterate on vertices in a name-order to render a predicable error message
// this is required by tests and enforce command reproducibility by user, which otherwise could be confusing
names := utils.MapKeys(g.vertices)
for _, name := range names {
err := searchCycle([]string{name}, g.vertices[name])
if err != nil {
return err
}
}
return nil
}
func searchCycle[T any](path []string, v *vertex[T]) error {
names := utils.MapKeys(v.children)
for _, name := range names {
if i := slices.Index(path, name); i > 0 {
return fmt.Errorf("dependency cycle detected: %s", strings.Join(path[i:], " -> "))
}
ch := v.children[name]
err := searchCycle(append(path, name), ch)
if err != nil {
return err
}
}
return nil
}

View File

@ -16,14 +16,6 @@
package graph
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/utils"
"golang.org/x/exp/slices"
)
// graph represents project as service dependencies
type graph[T any] struct {
vertices map[string]*vertex[T]
@ -72,34 +64,6 @@ func (g *graph[T]) leaves() []*vertex[T] {
return res
}
func (g *graph[T]) checkCycle() error {
// iterate on vertices in a name-order to render a predicable error message
// this is required by tests and enforce command reproducibility by user, which otherwise could be confusing
names := utils.MapKeys(g.vertices)
for _, name := range names {
err := searchCycle([]string{name}, g.vertices[name])
if err != nil {
return err
}
}
return nil
}
func searchCycle[T any](path []string, v *vertex[T]) error {
names := utils.MapKeys(v.children)
for _, name := range names {
if i := slices.Index(path, name); i > 0 {
return fmt.Errorf("dependency cycle detected: %s", strings.Join(path[i:], " -> "))
}
ch := v.children[name]
err := searchCycle(append(path, name), ch)
if err != nil {
return err
}
}
return nil
}
// descendents return all descendents for a vertex, might contain duplicates
func (v *vertex[T]) descendents() []string {
var vx []string

View File

@ -20,7 +20,6 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/override"
@ -106,11 +105,6 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
}
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{
@ -127,30 +121,6 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
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) {

View File

@ -60,43 +60,41 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
})
}
var relworkingdir string
for i, p := range r.Path {
for _, loader := range options.ResourceLoaders {
if loader.Accept(p) {
path, err := loader.Load(ctx, p)
if err != nil {
return err
if !loader.Accept(p) {
continue
}
path, err := loader.Load(ctx, p)
if err != nil {
return err
}
p = path
if i == 0 { // This is the "main" file, used to define project-directory. Others are overrides
relworkingdir = loader.Dir(path)
if r.ProjectDirectory == "" {
r.ProjectDirectory = filepath.Dir(path)
}
for _, f := range included {
if f == path {
included = append(included, path)
return fmt.Errorf("include cycle detected:\n%s\n include %s", included[0], strings.Join(included[1:], "\n include "))
}
}
p = path
break
}
}
r.Path[i] = p
}
mainFile := r.Path[0]
for _, f := range included {
if f == mainFile {
included = append(included, mainFile)
return fmt.Errorf("include cycle detected:\n%s\n include %s", included[0], strings.Join(included[1:], "\n include "))
}
}
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,
WorkingDir: r.ProjectDirectory,
})
if len(r.EnvFile) == 0 {

View File

@ -41,6 +41,7 @@ import (
"github.com/compose-spec/compose-go/v2/validation"
"github.com/mitchellh/mapstructure"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"gopkg.in/yaml.v3"
)
@ -84,6 +85,15 @@ type Options struct {
Listeners []Listener
}
var versionWarning []string
func (o *Options) warnObsoleteVersion(file string) {
if !slices.Contains(versionWarning, file) {
logrus.Warning(fmt.Sprintf("%s: `version` is obsolete", file))
}
versionWarning = append(versionWarning, file)
}
type Listener = func(event string, metadata map[string]any)
// Invoke all listeners for an event
@ -285,12 +295,45 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
return LoadWithContext(context.Background(), configDetails, options...)
}
// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration
// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration as a compose-go Project
func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
opts := toOptions(&configDetails, options)
dict, err := loadModelWithContext(ctx, &configDetails, opts)
if err != nil {
return nil, err
}
return modelToProject(dict, opts, configDetails)
}
// LoadModelWithContext reads a ConfigDetails and returns a fully loaded configuration as a yaml dictionary
func LoadModelWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (map[string]any, error) {
opts := toOptions(&configDetails, options)
return loadModelWithContext(ctx, &configDetails, opts)
}
// LoadModelWithContext reads a ConfigDetails and returns a fully loaded configuration as a yaml dictionary
func loadModelWithContext(ctx context.Context, configDetails *types.ConfigDetails, opts *Options) (map[string]any, error) {
if len(configDetails.ConfigFiles) < 1 {
return nil, errors.New("No files specified")
}
err := projectName(*configDetails, opts)
if err != nil {
return nil, err
}
// TODO(milas): this should probably ALWAYS set (overriding any existing)
if _, ok := configDetails.Environment[consts.ComposeProjectName]; !ok && opts.projectName != "" {
if configDetails.Environment == nil {
configDetails.Environment = map[string]string{}
}
configDetails.Environment[consts.ComposeProjectName] = opts.projectName
}
return load(ctx, *configDetails, opts, nil)
}
func toOptions(configDetails *types.ConfigDetails, options []func(*Options)) *Options {
opts := &Options{
Interpolate: &interp.Options{
Substitute: template.Substitute,
@ -304,21 +347,7 @@ func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, opt
op(opts)
}
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{configDetails.WorkingDir})
err := projectName(configDetails, opts)
if err != nil {
return nil, err
}
// TODO(milas): this should probably ALWAYS set (overriding any existing)
if _, ok := configDetails.Environment[consts.ComposeProjectName]; !ok && opts.projectName != "" {
if configDetails.Environment == nil {
configDetails.Environment = map[string]string{}
}
configDetails.Environment[consts.ComposeProjectName] = opts.projectName
}
return load(ctx, configDetails, opts, nil)
return opts
}
func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Options, ct *cycleTracker, included []string) (map[string]interface{}, error) {
@ -390,6 +419,10 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
if err := schema.Validate(dict); err != nil {
return fmt.Errorf("validating %s: %w", file.Filename, err)
}
if _, ok := dict["version"]; ok {
opts.warnObsoleteVersion(file.Filename)
delete(dict, "version")
}
}
return err
@ -458,7 +491,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
return dict, nil
}
func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (*types.Project, error) {
func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (map[string]interface{}, error) {
mainFile := configDetails.ConfigFiles[0].Filename
for _, f := range loaded {
if f == mainFile {
@ -481,6 +514,19 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
return nil, errors.New("project name must not be empty")
}
if !opts.SkipNormalization {
dict["name"] = opts.projectName
dict, err = Normalize(dict, configDetails.Environment)
if err != nil {
return nil, err
}
}
return dict, nil
}
// modelToProject binds a canonical yaml dict into compose-go structs
func modelToProject(dict map[string]interface{}, opts *Options, configDetails types.ConfigDetails) (*types.Project, error) {
project := &types.Project{
Name: opts.projectName,
WorkingDir: configDetails.WorkingDir,
@ -488,6 +534,7 @@ 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
var err error
dict, err = processExtensions(dict, tree.NewPath(), opts.KnownExtensions)
if err != nil {
return nil, err
@ -498,13 +545,6 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
return nil, err
}
if !opts.SkipNormalization {
err := Normalize(project)
if err != nil {
return nil, err
}
}
if opts.ConvertWindowsPaths {
for i, service := range project.Services {
for j, volume := range service.Volumes {
@ -514,6 +554,10 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
}
}
if project, err = project.WithProfiles(opts.Profiles); err != nil {
return nil, err
}
if !opts.SkipConsistencyCheck {
err := checkConsistency(project)
if err != nil {
@ -521,17 +565,12 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
}
}
if project, err = project.WithProfiles(opts.Profiles); err != nil {
return nil, err
}
if !opts.SkipResolveEnvironment {
project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles)
if err != nil {
return nil, err
}
}
return project, nil
}

View File

@ -18,266 +18,202 @@ package loader
import (
"fmt"
"strconv"
"strings"
"github.com/compose-spec/compose-go/v2/errdefs"
"github.com/compose-spec/compose-go/v2/types"
"github.com/sirupsen/logrus"
)
// Normalize compose project by moving deprecated attributes to their canonical position and injecting implicit defaults
func Normalize(project *types.Project) error {
if project.Networks == nil {
project.Networks = make(map[string]types.NetworkConfig)
func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
dict["networks"] = normalizeNetworks(dict)
if d, ok := dict["services"]; ok {
services := d.(map[string]any)
for name, s := range services {
service := s.(map[string]any)
_, hasNetworks := service["networks"]
_, hasNetworkMode := service["network_mode"]
if !hasNetworks && !hasNetworkMode {
// Service without explicit network attachment are implicitly exposed on default network
service["networks"] = map[string]any{"default": nil}
}
if service["pull_policy"] == types.PullPolicyIfNotPresent {
service["pull_policy"] = types.PullPolicyMissing
}
fn := func(s string) (string, bool) {
v, ok := env[s]
return v, ok
}
if b, ok := service["build"]; ok {
build := b.(map[string]any)
if build["context"] == nil {
build["context"] = "."
}
if build["dockerfile"] == nil && build["dockerfile_inline"] == nil {
build["dockerfile"] = "Dockerfile"
}
if a, ok := build["args"]; ok {
build["args"], _ = resolve(a, fn)
}
service["build"] = build
}
if e, ok := service["environment"]; ok {
service["environment"], _ = resolve(e, fn)
}
var dependsOn map[string]any
if d, ok := service["depends_on"]; ok {
dependsOn = d.(map[string]any)
} else {
dependsOn = map[string]any{}
}
if l, ok := service["links"]; ok {
links := l.([]any)
for _, e := range links {
link := e.(string)
parts := strings.Split(link, ":")
if len(parts) == 2 {
link = parts[0]
}
if _, ok := dependsOn[link]; !ok {
dependsOn[link] = map[string]any{
"condition": types.ServiceConditionStarted,
"restart": true,
"required": true,
}
}
}
}
for _, namespace := range []string{"network_mode", "ipc", "pid", "uts", "cgroup"} {
if n, ok := service[namespace]; ok {
ref := n.(string)
if strings.HasPrefix(ref, types.ServicePrefix) {
shared := ref[len(types.ServicePrefix):]
if _, ok := dependsOn[shared]; !ok {
dependsOn[shared] = map[string]any{
"condition": types.ServiceConditionStarted,
"restart": true,
"required": true,
}
}
}
}
}
if n, ok := service["volumes_from"]; ok {
volumesFrom := n.([]any)
for _, v := range volumesFrom {
vol := v.(string)
if !strings.HasPrefix(vol, types.ContainerPrefix) {
spec := strings.Split(vol, ":")
if _, ok := dependsOn[spec[0]]; !ok {
dependsOn[spec[0]] = map[string]any{
"condition": types.ServiceConditionStarted,
"restart": false,
"required": true,
}
}
}
}
}
if len(dependsOn) > 0 {
service["depends_on"] = dependsOn
}
services[name] = service
}
dict["services"] = services
}
// If not declared explicitly, Compose model involves an implicit "default" network
if _, ok := project.Networks["default"]; !ok {
project.Networks["default"] = types.NetworkConfig{}
}
setNameFromKey(dict)
for name, s := range project.Services {
if len(s.Networks) == 0 && s.NetworkMode == "" {
// Service without explicit network attachment are implicitly exposed on default network
s.Networks = map[string]*types.ServiceNetworkConfig{"default": nil}
}
if s.PullPolicy == types.PullPolicyIfNotPresent {
s.PullPolicy = types.PullPolicyMissing
}
fn := func(s string) (string, bool) {
v, ok := project.Environment[s]
return v, ok
}
if s.Build != nil {
if s.Build.Context == "" {
s.Build.Context = "."
}
if s.Build.Dockerfile == "" && s.Build.DockerfileInline == "" {
s.Build.Dockerfile = "Dockerfile"
}
s.Build.Args = s.Build.Args.Resolve(fn)
}
s.Environment = s.Environment.Resolve(fn)
for _, link := range s.Links {
parts := strings.Split(link, ":")
if len(parts) == 2 {
link = parts[0]
}
s.DependsOn = setIfMissing(s.DependsOn, link, types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Restart: true,
Required: true,
})
}
for _, namespace := range []string{s.NetworkMode, s.Ipc, s.Pid, s.Uts, s.Cgroup} {
if strings.HasPrefix(namespace, types.ServicePrefix) {
name := namespace[len(types.ServicePrefix):]
s.DependsOn = setIfMissing(s.DependsOn, name, types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Restart: true,
Required: true,
})
}
}
for _, vol := range s.VolumesFrom {
if !strings.HasPrefix(vol, types.ContainerPrefix) {
spec := strings.Split(vol, ":")
s.DependsOn = setIfMissing(s.DependsOn, spec[0], types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Restart: false,
Required: true,
})
}
}
err := relocateLogDriver(&s)
if err != nil {
return err
}
err = relocateLogOpt(&s)
if err != nil {
return err
}
err = relocateDockerfile(&s)
if err != nil {
return err
}
inferImplicitDependencies(&s)
project.Services[name] = s
}
setNameFromKey(project)
return nil
return dict, nil
}
// IsServiceDependency check the relation set by ref refers to a service
func IsServiceDependency(ref string) (string, bool) {
if strings.HasPrefix(
ref,
types.ServicePrefix,
) {
return ref[len(types.ServicePrefix):], true
func normalizeNetworks(dict map[string]any) map[string]any {
var networks map[string]any
if n, ok := dict["networks"]; ok {
networks = n.(map[string]any)
} else {
networks = map[string]any{}
}
return "", false
if _, ok := networks["default"]; !ok {
// If not declared explicitly, Compose model involves an implicit "default" network
networks["default"] = nil
}
return networks
}
func inferImplicitDependencies(service *types.ServiceConfig) {
var dependencies []string
maybeReferences := []string{
service.NetworkMode,
service.Ipc,
service.Pid,
service.Uts,
service.Cgroup,
}
for _, ref := range maybeReferences {
if dep, ok := IsServiceDependency(ref); ok {
dependencies = append(dependencies, dep)
}
}
for _, vol := range service.VolumesFrom {
spec := strings.Split(vol, ":")
if len(spec) == 0 {
continue
}
if spec[0] == "container" {
continue
}
dependencies = append(dependencies, spec[0])
}
for _, link := range service.Links {
dependencies = append(dependencies, strings.Split(link, ":")[0])
}
if len(dependencies) > 0 && service.DependsOn == nil {
service.DependsOn = make(types.DependsOnConfig)
}
for _, d := range dependencies {
if _, ok := service.DependsOn[d]; !ok {
service.DependsOn[d] = types.ServiceDependency{
Condition: types.ServiceConditionStarted,
Required: true,
func resolve(a any, fn func(s string) (string, bool)) (any, bool) {
switch v := a.(type) {
case []any:
var resolved []any
for _, val := range v {
if r, ok := resolve(val, fn); ok {
resolved = append(resolved, r)
}
}
return resolved, true
case map[string]any:
resolved := map[string]any{}
for key, val := range v {
if val != nil {
resolved[key] = val
continue
}
if s, ok := fn(key); ok {
resolved[key] = s
}
}
return resolved, true
case string:
if !strings.Contains(v, "=") {
if val, ok := fn(v); ok {
return fmt.Sprintf("%s=%s", v, val), true
}
return "", false
}
return v, true
default:
return v, false
}
}
// setIfMissing adds a ServiceDependency for service if not already defined
func setIfMissing(d types.DependsOnConfig, service string, dep types.ServiceDependency) types.DependsOnConfig {
if d == nil {
d = types.DependsOnConfig{}
}
if _, ok := d[service]; !ok {
d[service] = dep
}
return d
}
// Resources with no explicit name are actually named by their key in map
func setNameFromKey(project *types.Project) {
for key, n := range project.Networks {
if n.Name == "" {
if n.External {
n.Name = key
} else {
n.Name = fmt.Sprintf("%s_%s", project.Name, key)
}
project.Networks[key] = n
func setNameFromKey(dict map[string]any) {
for _, r := range []string{"networks", "volumes", "configs", "secrets"} {
a, ok := dict[r]
if !ok {
continue
}
}
for key, v := range project.Volumes {
if v.Name == "" {
if v.External {
v.Name = key
toplevel := a.(map[string]any)
for key, r := range toplevel {
var resource map[string]any
if r != nil {
resource = r.(map[string]any)
} else {
v.Name = fmt.Sprintf("%s_%s", project.Name, key)
resource = map[string]any{}
}
project.Volumes[key] = v
}
}
for key, c := range project.Configs {
if c.Name == "" {
if c.External {
c.Name = key
} else {
c.Name = fmt.Sprintf("%s_%s", project.Name, key)
if resource["name"] == nil {
if x, ok := resource["external"]; ok && isTrue(x) {
resource["name"] = key
} else {
resource["name"] = fmt.Sprintf("%s_%s", dict["name"], key)
}
}
project.Configs[key] = c
}
}
for key, s := range project.Secrets {
if s.Name == "" {
if s.External {
s.Name = key
} else {
s.Name = fmt.Sprintf("%s_%s", project.Name, key)
}
project.Secrets[key] = s
toplevel[key] = resource
}
}
}
func relocateLogOpt(s *types.ServiceConfig) error {
if len(s.LogOpt) != 0 {
logrus.Warn("`log_opts` is deprecated. Use the `logging` element")
if s.Logging == nil {
s.Logging = &types.LoggingConfig{}
}
for k, v := range s.LogOpt {
if _, ok := s.Logging.Options[k]; !ok {
s.Logging.Options[k] = v
} else {
return fmt.Errorf("can't use both 'log_opt' (deprecated) and 'logging.options': %w", errdefs.ErrInvalid)
}
}
}
return nil
}
func relocateLogDriver(s *types.ServiceConfig) error {
if s.LogDriver != "" {
logrus.Warn("`log_driver` is deprecated. Use the `logging` element")
if s.Logging == nil {
s.Logging = &types.LoggingConfig{}
}
if s.Logging.Driver == "" {
s.Logging.Driver = s.LogDriver
} else {
return fmt.Errorf("can't use both 'log_driver' (deprecated) and 'logging.driver': %w", errdefs.ErrInvalid)
}
}
return nil
}
func relocateDockerfile(s *types.ServiceConfig) error {
if s.Dockerfile != "" {
logrus.Warn("`dockerfile` is deprecated. Use the `build` element")
if s.Build == nil {
s.Build = &types.BuildConfig{}
}
if s.Dockerfile == "" {
s.Build.Dockerfile = s.Dockerfile
} else {
return fmt.Errorf("can't use both 'dockerfile' (deprecated) and 'build.dockerfile': %w", errdefs.ErrInvalid)
}
}
return nil
func isTrue(x any) bool {
parseBool, _ := strconv.ParseBool(fmt.Sprint(x))
return parseBool
}

View File

@ -17,7 +17,6 @@
package loader
import (
"context"
"errors"
"fmt"
"strings"
@ -71,17 +70,14 @@ func checkConsistency(project *types.Project) error {
}
}
for dependedService := range s.DependsOn {
for dependedService, cfg := range s.DependsOn {
if _, err := project.GetService(dependedService); err != nil {
return fmt.Errorf("service %q depends on undefined service %s: %w", s.Name, dependedService, errdefs.ErrInvalid)
if errors.Is(err, errdefs.ErrDisabled) && !cfg.Required {
continue
}
return fmt.Errorf("service %q depends on undefined service %q: %w", s.Name, dependedService, errdefs.ErrInvalid)
}
}
// Check there isn't a cycle in depends_on declarations
if err := graph.InDependencyOrder(context.Background(), project, func(ctx context.Context, s string, config types.ServiceConfig) error {
return nil
}); err != nil {
return err
}
if strings.HasPrefix(s.NetworkMode, types.ServicePrefix) {
serviceName := s.NetworkMode[len(types.ServicePrefix):]
@ -124,6 +120,31 @@ func checkConsistency(project *types.Project) error {
s.Deploy.Replicas = s.Scale
}
if s.CPUS != 0 && s.Deploy != nil {
if s.Deploy.Resources.Limits != nil && s.Deploy.Resources.Limits.NanoCPUs.Value() != s.CPUS {
return fmt.Errorf("services.%s: can't set distinct values on 'cpus' and 'deploy.resources.limits.cpus': %w",
s.Name, errdefs.ErrInvalid)
}
}
if s.MemLimit != 0 && s.Deploy != nil {
if s.Deploy.Resources.Limits != nil && s.Deploy.Resources.Limits.MemoryBytes != s.MemLimit {
return fmt.Errorf("services.%s: can't set distinct values on 'mem_limit' and 'deploy.resources.limits.memory': %w",
s.Name, errdefs.ErrInvalid)
}
}
if s.MemReservation != 0 && s.Deploy != nil {
if s.Deploy.Resources.Reservations != nil && s.Deploy.Resources.Reservations.MemoryBytes != s.MemReservation {
return fmt.Errorf("services.%s: can't set distinct values on 'mem_reservation' and 'deploy.resources.reservations.memory': %w",
s.Name, errdefs.ErrInvalid)
}
}
if s.PidsLimit != 0 && s.Deploy != nil {
if s.Deploy.Resources.Limits != nil && s.Deploy.Resources.Limits.Pids != s.PidsLimit {
return fmt.Errorf("services.%s: can't set distinct values on 'pids_limit' and 'deploy.resources.limits.pids': %w",
s.Name, errdefs.ErrInvalid)
}
}
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)
@ -159,5 +180,5 @@ func checkConsistency(project *types.Project) error {
}
}
return nil
return graph.CheckCycle(project)
}

View File

@ -322,11 +322,13 @@
{
"type": "object",
"properties": {
"name": {"type": "string"},
"mode": {"type": "string"},
"host_ip": {"type": "string"},
"target": {"type": "integer"},
"published": {"type": ["string", "integer"]},
"protocol": {"type": "string"}
"protocol": {"type": "string"},
"app_protocol": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
@ -389,7 +391,8 @@
"volume": {
"type": "object",
"properties": {
"nocopy": {"type": "boolean"}
"nocopy": {"type": "boolean"},
"subpath": {"type": "string"}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}

View File

@ -44,7 +44,7 @@ var patternString = fmt.Sprintf(
groupInvalid,
)
var defaultPattern = regexp.MustCompile(patternString)
var DefaultPattern = regexp.MustCompile(patternString)
// InvalidTemplateError is returned when a variable template is not in a valid
// format
@ -121,7 +121,7 @@ func SubstituteWithOptions(template string, mapping Mapping, options ...Option)
var returnErr error
cfg := &Config{
pattern: defaultPattern,
pattern: DefaultPattern,
replacementFunc: DefaultReplacementFunc,
logging: true,
}
@ -268,14 +268,14 @@ func getFirstBraceClosingIndex(s string) int {
// Substitute variables in the string with their values
func Substitute(template string, mapping Mapping) (string, error) {
return SubstituteWith(template, mapping, defaultPattern)
return SubstituteWith(template, mapping, DefaultPattern)
}
// ExtractVariables returns a map of all the variables defined in the specified
// composefile (dict representation) and their default value if any.
func ExtractVariables(configDict map[string]interface{}, pattern *regexp.Regexp) map[string]Variable {
if pattern == nil {
pattern = defaultPattern
pattern = DefaultPattern
}
return recurseExtract(configDict, pattern)
}

View File

@ -18,6 +18,7 @@ package types
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
@ -25,6 +26,7 @@ import (
"sort"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/errdefs"
"github.com/compose-spec/compose-go/v2/utils"
"github.com/distribution/reference"
"github.com/mitchellh/copystructure"
@ -215,9 +217,9 @@ func (p *Project) GetService(name string) (ServiceConfig, error) {
if !ok {
_, ok := p.DisabledServices[name]
if ok {
return ServiceConfig{}, fmt.Errorf("service %s is disabled", name)
return ServiceConfig{}, fmt.Errorf("no such service: %s: %w", name, errdefs.ErrDisabled)
}
return ServiceConfig{}, fmt.Errorf("no such service: %s", name)
return ServiceConfig{}, fmt.Errorf("no such service: %s: %w", name, errdefs.ErrNotFound)
}
return service, nil
}
@ -331,6 +333,9 @@ func (s ServiceConfig) HasProfile(profiles []string) bool {
return true
}
for _, p := range profiles {
if p == "*" {
return true
}
for _, sp := range s.Profiles {
if sp == p {
return true
@ -344,11 +349,6 @@ func (s ServiceConfig) HasProfile(profiles []string) bool {
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithProfiles(profiles []string) (*Project, error) {
newProject := p.deepCopy()
for _, p := range profiles {
if p == "*" {
return newProject, nil
}
}
enabled := Services{}
disabled := Services{}
for name, service := range newProject.AllServices() {
@ -536,39 +536,29 @@ func (p *Project) WithServicesDisabled(names ...string) *Project {
// WithImagesResolved updates services images to include digest computed by a resolver function
// It returns a new Project instance with the changes and keep the original Project unchanged
func (p *Project) WithImagesResolved(resolver func(named reference.Named) (godigest.Digest, error)) (*Project, error) {
newProject := p.deepCopy()
eg := errgroup.Group{}
for i, s := range newProject.Services {
idx := i
service := s
return p.WithServicesTransform(func(name string, service ServiceConfig) (ServiceConfig, error) {
if service.Image == "" {
continue
return service, nil
}
eg.Go(func() error {
named, err := reference.ParseDockerRef(service.Image)
named, err := reference.ParseDockerRef(service.Image)
if err != nil {
return service, err
}
if _, ok := named.(reference.Canonical); !ok {
// image is named but not digested reference
digest, err := resolver(named)
if err != nil {
return err
return service, err
}
if _, ok := named.(reference.Canonical); !ok {
// image is named but not digested reference
digest, err := resolver(named)
if err != nil {
return err
}
named, err = reference.WithDigest(named, digest)
if err != nil {
return err
}
named, err = reference.WithDigest(named, digest)
if err != nil {
return service, err
}
service.Image = named.String()
newProject.Services[idx] = service
return nil
})
}
return newProject, eg.Wait()
}
service.Image = named.String()
return service, nil
})
}
// MarshalYAML marshal Project into a yaml tree
@ -606,7 +596,7 @@ func (p *Project) MarshalJSON() ([]byte, error) {
for k, v := range p.Extensions {
m[k] = v
}
return json.Marshal(m)
return json.MarshalIndent(m, "", " ")
}
// WithServicesEnvironmentResolved parses env_files set for services to resolve the actual environment map for services
@ -662,3 +652,47 @@ func (p *Project) deepCopy() *Project {
}
return instance.(*Project)
}
// WithServicesTransform applies a transformation to project services and return a new project with transformation results
func (p *Project) WithServicesTransform(fn func(name string, s ServiceConfig) (ServiceConfig, error)) (*Project, error) {
type result struct {
name string
service ServiceConfig
}
resultCh := make(chan result)
newProject := p.deepCopy()
eg, ctx := errgroup.WithContext(context.Background())
eg.Go(func() error {
expect := len(newProject.Services)
s := Services{}
for expect > 0 {
select {
case <-ctx.Done():
// interrupted as some goroutine returned an error
return nil
case r := <-resultCh:
s[r.name] = r.service
expect--
}
}
newProject.Services = s
return nil
})
for n, s := range newProject.Services {
name := n
service := s
eg.Go(func() error {
updated, err := fn(name, service)
if err != nil {
return err
}
resultCh <- result{
name: name,
service: updated,
}
return nil
})
}
return newProject, eg.Wait()
}

View File

@ -20,6 +20,7 @@ import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/docker/go-connections/nat"
@ -365,7 +366,7 @@ type Resources struct {
// Resource is a resource to be limited or reserved
type Resource struct {
// TODO: types to convert from units and ratios
NanoCPUs string `yaml:"cpus,omitempty" json:"cpus,omitempty"`
NanoCPUs NanoCPUs `yaml:"cpus,omitempty" json:"cpus,omitempty"`
MemoryBytes UnitBytes `yaml:"memory,omitempty" json:"memory,omitempty"`
Pids int64 `yaml:"pids,omitempty" json:"pids,omitempty"`
Devices []DeviceRequest `yaml:"devices,omitempty" json:"devices,omitempty"`
@ -374,6 +375,30 @@ type Resource struct {
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
type NanoCPUs float32
func (n *NanoCPUs) DecodeMapstructure(a any) error {
switch v := a.(type) {
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return err
}
*n = NanoCPUs(f)
case float32:
*n = NanoCPUs(v)
case float64:
*n = NanoCPUs(v)
default:
return fmt.Errorf("unexpected value type %T for cpus", v)
}
return nil
}
func (n *NanoCPUs) Value() float32 {
return float32(*n)
}
// GenericResource represents a "user defined" resource which can
// only be an integer (e.g: SSD=3) for a service
type GenericResource struct {
@ -433,11 +458,13 @@ type ServiceNetworkConfig struct {
// ServicePortConfig is the port configuration for a service
type ServicePortConfig struct {
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
HostIP string `yaml:"host_ip,omitempty" json:"host_ip,omitempty"`
Target uint32 `yaml:"target,omitempty" json:"target,omitempty"`
Published string `yaml:"published,omitempty" json:"published,omitempty"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
HostIP string `yaml:"host_ip,omitempty" json:"host_ip,omitempty"`
Target uint32 `yaml:"target,omitempty" json:"target,omitempty"`
Published string `yaml:"published,omitempty" json:"published,omitempty"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"`
AppProtocol string `yaml:"app_protocol,omitempty" json:"app_protocol,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
@ -511,6 +538,9 @@ func (s ServiceVolumeConfig) String() string {
if s.Volume != nil && s.Volume.NoCopy {
options = append(options, "nocopy")
}
if s.Volume != nil && s.Volume.Subpath != "" {
options = append(options, s.Volume.Subpath)
}
return fmt.Sprintf("%s:%s:%s", s.Source, s.Target, strings.Join(options, ","))
}
@ -567,7 +597,8 @@ const (
// ServiceVolumeVolume are options for a service volume of type volume
type ServiceVolumeVolume struct {
NoCopy bool `yaml:"nocopy,omitempty" json:"nocopy,omitempty"`
NoCopy bool `yaml:"nocopy,omitempty" json:"nocopy,omitempty"`
Subpath string `yaml:"subpath,omitempty" json:"subpath,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}

2
vendor/modules.txt vendored
View File

@ -131,7 +131,7 @@ github.com/cenkalti/backoff/v4
# github.com/cespare/xxhash/v2 v2.2.0
## explicit; go 1.11
github.com/cespare/xxhash/v2
# github.com/compose-spec/compose-go/v2 v2.0.0-rc.8
# github.com/compose-spec/compose-go/v2 v2.0.2
## explicit; go 1.21
github.com/compose-spec/compose-go/v2/cli
github.com/compose-spec/compose-go/v2/consts