Skip to content

Commit

Permalink
feat: add separate hashes for service configs and secrets, add file f…
Browse files Browse the repository at this point in the history
…older support

Signed-off-by: Suleiman Dibirov <[email protected]>
  • Loading branch information
idsulik committed Sep 16, 2024
1 parent 381d24b commit 2aa70c0
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 87 deletions.
6 changes: 4 additions & 2 deletions pkg/api/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ 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"
// ServiceConfigsHash stores configuration hash for a compose service configs
ServiceConfigsHash = "com.docker.compose.service.configs-hash"
// ServiceSecretsHash stores configuration hash for a compose service secrets
ServiceSecretsHash = "com.docker.compose.service.secrets-hash"
// 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
Expand Down
12 changes: 9 additions & 3 deletions pkg/compose/convergence.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,20 @@ func mustRecreate(project *types.Project, expected types.ServiceConfig, actual m
return true, nil
}

serviceDependenciesHash, err := ServiceDependenciesHash(project, expected)
serviceConfigsHash, err := ServiceConfigsHash(project, expected)
if err != nil {
return false, err
}

serviceDependenciesChanged := actual.Labels[api.ConfigHashDependenciesLabel] != serviceDependenciesHash
serviceSecretsHash, err := ServiceSecretsHash(project, expected)
if err != nil {
return false, err
}

serviceConfigsChanged := actual.Labels[api.ServiceConfigsHash] != serviceConfigsHash
serviceSecretsChanged := actual.Labels[api.ServiceSecretsHash] != serviceSecretsHash

return serviceDependenciesChanged, nil
return serviceConfigsChanged || serviceSecretsChanged, nil
}

func getContainerName(projectName string, service types.ServiceConfig, number int) string {
Expand Down
10 changes: 8 additions & 2 deletions pkg/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,13 +504,19 @@ func (s *composeService) prepareLabels(labels types.Labels, project *types.Proje
return nil, err
}

serviceDependenciesHash, err := ServiceDependenciesHash(project, service)
serviceConfigsHash, err := ServiceConfigsHash(project, service)
if err != nil {
return nil, err
}

serviceSecretsHash, err := ServiceSecretsHash(project, service)
if err != nil {
return nil, err
}

labels[api.ConfigHashLabel] = serviceHash
labels[api.ConfigHashDependenciesLabel] = serviceDependenciesHash
labels[api.ServiceConfigsHash] = serviceConfigsHash
labels[api.ServiceSecretsHash] = serviceSecretsHash
labels[api.ContainerNumberLabel] = strconv.Itoa(number)

var dependencies []string
Expand Down
127 changes: 113 additions & 14 deletions pkg/compose/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@
package compose

import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/pkg/utils"
"github.com/opencontainers/go-digest"
)

Expand All @@ -42,27 +47,121 @@ 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) {
// ServiceConfigsHash computes the configuration hash for service configs.
func ServiceConfigsHash(project *types.Project, serviceConfig types.ServiceConfig) (string, error) {
bytes := make([]byte, 0)
for _, serviceConfig := range o.Configs {
projectConfig, ok := project.Configs[serviceConfig.Source]
for _, config := range serviceConfig.Configs {
file := project.Configs[config.Source]
b, err := createTarForConfig(project, types.FileReferenceConfig(config), types.FileObjectConfig(file))

if err != nil {
return "", err
}

bytes = append(bytes, b.Bytes()...)
}

return digest.SHA256.FromBytes(bytes).Encoded(), nil
}

// ServiceSecretsHash computes the configuration hash for service secrets.
func ServiceSecretsHash(project *types.Project, serviceConfig types.ServiceConfig) (string, error) {
bytes := make([]byte, 0)
for _, config := range serviceConfig.Secrets {
file := project.Secrets[config.Source]
b, err := createTarForConfig(project, types.FileReferenceConfig(config), types.FileObjectConfig(file))

if err != nil {
return "", err
}

bytes = append(bytes, b.Bytes()...)
}

return digest.SHA256.FromBytes(bytes).Encoded(), nil
}

func createTarForConfig(
project *types.Project,
serviceConfig types.FileReferenceConfig,
file types.FileObjectConfig,
) (*bytes.Buffer, error) {
// fixed time to ensure the tarball is deterministic
modTime := time.Unix(0, 0)
content := make([]byte, 0)

if file.Content != "" {
content = []byte(file.Content)
} else if file.Environment != "" {
env, ok := project.Environment[file.Environment]
if !ok {
continue
return nil, fmt.Errorf(
"environment variable %q required by file %q is not set",
file.Environment,
file.Name,
)
}
content = []byte(env)
} else if file.File != "" {
var err error
content, err = readPathContent(file.File)

if projectConfig.Content != "" {
bytes = append(bytes, []byte(projectConfig.Content)...)
} else if projectConfig.File != "" {
content, err := os.ReadFile(projectConfig.File)
if err != nil {
return nil, err
}
}

if len(content) == 0 {
return nil, fmt.Errorf("config %q is empty", file.Name)
}

if serviceConfig.Target == "" {
serviceConfig.Target = "/" + serviceConfig.Source
}

b, err := utils.CreateTar(content, serviceConfig, modTime)
if err != nil {
return nil, err
}

return &b, nil
}

func readPathContent(path string) ([]byte, error) {
content := make([]byte, 0)

// Check if the path is a directory
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("error accessing path %q: %v", path, err)
}

if info.IsDir() {
// If it's a directory, read all files and concatenate their contents
err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return "", err
return err
}
if !info.IsDir() {
fileContent, err := os.ReadFile(path)
if err != nil {
return err
}
content = append(content, fileContent...)
}
bytes = append(bytes, content...)
} else if projectConfig.Environment != "" {
bytes = append(bytes, []byte(projectConfig.Environment)...)
return nil
})
if err != nil {
return nil, fmt.Errorf("error reading directory %q: %v", path, err)
}
} else {
// If it's a file, read its content
fileContent, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("error reading file %q: %v", path, err)
}
content = fileContent
}

return digest.SHA256.FromBytes(bytes).Encoded(), nil
return content, nil
}
105 changes: 89 additions & 16 deletions pkg/compose/hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,56 +39,124 @@ func TestServiceHashWithIgnorableValues(t *testing.T) {
assert.Equal(t, hash1, hash2)
}

func TestServiceDependenciesHashWithoutChangesContent(t *testing.T) {
hash1, err := ServiceDependenciesHash(projectConfig("myConfigSource", "a", "", ""), serviceConfig("myContext1", "always", 1))
func TestServiceConfigsHashWithoutChangesContent(t *testing.T) {
hash1, err := ServiceConfigsHash(projectWithConfigs("myConfigSource", "a", "", ""), serviceConfig("myContext1", "always", 1))
assert.NilError(t, err)
hash2, err := ServiceDependenciesHash(projectConfig("myConfigSource", "a", "", ""), serviceConfig("myContext2", "never", 2))
hash2, err := ServiceConfigsHash(projectWithConfigs("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))
func TestServiceConfigsHashWithChangedConfigContent(t *testing.T) {
hash1, err := ServiceConfigsHash(projectWithConfigs("myConfigSource", "a", "", ""), serviceConfig("myContext1", "always", 1))
assert.NilError(t, err)
hash2, err := ServiceDependenciesHash(projectConfig("myConfigSource", "b", "", ""), serviceConfig("myContext2", "never", 2))
hash2, err := ServiceConfigsHash(projectWithConfigs("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))
func TestServiceConfigsHashWithChangedConfigEnvironment(t *testing.T) {
hash1, err := ServiceConfigsHash(projectWithConfigs("myConfigSource", "", "a", ""), serviceConfig("myContext1", "always", 1))
assert.NilError(t, err)
hash2, err := ServiceDependenciesHash(projectConfig("myConfigSource", "", "b", ""), serviceConfig("myContext2", "never", 2))
hash2, err := ServiceConfigsHash(projectWithConfigs("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"),
func TestServiceConfigsHashWithChangedConfigFile(t *testing.T) {
hash1, err := ServiceConfigsHash(
projectWithConfigs("myConfigSource", "", "", "./testdata/config1.txt"),
serviceConfig("myContext1", "always", 1),
)
assert.NilError(t, err)
hash2, err := ServiceDependenciesHash(
projectConfig("myConfigSource", "", "", "./testdata/config2.txt"),
hash2, err := ServiceConfigsHash(
projectWithConfigs("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 {
func TestServiceSecretsHashWithoutChangesContent(t *testing.T) {
hash1, err := ServiceSecretsHash(projectWithSecrets("mySecretSource", "a", "", ""), serviceConfig("myContext1", "always", 1))
assert.NilError(t, err)
hash2, err := ServiceSecretsHash(projectWithSecrets("mySecretSource", "a", "", ""), serviceConfig("myContext2", "never", 2))
assert.NilError(t, err)
assert.Assert(t, hash1 == hash2)
}

func TestServiceSecretsHashWithChangedSecretContent(t *testing.T) {
hash1, err := ServiceSecretsHash(projectWithSecrets("mySecretSource", "a", "", ""), serviceConfig("myContext1", "always", 1))
assert.NilError(t, err)
hash2, err := ServiceSecretsHash(projectWithSecrets("mySecretSource", "b", "", ""), serviceConfig("myContext2", "never", 2))
assert.NilError(t, err)
assert.Assert(t, hash1 != hash2)
}

func TestServiceSecretsHashWithChangedSecretEnvironment(t *testing.T) {
hash1, err := ServiceSecretsHash(projectWithSecrets("mySecretSource", "", "a", ""), serviceConfig("myContext1", "always", 1))
assert.NilError(t, err)
hash2, err := ServiceSecretsHash(projectWithSecrets("mySecretSource", "", "b", ""), serviceConfig("myContext2", "never", 2))
assert.NilError(t, err)
assert.Assert(t, hash1 != hash2)
}

func TestServiceSecretsHashWithChangedSecretFile(t *testing.T) {
hash1, err := ServiceSecretsHash(
projectWithSecrets("mySecretSource", "", "", "./testdata/config1.txt"),
serviceConfig("myContext1", "always", 1),
)
assert.NilError(t, err)
hash2, err := ServiceSecretsHash(
projectWithSecrets("mySecretSource", "", "", "./testdata/config2.txt"),
serviceConfig("myContext2", "never", 2),
)
assert.NilError(t, err)
assert.Assert(t, hash1 != hash2)
}

func projectWithConfigs(configName, configContent, configEnvironmentValue, configFile string) *types.Project {
envName := "myEnv"

if configEnvironmentValue == "" {
envName = ""
}

return &types.Project{
Environment: types.Mapping{
envName: configEnvironmentValue,
},
Configs: types.Configs{
configName: types.ConfigObjConfig{
Content: configContent,
Environment: configEnvironment,
Environment: envName,
File: configFile,
},
},
}
}

func projectWithSecrets(secretName, secretContent, secretEnvironmentValue, secretFile string) *types.Project {
envName := "myEnv"

if secretEnvironmentValue == "" {
envName = ""
}

return &types.Project{
Environment: types.Mapping{
envName: secretEnvironmentValue,
},
Secrets: types.Secrets{
secretName: types.SecretConfig{
Content: secretContent,
Environment: envName,
File: secretFile,
},
},
}
}

func serviceConfig(buildContext, pullPolicy string, replicas int) types.ServiceConfig {
return types.ServiceConfig{
Build: &types.BuildConfig{
Expand All @@ -106,5 +174,10 @@ func serviceConfig(buildContext, pullPolicy string, replicas int) types.ServiceC
Source: "myConfigSource",
},
},
Secrets: []types.ServiceSecretConfig{
{
Source: "mySecretSource",
},
},
}
}
Loading

0 comments on commit 2aa70c0

Please sign in to comment.