mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
fix(edge/docker): add bind mount volume label for restarting the specific service [BE-12575] (#1821)
This commit is contained in:
@@ -54,6 +54,9 @@ type (
|
||||
// Used only for EE
|
||||
AlwaysCloneGitRepoForRelativePath bool
|
||||
|
||||
// Whether the edge stack supports per device configs
|
||||
SupportPerDeviceConfigs bool
|
||||
|
||||
// Mount point for relative path
|
||||
FilesystemPath string
|
||||
// Used only for EE
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const BindMountHashLabelKey = "io.portainer.bind-mount-hash"
|
||||
|
||||
func addBindMountHashLabel(name string, s types.ServiceConfig) (types.ServiceConfig, error) {
|
||||
hashes := []string{}
|
||||
|
||||
for _, volume := range s.Volumes {
|
||||
// Calculate hash for bind mounts only for now
|
||||
if volume.Type != "bind" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate hash for volume.Source, volume.Source can be a file or dir
|
||||
// and volume.Source is already an absolute path so we can hash it directly
|
||||
hash, err := pathHash(volume.Source)
|
||||
if err != nil {
|
||||
// If we fail to calculate the hash for this bind mount, skip it and continue
|
||||
log.Debug().Err(err).
|
||||
Str("bind_mount_source", volume.Source).
|
||||
Str("service", name).
|
||||
Msg("failed to calculate hash for bind mount, skipping this bind mount from hash label calculation")
|
||||
continue
|
||||
}
|
||||
|
||||
if hash != "" {
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hashes) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Sort hashes to ensure deterministic output
|
||||
sort.Strings(hashes)
|
||||
|
||||
// Final hash of the combined hashes
|
||||
finalH := sha256.New()
|
||||
for _, h := range hashes {
|
||||
finalH.Write([]byte(h))
|
||||
}
|
||||
|
||||
value := hex.EncodeToString(finalH.Sum(nil))
|
||||
|
||||
if s.Labels == nil {
|
||||
s.Labels = make(map[string]string)
|
||||
}
|
||||
s.Labels[BindMountHashLabelKey] = value
|
||||
|
||||
log.Debug().Str("service", name).
|
||||
Str("label_key", BindMountHashLabelKey).
|
||||
Str("bind_mount_hash", value).
|
||||
Msg("Calculated bind mount hash for service")
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// pathHash calculates a SHA-256 hash for a file or a directory.
|
||||
func pathHash(path string) (string, error) {
|
||||
hash := sha256.New()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
// It's a single file
|
||||
return hashFile(path)
|
||||
}
|
||||
|
||||
// It's a directory: we must collect and sort all files for determinism
|
||||
var files []string
|
||||
if err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, f := range files {
|
||||
// Include the relative path in the hash so that renames and moves within
|
||||
// the directory change the hash even when file contents stay the same.
|
||||
relPath, err := filepath.Rel(path, f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := hash.Write([]byte(relPath)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Stream the file content into the same hasher
|
||||
if err := copyFileToHash(hash, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hashFile(path string) (string, error) {
|
||||
h := sha256.New()
|
||||
if err := copyFileToHash(h, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// copyFileToHash opens the file at path, streams its content into w, and closes it.
|
||||
// If the copy fails, the close error is logged but the copy error is returned.
|
||||
// If the copy succeeds but close fails, the close error is returned.
|
||||
func copyFileToHash(w io.Writer, path string) (err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
log.Debug().Err(cerr).
|
||||
Str("filename", path).
|
||||
Msg("error closing file after hash")
|
||||
if err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPathHash_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "file.txt")
|
||||
|
||||
require.NoError(t, os.WriteFile(path, []byte("hello"), 0644))
|
||||
|
||||
h1, err := pathHash(path)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, h1)
|
||||
|
||||
// Same content, same hash
|
||||
h2, err := pathHash(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, h1, h2)
|
||||
|
||||
// Different content, different hash
|
||||
require.NoError(t, os.WriteFile(path, []byte("world"), 0644))
|
||||
h3, err := pathHash(path)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, h1, h3)
|
||||
}
|
||||
|
||||
func TestPathHash_Directory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bbb"), 0644))
|
||||
|
||||
h1, err := pathHash(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Same directory -> same hash
|
||||
h2, err := pathHash(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, h1, h2)
|
||||
|
||||
// Rename a file -> different hash (relative path is part of the hash)
|
||||
require.NoError(t, os.Rename(filepath.Join(dir, "a.txt"), filepath.Join(dir, "c.txt")))
|
||||
h3, err := pathHash(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, h1, h3, "renaming a file should change the directory hash")
|
||||
|
||||
// Restore and change content -> different hash
|
||||
require.NoError(t, os.Rename(filepath.Join(dir, "c.txt"), filepath.Join(dir, "a.txt")))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("modified"), 0644))
|
||||
h4, err := pathHash(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, h1, h4, "changing file content should change the directory hash")
|
||||
}
|
||||
|
||||
func TestAddBindMountHashLabel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
webDir := filepath.Join(dir, "web")
|
||||
require.NoError(t, os.MkdirAll(webDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "nginx.conf"), []byte("server {}"), 0644))
|
||||
|
||||
t.Run("no bind mounts", func(t *testing.T) {
|
||||
svc := types.ServiceConfig{Name: "web"}
|
||||
result, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result.Labels[BindMountHashLabelKey])
|
||||
})
|
||||
|
||||
t.Run("non-bind volume is skipped", func(t *testing.T) {
|
||||
svc := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Volumes: []types.ServiceVolumeConfig{{Type: "volume", Source: "myvolume"}},
|
||||
}
|
||||
result, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result.Labels[BindMountHashLabelKey])
|
||||
})
|
||||
|
||||
t.Run("missing path silently skips label", func(t *testing.T) {
|
||||
svc := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: "/nonexistent/path"}},
|
||||
}
|
||||
result, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result.Labels[BindMountHashLabelKey])
|
||||
})
|
||||
|
||||
t.Run("valid bind mount with directory source sets label", func(t *testing.T) {
|
||||
svc := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: webDir}},
|
||||
}
|
||||
result, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Labels[BindMountHashLabelKey])
|
||||
})
|
||||
|
||||
t.Run("valid bind mount with file source sets label", func(t *testing.T) {
|
||||
svc := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: filepath.Join(webDir, "nginx.conf")}},
|
||||
}
|
||||
result, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Labels[BindMountHashLabelKey])
|
||||
})
|
||||
|
||||
t.Run("label is deterministic", func(t *testing.T) {
|
||||
svc := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: webDir}},
|
||||
}
|
||||
r1, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
r2, err := addBindMountHashLabel("web", svc)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, r1.Labels[BindMountHashLabelKey], r2.Labels[BindMountHashLabelKey])
|
||||
})
|
||||
}
|
||||
@@ -408,5 +408,14 @@ func createProject(ctx context.Context, configFilepaths []string, options libsta
|
||||
return nil, fmt.Errorf("failed to resolve services environment: %w", err)
|
||||
}
|
||||
|
||||
if options.BindMountHashEnabled {
|
||||
// Set per-service label for bind mount hashes under each service
|
||||
if project, err = project.WithServicesTransform(addBindMountHashLabel); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Msg("Failed to set bind mount hash labels, proceeding without them. Stack updates may not be detected when bind-mounted files change")
|
||||
}
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ type Options struct {
|
||||
// ConfigOptions is a list of options to pass to the docker-compose config command
|
||||
ConfigOptions []string
|
||||
Registries []configtypes.AuthConfig
|
||||
// BindMountHashEnabled controls whether bind mount hash labels are set for services.
|
||||
// This option is primarily used by libstack internals and advanced callers that need
|
||||
// to manage bind mount hash behavior explicitly. This is not an option that users can set.
|
||||
BindMountHashEnabled bool
|
||||
}
|
||||
|
||||
type DeployOptions struct {
|
||||
|
||||
Reference in New Issue
Block a user