bump compose-go to version v2.13.0

Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours
2026-07-02 11:46:15 +02:00
parent 9587b741bc
commit 0492548633
12 changed files with 222 additions and 30 deletions
+18
View File
@@ -383,6 +383,24 @@ func WithoutEnvironmentResolution(o *ProjectOptions) error {
return nil
}
// WithSelectedServices restricts the loaded project to the given services and their
// dependencies. An empty list means "all services". When set, services not in the
// list are dropped from the project before environment resolution, so their
// `env_file` / `label_file` entries are not loaded from disk.
func WithSelectedServices(services ...string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loader.WithSelectedServices(services))
return nil
}
}
// WithoutUnnecessaryResources drops networks/volumes/secrets/configs/models that
// are not referenced by services remaining after selection.
func WithoutUnnecessaryResources(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loader.WithoutUnnecessaryResources)
return nil
}
// DefaultFileNames defines the Compose file names for auto-discovery (in order of preference)
var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
+44
View File
@@ -78,6 +78,14 @@ type Options struct {
projectNameImperativelySet bool
// Profiles set profiles to enable
Profiles []string
// SelectedServices restricts the project model to these services (and their dependencies)
// after parsing. An empty slice means "all services". When set, services not in the list
// are dropped from the project before environment resolution, so their env_file / label_file
// entries are not loaded.
SelectedServices []string
// PruneUnnecessaryResources drops networks/volumes/secrets/configs/models that are not
// referenced by active services after service selection.
PruneUnnecessaryResources bool
// ResourceLoaders manages support for remote resources
ResourceLoaders []ResourceLoader
// KnownExtensions manages x-* attribute we know and the corresponding go structs
@@ -187,6 +195,8 @@ func (o *Options) clone() *Options {
projectName: o.projectName,
projectNameImperativelySet: o.projectNameImperativelySet,
Profiles: o.Profiles,
SelectedServices: o.SelectedServices,
PruneUnnecessaryResources: o.PruneUnnecessaryResources,
ResourceLoaders: o.ResourceLoaders,
KnownExtensions: o.KnownExtensions,
Listeners: o.Listeners,
@@ -260,6 +270,22 @@ func WithProfiles(profiles []string) func(*Options) {
}
}
// WithSelectedServices restricts the loaded project to the given services and their
// dependencies. An empty slice means "all services". When set, services not in the
// list are dropped from the project before environment resolution: their `env_file`
// and `label_file` entries will not be loaded from disk.
func WithSelectedServices(services []string) func(*Options) {
return func(opts *Options) {
opts.SelectedServices = services
}
}
// WithoutUnnecessaryResources drops networks/volumes/secrets/configs/models that
// are not referenced by services remaining after selection.
func WithoutUnnecessaryResources(opts *Options) {
opts.PruneUnnecessaryResources = true
}
// PostProcessor is used to tweak compose model based on metadata extracted during yaml Unmarshal phase
// that hardly can be implemented using go-yaml and mapstructure
type PostProcessor interface {
@@ -603,6 +629,24 @@ func ModelToProject(dict map[string]interface{}, opts *Options, configDetails ty
}
}
if len(opts.SelectedServices) > 0 {
// WithServicesEnabled must precede WithSelectedServices: the latter walks
// only active services, so any selected service currently sitting in
// DisabledServices (e.g. gated by a profile) would otherwise be invisible.
project, err = project.WithServicesEnabled(opts.SelectedServices...)
if err != nil {
return nil, err
}
project, err = project.WithSelectedServices(opts.SelectedServices)
if err != nil {
return nil, err
}
}
if opts.PruneUnnecessaryResources {
project = project.WithoutUnnecessaryResources()
}
if !opts.SkipResolveEnvironment {
project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles)
if err != nil {
+22
View File
@@ -133,6 +133,9 @@ func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
if len(dependsOn) > 0 {
service["depends_on"] = dependsOn
}
inheritPreStartImage(service)
services[name] = service
}
@@ -143,6 +146,25 @@ func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
return dict, nil
}
// inheritPreStartImage propagates the parent service's image to any pre_start
// hook that does not declare its own, per compose-spec PR #647.
func inheritPreStartImage(service map[string]any) {
hooks, ok := service["pre_start"].([]any)
if !ok {
return
}
image, ok := service["image"].(string)
if !ok || image == "" {
return
}
for _, h := range hooks {
hook := h.(map[string]any)
if _, set := hook["image"]; !set {
hook["image"] = image
}
}
}
func normalizeNetworks(dict map[string]any) {
var networks map[string]any
if n, ok := dict["networks"]; ok {
+24 -14
View File
@@ -40,7 +40,7 @@ type nodeCache struct {
type ResetProcessor struct {
target any
paths []tree.Path
visitedNodes map[*yaml.Node][]string
visitedNodes map[*yaml.Node][]tree.Path
resolvedNodes map[*yaml.Node]nodeCache
visitCount int
// maxNodeVisits is the per-document cap; when zero, defaultMaxNodeVisits is used.
@@ -49,7 +49,7 @@ type ResetProcessor struct {
// UnmarshalYAML implement yaml.Unmarshaler
func (p *ResetProcessor) UnmarshalYAML(value *yaml.Node) error {
p.visitedNodes = make(map[*yaml.Node][]string)
p.visitedNodes = make(map[*yaml.Node][]tree.Path)
p.resolvedNodes = make(map[*yaml.Node]nodeCache)
p.visitCount = 0
defer func() {
@@ -190,7 +190,12 @@ func (p *ResetProcessor) resolveContainer(node *yaml.Node, path tree.Path) (*yam
if resolved == nil {
continue
}
if v.Kind == yaml.AliasNode {
// Under the merge key `<<`, the YAML library only accepts an
// AliasNode value when its target is a MappingNode. An alias to a
// SequenceNode (the spec-allowed "sequence of mappings" form via an
// anchor) is rejected. Substitute the resolved target so the YAML
// library sees the underlying node directly for merge keys.
if v.Kind == yaml.AliasNode && key != "<<" {
nodes = append(nodes, node.Content[idx-1], v)
} else {
nodes = append(nodes, node.Content[idx-1], resolved)
@@ -278,36 +283,41 @@ func (p *ResetProcessor) applyNullOverrides(target any, path tree.Path) error {
func (p *ResetProcessor) checkForCycle(node *yaml.Node, path tree.Path) error {
paths := p.visitedNodes[node]
pathStr := path.String()
for _, prevPath := range paths {
// If we're visiting the exact same path, it's not a cycle
if pathStr == prevPath {
if path == prevPath {
continue
}
// Compare on the raw form so dots inside escaped segment names (e.g.
// service names containing ".") aren't conflated with path separators.
pathStr := string(path)
prevStr := string(prevPath)
// If either path is using a merge key, it's legitimate YAML merging
if strings.Contains(prevPath, "<<") || strings.Contains(pathStr, "<<") {
if strings.Contains(prevStr, "<<") || strings.Contains(pathStr, "<<") {
continue
}
// Only consider it a cycle if one path is contained within the other
// and they're not in different service definitions
if (strings.HasPrefix(pathStr, prevPath+".") ||
strings.HasPrefix(prevPath, pathStr+".")) &&
!areInDifferentServices(pathStr, prevPath) {
return fmt.Errorf("cycle detected: node at path %s references node at path %s", pathStr, prevPath)
if (strings.HasPrefix(pathStr, prevStr+".") ||
strings.HasPrefix(prevStr, pathStr+".")) &&
!areInDifferentServices(path, prevPath) {
return fmt.Errorf("cycle detected: node at path %s references node at path %s",
path.String(), prevPath.String())
}
}
p.visitedNodes[node] = append(paths, pathStr)
p.visitedNodes[node] = append(paths, path)
return nil
}
// areInDifferentServices checks if two paths are in different service definitions
func areInDifferentServices(path1, path2 string) bool {
parts1 := strings.Split(path1, ".")
parts2 := strings.Split(path2, ".")
func areInDifferentServices(path1, path2 tree.Path) bool {
parts1 := path1.Parts()
parts2 := path2.Parts()
for i := 0; i < len(parts1) && i < len(parts2); i++ {
if parts1[i] == "services" && i+1 < len(parts1) &&
parts2[i] == "services" && i+1 < len(parts2) {
+42
View File
@@ -688,6 +688,11 @@
},
"uniqueItems": true
},
"pre_start": {
"type": "array",
"items": {"$ref": "#/$defs/pre_start_hook"},
"description": "Init containers to run to completion before the service container is started. Each step runs in its own ephemeral container, in declared order; a non-zero exit fails the bring-up of the service and its dependents."
},
"post_start": {
"type": "array",
"items": {"$ref": "#/$defs/service_hook"},
@@ -1657,6 +1662,43 @@
"required": ["command"]
},
"pre_start_hook": {
"type": "object",
"description": "Configuration for a pre_start init container, run to completion before the service container starts.",
"properties": {
"command": {
"$ref": "#/$defs/command",
"description": "Command to execute. Optional when the chosen image's entrypoint already runs the intended command."
},
"image": {
"type": "string",
"description": "Image used for the ephemeral container. If omitted, the parent service's image is used."
},
"user": {
"type": "string",
"description": "User to run the command as. Defaults to the user declared in image (or to the service's user when image is omitted)."
},
"privileged": {
"type": ["boolean", "string"],
"description": "Whether to run the command with extended privileges."
},
"working_dir": {
"type": "string",
"description": "Working directory for the command. Defaults to the service's working directory."
},
"environment": {
"$ref": "#/$defs/list_or_dict",
"description": "Environment variables for the command. Appended to or overriding the service environment."
},
"per_replica": {
"type": ["boolean", "string"],
"description": "Whether the hook runs once per service replica (true), or once for the service as a whole before any replica starts (false, the default)."
}
},
"additionalProperties": false,
"patternProperties": {"^x-": {}}
},
"env_file": {
"oneOf": [
{
+35 -11
View File
@@ -24,6 +24,7 @@ import (
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/santhosh-tekuri/jsonschema/v6"
@@ -46,22 +47,45 @@ func durationFormatChecker(input any) error {
//go:embed compose-spec.json
var Schema string
// compiledSchema is the compose-spec schema compiled once and reused across
// every Validate call. Compiling the schema (which loads and resolves the
// draft 2020-12 meta-schema) is expensive and was previously redone on every
// call; doing it once behind a sync.Once keeps Validate cheap and makes it
// safe to call from several goroutines without sharing any mutable compiler
// state. see https://github.com/docker/compose/issues/13866
var (
compiledSchema *jsonschema.Schema
compiledSchemaErr error
compiledSchemaOnce sync.Once
)
func compileSchema() (*jsonschema.Schema, error) {
compiledSchemaOnce.Do(func() {
compiler := jsonschema.NewCompiler()
shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
if err != nil {
compiledSchemaErr = err
return
}
if err := compiler.AddResource("compose-spec.json", shema); err != nil {
compiledSchemaErr = err
return
}
compiler.RegisterFormat(&jsonschema.Format{
Name: "duration",
Validate: durationFormatChecker,
})
compiledSchema, compiledSchemaErr = compiler.Compile("compose-spec.json")
})
return compiledSchema, compiledSchemaErr
}
// Validate uses the jsonschema to validate the configuration
func Validate(config map[string]interface{}) error {
compiler := jsonschema.NewCompiler()
shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
schema, err := compileSchema()
if err != nil {
return err
}
err = compiler.AddResource("compose-spec.json", shema)
if err != nil {
return err
}
compiler.RegisterFormat(&jsonschema.Format{
Name: "duration",
Validate: durationFormatChecker,
})
schema := compiler.MustCompile("compose-spec.json")
// santhosh-tekuri doesn't allow derived types
// see https://github.com/santhosh-tekuri/jsonschema/pull/240
+26
View File
@@ -729,6 +729,24 @@ func deriveDeepCopyService(dst, src *ServiceConfig) {
copy(dst.VolumesFrom, src.VolumesFrom)
}
dst.WorkingDir = src.WorkingDir
if src.PreStart == nil {
dst.PreStart = nil
} else {
if dst.PreStart != nil {
if len(src.PreStart) > len(dst.PreStart) {
if cap(dst.PreStart) >= len(src.PreStart) {
dst.PreStart = (dst.PreStart)[:len(src.PreStart)]
} else {
dst.PreStart = make([]ServiceHook, len(src.PreStart))
}
} else if len(src.PreStart) < len(dst.PreStart) {
dst.PreStart = (dst.PreStart)[:len(src.PreStart)]
}
} else {
dst.PreStart = make([]ServiceHook, len(src.PreStart))
}
deriveDeepCopy_26(dst.PreStart, src.PreStart)
}
if src.PostStart == nil {
dst.PostStart = nil
} else {
@@ -2101,6 +2119,7 @@ func deriveDeepCopy_49(dst, src *ServiceHook) {
}
copy(dst.Command, src.Command)
}
dst.Image = src.Image
dst.User = src.User
dst.Privileged = src.Privileged
dst.WorkingDir = src.WorkingDir
@@ -2110,6 +2129,7 @@ func deriveDeepCopy_49(dst, src *ServiceHook) {
} else {
dst.Environment = nil
}
dst.PerReplica = src.PerReplica
if src.Extensions != nil {
dst.Extensions = make(map[string]any, len(src.Extensions))
src.Extensions.DeepCopy(dst.Extensions)
@@ -2139,6 +2159,12 @@ func deriveDeepCopy_50(dst, src *IPAMConfig) {
}
deriveDeepCopy_60(dst.Config, src.Config)
}
if src.Options != nil {
dst.Options = make(map[string]string, len(src.Options))
deriveDeepCopy_5(dst.Options, src.Options)
} else {
dst.Options = nil
}
if src.Extensions != nil {
dst.Extensions = make(map[string]any, len(src.Extensions))
src.Extensions.DeepCopy(dst.Extensions)
+5 -1
View File
@@ -16,13 +16,17 @@
package types
// ServiceHook is a command to exec inside container by some lifecycle events
// ServiceHook is a hook executed at a service lifecycle event: a command exec'd
// inside the service container for post_start/pre_stop, or an ephemeral
// container run before the service starts for pre_start.
type ServiceHook struct {
Command ShellCommand `yaml:"command,omitempty" json:"command"`
Image string `yaml:"image,omitempty" json:"image,omitempty"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
Privileged bool `yaml:"privileged,omitempty" json:"privileged,omitempty"`
WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"`
Environment MappingWithEquals `yaml:"environment,omitempty" json:"environment,omitempty"`
PerReplica bool `yaml:"per_replica,omitempty" json:"per_replica,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}
+2
View File
@@ -138,6 +138,7 @@ type ServiceConfig struct {
Volumes []ServiceVolumeConfig `yaml:"volumes,omitempty" json:"volumes,omitempty"`
VolumesFrom []string `yaml:"volumes_from,omitempty" json:"volumes_from,omitempty"`
WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"`
PreStart []ServiceHook `yaml:"pre_start,omitempty" json:"pre_start,omitempty"`
PostStart []ServiceHook `yaml:"post_start,omitempty" json:"post_start,omitempty"`
PreStop []ServiceHook `yaml:"pre_stop,omitempty" json:"pre_stop,omitempty"`
@@ -753,6 +754,7 @@ type NetworkConfig struct {
type IPAMConfig struct {
Driver string `yaml:"driver,omitempty" json:"driver,omitempty"`
Config []*IPAMPool `yaml:"config,omitempty" json:"config,omitempty"`
Options Options `yaml:"options,omitempty" json:"options,omitempty"`
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
}