diff --git a/pkg/api/labels.go b/pkg/api/labels.go index ba110c83147..3a6ee60fba2 100644 --- a/pkg/api/labels.go +++ b/pkg/api/labels.go @@ -31,6 +31,8 @@ const ( ServiceLabel = "com.docker.compose.service" // ConfigHashLabel stores configuration hash for a compose service ConfigHashLabel = "com.docker.compose.config-hash" + // ConfigHashDependenciesLabel stores configuration hash for a compose service dependencies + ConfigHashDependenciesLabel = "com.docker.compose.config-hash-dependencies" // ContainerNumberLabel stores the container index of a replicated service ContainerNumberLabel = "com.docker.compose.container-number" // VolumeLabel allow to track resource related to a compose volume diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 3b680fe77c3..ed531e67923 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -128,11 +128,11 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, sort.Slice(containers, func(i, j int) bool { // select obsolete containers first, so they get removed as we scale down - if obsolete, _ := mustRecreate(service, containers[i], recreate); obsolete { + if obsolete, _ := mustRecreate(project, service, containers[i], recreate); obsolete { // i is obsolete, so must be first in the list return true } - if obsolete, _ := mustRecreate(service, containers[j], recreate); obsolete { + if obsolete, _ := mustRecreate(project, service, containers[j], recreate); obsolete { // j is obsolete, so must be first in the list return false } @@ -158,7 +158,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, continue } - mustRecreate, err := mustRecreate(service, container, recreate) + mustRecreate, err := mustRecreate(project, service, container, recreate) if err != nil { return err } @@ -292,20 +292,34 @@ func (c *convergence) resolveSharedNamespaces(service *types.ServiceConfig) erro return nil } -func mustRecreate(expected types.ServiceConfig, actual moby.Container, policy string) (bool, error) { +func mustRecreate(project *types.Project, expected types.ServiceConfig, actual moby.Container, policy string) (bool, error) { if policy == api.RecreateNever { return false, nil } if policy == api.RecreateForce || expected.Extensions[extLifecycle] == forceRecreate { return true, nil } - configHash, err := ServiceHash(expected) + serviceHash, err := ServiceHash(expected) if err != nil { return false, err } - configChanged := actual.Labels[api.ConfigHashLabel] != configHash - imageUpdated := actual.Labels[api.ImageDigestLabel] != expected.CustomLabels[api.ImageDigestLabel] - return configChanged || imageUpdated, nil + + if actual.Labels[api.ConfigHashLabel] != serviceHash { + return true, nil + } + + if actual.Labels[api.ImageDigestLabel] != expected.CustomLabels[api.ImageDigestLabel] { + return true, nil + } + + serviceDependenciesHash, err := ServiceDependenciesHash(project, expected) + if err != nil { + return false, err + } + + serviceDependenciesChanged := actual.Labels[api.ConfigHashDependenciesLabel] != serviceDependenciesHash + + return serviceDependenciesChanged, nil } func getContainerName(projectName string, service types.ServiceConfig, number int) string { diff --git a/pkg/compose/create.go b/pkg/compose/create.go index ccf92058057..685bc84fc1e 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -213,7 +213,7 @@ func (s *composeService) getCreateConfigs(ctx context.Context, inherit *moby.Container, opts createOptions, ) (createConfigs, error) { - labels, err := s.prepareLabels(opts.Labels, service, number) + labels, err := s.prepareLabels(opts.Labels, p, service, number) if err != nil { return createConfigs{}, err } @@ -499,13 +499,19 @@ func parseSecurityOpts(p *types.Project, securityOpts []string) ([]string, bool, return parsed, unconfined, nil } -func (s *composeService) prepareLabels(labels types.Labels, service types.ServiceConfig, number int) (map[string]string, error) { - hash, err := ServiceHash(service) +func (s *composeService) prepareLabels(labels types.Labels, project *types.Project, service types.ServiceConfig, number int) (map[string]string, error) { + serviceHash, err := ServiceHash(service) if err != nil { return nil, err } - labels[api.ConfigHashLabel] = hash + serviceDependenciesHash, err := ServiceDependenciesHash(project, service) + if err != nil { + return nil, err + } + + labels[api.ConfigHashLabel] = serviceHash + labels[api.ConfigHashDependenciesLabel] = serviceDependenciesHash labels[api.ContainerNumberLabel] = strconv.Itoa(number) var dependencies []string diff --git a/pkg/compose/hash.go b/pkg/compose/hash.go index 284ccaa76fc..7d38d8d8b3c 100644 --- a/pkg/compose/hash.go +++ b/pkg/compose/hash.go @@ -18,6 +18,7 @@ package compose import ( "encoding/json" + "os" "github.com/compose-spec/compose-go/v2/types" "github.com/opencontainers/go-digest" @@ -39,3 +40,28 @@ func ServiceHash(o types.ServiceConfig) (string, error) { } return digest.SHA256.FromBytes(bytes).Encoded(), nil } + +// ServiceDependenciesHash computes the configuration hash for service dependencies. +func ServiceDependenciesHash(project *types.Project, o types.ServiceConfig) (string, error) { + bytes := make([]byte, 0) + for _, serviceConfig := range o.Configs { + projectConfig, ok := project.Configs[serviceConfig.Source] + if !ok { + continue + } + + if projectConfig.Content != "" { + bytes = append(bytes, []byte(projectConfig.Content)...) + } else if projectConfig.File != "" { + content, err := os.ReadFile(projectConfig.File) + if err != nil { + return "", err + } + bytes = append(bytes, content...) + } else if projectConfig.Environment != "" { + bytes = append(bytes, []byte(projectConfig.Environment)...) + } + } + + return digest.SHA256.FromBytes(bytes).Encoded(), nil +} diff --git a/pkg/compose/hash_test.go b/pkg/compose/hash_test.go index 73b7f387735..0aef924c8d7 100644 --- a/pkg/compose/hash_test.go +++ b/pkg/compose/hash_test.go @@ -23,21 +23,88 @@ import ( "gotest.tools/v3/assert" ) -func TestServiceHash(t *testing.T) { - hash1, err := ServiceHash(serviceConfig(1)) +func TestServiceHashWithAllValuesTheSame(t *testing.T) { + hash1, err := ServiceHash(serviceConfig("myContext1", "always", 1)) assert.NilError(t, err) - hash2, err := ServiceHash(serviceConfig(2)) + hash2, err := ServiceHash(serviceConfig("myContext1", "always", 1)) assert.NilError(t, err) assert.Equal(t, hash1, hash2) } -func serviceConfig(replicas int) types.ServiceConfig { +func TestServiceHashWithIgnorableValues(t *testing.T) { + hash1, err := ServiceHash(serviceConfig("myContext1", "always", 1)) + assert.NilError(t, err) + hash2, err := ServiceHash(serviceConfig("myContext2", "never", 2)) + assert.NilError(t, err) + assert.Equal(t, hash1, hash2) +} + +func TestServiceDependenciesHashWithoutChangesContent(t *testing.T) { + hash1, err := ServiceDependenciesHash(projectConfig("myConfigSource", "a", "", ""), serviceConfig("myContext1", "always", 1)) + assert.NilError(t, err) + hash2, err := ServiceDependenciesHash(projectConfig("myConfigSource", "a", "", ""), serviceConfig("myContext2", "never", 2)) + assert.NilError(t, err) + assert.Assert(t, hash1 == hash2) +} + +func TestServiceDependenciesHashWithChangedConfigContent(t *testing.T) { + hash1, err := ServiceDependenciesHash(projectConfig("myConfigSource", "a", "", ""), serviceConfig("myContext1", "always", 1)) + assert.NilError(t, err) + hash2, err := ServiceDependenciesHash(projectConfig("myConfigSource", "b", "", ""), serviceConfig("myContext2", "never", 2)) + assert.NilError(t, err) + assert.Assert(t, hash1 != hash2) +} + +func TestServiceDependenciesHashWithChangedConfigEnvironment(t *testing.T) { + hash1, err := ServiceDependenciesHash(projectConfig("myConfigSource", "", "a", ""), serviceConfig("myContext1", "always", 1)) + assert.NilError(t, err) + hash2, err := ServiceDependenciesHash(projectConfig("myConfigSource", "", "b", ""), serviceConfig("myContext2", "never", 2)) + assert.NilError(t, err) + assert.Assert(t, hash1 != hash2) +} + +func TestServiceDependenciesHashWithChangedConfigFile(t *testing.T) { + hash1, err := ServiceDependenciesHash( + projectConfig("myConfigSource", "", "", "./testdata/config1.txt"), + serviceConfig("myContext1", "always", 1), + ) + assert.NilError(t, err) + hash2, err := ServiceDependenciesHash( + projectConfig("myConfigSource", "", "", "./testdata/config2.txt"), + serviceConfig("myContext2", "never", 2), + ) + assert.NilError(t, err) + assert.Assert(t, hash1 != hash2) +} + +func projectConfig(configName, configContent, configEnvironment, configFile string) *types.Project { + return &types.Project{ + Configs: types.Configs{ + configName: types.ConfigObjConfig{ + Content: configContent, + Environment: configEnvironment, + File: configFile, + }, + }, + } +} + +func serviceConfig(buildContext, pullPolicy string, replicas int) types.ServiceConfig { return types.ServiceConfig{ - Scale: &replicas, + Build: &types.BuildConfig{ + Context: buildContext, + }, + PullPolicy: pullPolicy, + Scale: &replicas, Deploy: &types.DeployConfig{ Replicas: &replicas, }, Name: "foo", Image: "bar", + Configs: []types.ServiceConfigObjConfig{ + { + Source: "myConfigSource", + }, + }, } } diff --git a/pkg/compose/testdata/config1.txt b/pkg/compose/testdata/config1.txt new file mode 100644 index 00000000000..c8a618fe3ee --- /dev/null +++ b/pkg/compose/testdata/config1.txt @@ -0,0 +1 @@ +This is 1 config file diff --git a/pkg/compose/testdata/config2.txt b/pkg/compose/testdata/config2.txt new file mode 100644 index 00000000000..51f7bc363de --- /dev/null +++ b/pkg/compose/testdata/config2.txt @@ -0,0 +1 @@ +This is 2 config file