diff --git a/go.mod b/go.mod index e6a921e4..97be7582 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,8 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/go.sum b/go.sum index d61ccb71..6f3c68c3 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,12 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/loader/loader.go b/loader/loader.go index fc0f3545..e0756680 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -451,10 +451,12 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, } } - project.ApplyProfiles(opts.Profiles) + if project, err = project.WithProfiles(opts.Profiles); err != nil { + return nil, err + } if !opts.SkipResolveEnvironment { - err := project.ResolveServicesEnvironment(opts.discardEnvFiles) + project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles) if err != nil { return nil, err } diff --git a/loader/loader_test.go b/loader/loader_test.go index 76bedb92..4b9d6bc0 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -2131,7 +2131,7 @@ func TestLoadServiceWithEnvFile(t *testing.T) { }, }, } - err = p.ResolveServicesEnvironment(false) + p, err = p.WithServicesEnvironmentResolved(false) assert.NilError(t, err) service, err := p.GetService("test") assert.NilError(t, err) diff --git a/types/config_test.go b/types/config_test.go index e0730b5f..13962fbf 100644 --- a/types/config_test.go +++ b/types/config_test.go @@ -23,7 +23,7 @@ import ( ) func Test_WithServices(t *testing.T) { - p := Project{ + p := &Project{ Services: Services{ "service_1": ServiceConfig{ Name: "service_1", @@ -49,12 +49,12 @@ func Test_WithServices(t *testing.T) { }, } order := []string{} - fn := func(name string, _ ServiceConfig) error { + fn := func(name string, _ *ServiceConfig) error { order = append(order, name) return nil } - err := p.WithServices(nil, fn) + err := p.ForEachService(nil, fn) assert.NilError(t, err) assert.DeepEqual(t, order, []string{"service_2", "service_3", "service_1"}) } diff --git a/types/project.go b/types/project.go index 7c035608..a50fd9b3 100644 --- a/types/project.go +++ b/types/project.go @@ -27,6 +27,7 @@ import ( "github.com/compose-spec/compose-go/v2/dotenv" "github.com/compose-spec/compose-go/v2/utils" "github.com/distribution/reference" + "github.com/mitchellh/copystructure" godigest "github.com/opencontainers/go-digest" "github.com/pkg/errors" "golang.org/x/sync/errgroup" @@ -34,6 +35,8 @@ import ( ) // Project is the result of loading a set of compose files +// Since v2, Project are managed as immutable objects. +// Each public functions which mutate Project state now return a copy of the original Project with the expected changes. type Project struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` WorkingDir string `yaml:"-" json:"-"` @@ -183,10 +186,10 @@ func (p *Project) AllServices() Services { return all } -type ServiceFunc func(name string, service ServiceConfig) error +type ServiceFunc func(name string, service *ServiceConfig) error -// WithServices run ServiceFunc on each service and dependencies according to DependencyPolicy -func (p *Project) WithServices(names []string, fn ServiceFunc, options ...DependencyOption) error { +// ForEachService runs ServiceFunc on each service and dependencies according to DependencyPolicy +func (p *Project) ForEachService(names []string, fn ServiceFunc, options ...DependencyOption) error { if len(options) == 0 { // backward compatibility options = []DependencyOption{IncludeDependencies} @@ -240,7 +243,7 @@ func (p *Project) withServices(names []string, fn ServiceFunc, seen map[string]b return err } } - if err := fn(name, service); err != nil { + if err := fn(name, service.deepCopy()); err != nil { return err } } @@ -290,54 +293,64 @@ func (s ServiceConfig) HasProfile(profiles []string) bool { return false } -// ApplyProfiles disables service which don't match selected profiles -func (p *Project) ApplyProfiles(profiles []string) { +// WithProfiles disables services which don't match selected profiles +// 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 + return newProject, nil } } enabled := Services{} disabled := Services{} - for name, service := range p.AllServices() { + for name, service := range newProject.AllServices() { if service.HasProfile(profiles) { enabled[name] = service } else { disabled[name] = service } } - p.Services = enabled - p.DisabledServices = disabled - p.Profiles = profiles + newProject.Services = enabled + newProject.DisabledServices = disabled + newProject.Profiles = profiles + return newProject, nil } -// EnableServices ensure services are enabled and activate profiles accordingly -func (p *Project) EnableServices(names ...string) error { +// WithServicesEnabled ensures services are enabled and activate profiles accordingly +// It returns a new Project instance with the changes and keep the original Project unchanged +func (p *Project) WithServicesEnabled(names ...string) (*Project, error) { + newProject := p.deepCopy() if len(names) == 0 { - return nil + return newProject, nil } profiles := append([]string{}, p.Profiles...) for _, name := range names { - if _, ok := p.Services[name]; ok { + if _, ok := newProject.Services[name]; ok { // already enabled continue } service := p.DisabledServices[name] profiles = append(profiles, service.Profiles...) } - p.ApplyProfiles(profiles) + newProject, err := newProject.WithProfiles(profiles) + if err != nil { + return newProject, err + } - return p.ResolveServicesEnvironment(true) + return newProject.WithServicesEnvironmentResolved(true) } // WithoutUnnecessaryResources drops networks/volumes/secrets/configs that are not referenced by active services -func (p *Project) WithoutUnnecessaryResources() { +// It returns a new Project instance with the changes and keep the original Project unchanged +func (p *Project) WithoutUnnecessaryResources() *Project { + newProject := p.deepCopy() requiredNetworks := map[string]struct{}{} requiredVolumes := map[string]struct{}{} requiredSecrets := map[string]struct{}{} requiredConfigs := map[string]struct{}{} - for _, s := range p.Services { + for _, s := range newProject.Services { for k := range s.Networks { requiredNetworks[k] = struct{}{} } @@ -366,7 +379,7 @@ func (p *Project) WithoutUnnecessaryResources() { networks[k] = value } } - p.Networks = networks + newProject.Networks = networks volumes := Volumes{} for k := range requiredVolumes { @@ -374,7 +387,7 @@ func (p *Project) WithoutUnnecessaryResources() { volumes[k] = value } } - p.Volumes = volumes + newProject.Volumes = volumes secrets := Secrets{} for k := range requiredSecrets { @@ -382,7 +395,7 @@ func (p *Project) WithoutUnnecessaryResources() { secrets[k] = value } } - p.Secrets = secrets + newProject.Secrets = secrets configs := Configs{} for k := range requiredConfigs { @@ -390,7 +403,8 @@ func (p *Project) WithoutUnnecessaryResources() { configs[k] = value } } - p.Configs = configs + newProject.Configs = configs + return newProject } type DependencyOption func(options *withServicesOptions) @@ -407,25 +421,27 @@ func IgnoreDependencies(options *withServicesOptions) { options.dependencyPolicy = ignoreDependencies } -// ForServices restrict the project model to selected services and dependencies -func (p *Project) ForServices(names []string, options ...DependencyOption) error { +// WithSelectedServices restricts the project model to selected services and dependencies +// It returns a new Project instance with the changes and keep the original Project unchanged +func (p *Project) WithSelectedServices(names []string, options ...DependencyOption) (*Project, error) { + newProject := p.deepCopy() if len(names) == 0 { // All services - return nil + return newProject, nil } set := utils.NewSet[string]() - err := p.WithServices(names, func(name string, service ServiceConfig) error { + err := p.ForEachService(names, func(name string, service *ServiceConfig) error { set.Add(name) return nil }, options...) if err != nil { - return err + return nil, err } // Disable all services which are not explicit target or dependencies enabled := Services{} - for name, s := range p.Services { + for name, s := range newProject.Services { if _, ok := set[name]; ok { // remove all dependencies but those implied by explicitly selected services dependencies := s.DependsOn @@ -437,32 +453,45 @@ func (p *Project) ForServices(names []string, options ...DependencyOption) error s.DependsOn = dependencies enabled[name] = s } else { - p.DisableService(s) + newProject = newProject.WithServicesDisabled(name) } } - p.Services = enabled - return nil + newProject.Services = enabled + return newProject, nil } -func (p *Project) DisableService(service ServiceConfig) { - // We should remove all dependencies which reference the disabled service - for i, s := range p.Services { - if _, ok := s.DependsOn[service.Name]; ok { - delete(s.DependsOn, service.Name) - p.Services[i] = s - } +// WithServicesDisabled removes from the project model the given services and their references in all dependencies +// It returns a new Project instance with the changes and keep the original Project unchanged +func (p *Project) WithServicesDisabled(names ...string) *Project { + newProject := p.deepCopy() + if len(names) == 0 { + return newProject + } + if newProject.DisabledServices == nil { + newProject.DisabledServices = Services{} } - delete(p.Services, service.Name) - if p.DisabledServices == nil { - p.DisabledServices = Services{} + for _, name := range names { + // We should remove all dependencies which reference the disabled service + for i, s := range newProject.Services { + if _, ok := s.DependsOn[name]; ok { + delete(s.DependsOn, name) + newProject.Services[i] = s + } + } + if service, ok := newProject.Services[name]; ok { + newProject.DisabledServices[name] = service + delete(newProject.Services, name) + } } - p.DisabledServices[service.Name] = service + return newProject } -// ResolveImages updates services images to include digest computed by a resolver function -func (p *Project) ResolveImages(resolver func(named reference.Named) (godigest.Digest, error)) error { +// 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 p.Services { + for i, s := range newProject.Services { idx := i service := s @@ -488,11 +517,11 @@ func (p *Project) ResolveImages(resolver func(named reference.Named) (godigest.D } service.Image = named.String() - p.Services[idx] = service + newProject.Services[idx] = service return nil }) } - return eg.Wait() + return newProject, eg.Wait() } // MarshalYAML marshal Project into a yaml tree @@ -533,10 +562,12 @@ func (p *Project) MarshalJSON() ([]byte, error) { return json.Marshal(m) } -// ResolveServicesEnvironment parse env_files set for services to resolve the actual environment map for services -func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error { - for i, service := range p.Services { - service.Environment = service.Environment.Resolve(p.Environment.Resolve) +// WithServicesEnvironmentResolved parses env_files set for services to resolve the actual environment map for services +// It returns a new Project instance with the changes and keep the original Project unchanged +func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project, error) { + newProject := p.deepCopy() + for i, service := range newProject.Services { + service.Environment = service.Environment.Resolve(newProject.Environment.Resolve) environment := MappingWithEquals{} // resolve variables based on other files we already parsed, + project's environment @@ -545,24 +576,24 @@ func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error { if ok && v != nil { return *v, ok } - return p.Environment.Resolve(s) + return newProject.Environment.Resolve(s) } for _, envFile := range service.EnvFiles { if _, err := os.Stat(envFile.Path); os.IsNotExist(err) { if envFile.Required { - return errors.Wrapf(err, "env file %s not found", envFile.Path) + return nil, errors.Wrapf(err, "env file %s not found", envFile.Path) } continue } b, err := os.ReadFile(envFile.Path) if err != nil { - return errors.Wrapf(err, "failed to load %s", envFile.Path) + return nil, errors.Wrapf(err, "failed to load %s", envFile.Path) } fileVars, err := dotenv.ParseWithLookup(bytes.NewBuffer(b), resolve) if err != nil { - return errors.Wrapf(err, "failed to read %s", envFile.Path) + return nil, errors.Wrapf(err, "failed to read %s", envFile.Path) } environment.OverrideBy(Mapping(fileVars).ToMappingWithEquals()) } @@ -572,7 +603,15 @@ func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error { if discardEnvFiles { service.EnvFiles = nil } - p.Services[i] = service + newProject.Services[i] = service } - return nil + return newProject, nil +} + +func (p *Project) deepCopy() *Project { + instance, err := copystructure.Copy(p) + if err != nil { + panic(err) + } + return instance.(*Project) } diff --git a/types/project_test.go b/types/project_test.go index 5aed8da3..7d226c24 100644 --- a/types/project_test.go +++ b/types/project_test.go @@ -28,11 +28,12 @@ import ( func Test_ApplyProfiles(t *testing.T) { p := makeProject() - p.ApplyProfiles([]string{"foo"}) + p, err := p.WithProfiles([]string{"foo"}) + assert.NilError(t, err) assert.DeepEqual(t, p.ServiceNames(), []string{"service_1", "service_2", "service_6"}) assert.DeepEqual(t, p.DisabledServiceNames(), []string{"service_3", "service_4", "service_5"}) - err := p.EnableServices("service_4") + p, err = p.WithServicesEnabled("service_4") assert.NilError(t, err) assert.DeepEqual(t, p.ServiceNames(), []string{"service_1", "service_2", "service_4", "service_5", "service_6"}) @@ -46,7 +47,7 @@ func Test_WithoutUnnecessaryResources(t *testing.T) { p.Volumes["unused"] = VolumeConfig{} p.Secrets["unused"] = SecretConfig{} p.Configs["unused"] = ConfigObjConfig{} - p.WithoutUnnecessaryResources() + p = p.WithoutUnnecessaryResources() if _, ok := p.Networks["unused"]; ok { t.Fail() } @@ -63,7 +64,8 @@ func Test_WithoutUnnecessaryResources(t *testing.T) { func Test_NoProfiles(t *testing.T) { p := makeProject() - p.ApplyProfiles(nil) + p, err := p.WithProfiles(nil) + assert.NilError(t, err) assert.Equal(t, len(p.Services), 2) assert.Equal(t, len(p.DisabledServices), 4) assert.DeepEqual(t, p.ServiceNames(), []string{"service_1", "service_6"}) @@ -81,21 +83,21 @@ func Test_ServiceProfiles(t *testing.T) { func Test_ForServices(t *testing.T) { p := makeProject() - err := p.ForServices([]string{"service_2"}) + p, err := p.WithSelectedServices([]string{"service_2"}) assert.NilError(t, err) assert.DeepEqual(t, p.DisabledServiceNames(), []string{"service_3", "service_4", "service_5", "service_6"}) // Should not load the dependency service_1 when explicitly loading service_6 p = makeProject() - err = p.ForServices([]string{"service_6"}) + p, err = p.WithSelectedServices([]string{"service_6"}) assert.NilError(t, err) assert.DeepEqual(t, p.DisabledServiceNames(), []string{"service_1", "service_2", "service_3", "service_4", "service_5"}) } func Test_ForServicesIgnoreDependencies(t *testing.T) { p := makeProject() - err := p.ForServices([]string{"service_2"}, IgnoreDependencies) + p, err := p.WithSelectedServices([]string{"service_2"}, IgnoreDependencies) assert.NilError(t, err) assert.Equal(t, len(p.DisabledServices), 5) @@ -104,7 +106,7 @@ func Test_ForServicesIgnoreDependencies(t *testing.T) { assert.Equal(t, len(service.DependsOn), 0) p = makeProject() - err = p.ForServices([]string{"service_2", "service_3"}, IgnoreDependencies) + p, err = p.WithSelectedServices([]string{"service_2", "service_3"}, IgnoreDependencies) assert.NilError(t, err) assert.Equal(t, len(p.DisabledServices), 4) @@ -120,12 +122,12 @@ func Test_ForServicesCycle(t *testing.T) { service := p.Services["service_1"] service.Links = []string{"service_2"} p.Services["service_1"] = service - err := p.ForServices([]string{"service_2"}) + p, err := p.WithSelectedServices([]string{"service_2"}) assert.NilError(t, err) } -func makeProject() Project { - return Project{ +func makeProject() *Project { + return &Project{ Services: Services{ "service_1": ServiceConfig{ Name: "service_1", @@ -197,7 +199,7 @@ func Test_ResolveImages(t *testing.T) { service := p.Services["service_1"] service.Image = test.image p.Services["service_1"] = service - err := p.ResolveImages(resolver) + p, err := p.WithImagesResolved(resolver) assert.NilError(t, err) assert.Equal(t, p.Services["service_1"].Image, test.resolved) } @@ -206,7 +208,7 @@ func Test_ResolveImages(t *testing.T) { func TestWithServices(t *testing.T) { p := makeProject() var seen []string - err := p.WithServices([]string{"service_3"}, func(name string, _ ServiceConfig) error { + err := p.ForEachService([]string{"service_3"}, func(name string, _ *ServiceConfig) error { seen = append(seen, name) return nil }, IncludeDependencies) @@ -214,7 +216,7 @@ func TestWithServices(t *testing.T) { assert.DeepEqual(t, seen, []string{"service_1", "service_2", "service_3"}) seen = []string{} - err = p.WithServices([]string{"service_1"}, func(name string, _ ServiceConfig) error { + err = p.ForEachService([]string{"service_1"}, func(name string, _ *ServiceConfig) error { seen = append(seen, name) return nil }, IncludeDependents) @@ -223,7 +225,7 @@ func TestWithServices(t *testing.T) { assert.Check(t, utils.ArrayContains(seen, []string{"service_3", "service_4", "service_2", "service_1"})) seen = []string{} - err = p.WithServices([]string{"service_1"}, func(name string, _ ServiceConfig) error { + err = p.ForEachService([]string{"service_1"}, func(name string, _ *ServiceConfig) error { seen = append(seen, name) return nil }, IgnoreDependencies) @@ -231,7 +233,7 @@ func TestWithServices(t *testing.T) { assert.DeepEqual(t, seen, []string{"service_1"}) seen = []string{} - err = p.WithServices([]string{"service_4"}, func(name string, _ ServiceConfig) error { + err = p.ForEachService([]string{"service_4"}, func(name string, _ *ServiceConfig) error { seen = append(seen, name) return nil }, IncludeDependencies) diff --git a/types/types.go b/types/types.go index 3ec9223c..103db084 100644 --- a/types/types.go +++ b/types/types.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/docker/go-connections/nat" + "github.com/mitchellh/copystructure" ) // ServiceConfig is the configuration of one service @@ -187,6 +188,14 @@ func (s *ServiceConfig) SetScale(scale int) { } } +func (s *ServiceConfig) deepCopy() *ServiceConfig { + instance, err := copystructure.Copy(s) + if err != nil { + panic(err) + } + return instance.(*ServiceConfig) +} + const ( // PullPolicyAlways always pull images PullPolicyAlways = "always" @@ -247,22 +256,6 @@ func (s ServiceConfig) GetDependents(p *Project) []string { return dependent } -type set map[string]struct{} - -func (s set) append(strs ...string) { - for _, str := range strs { - s[str] = struct{}{} - } -} - -func (s set) toSlice() []string { - slice := make([]string, 0, len(s)) - for v := range s { - slice = append(slice, v) - } - return slice -} - // BuildConfig is a type for build type BuildConfig struct { Context string `yaml:"context,omitempty" json:"context,omitempty"` diff --git a/types/types_test.go b/types/types_test.go index 495b8fe6..08df5c8e 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -211,15 +211,6 @@ func assertContains(t *testing.T, portConfigs []ServicePortConfig, expected Serv } } -func TestSet(t *testing.T) { - s := make(set) - s.append("one") - s.append("two") - s.append("three") - s.append("two") - assert.Equal(t, len(s.toSlice()), 3) -} - type foo struct { Bar string }