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
16 changed files with 530 additions and 438 deletions

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)
}