From 13fb3118eece38b9fc93b478b6d0437cdbf127f5 Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:44:50 +1300 Subject: [PATCH] fix(edge/docker): add bind mount volume label for restarting the specific service [BE-12575] (#1821) --- api/edge/edge.go | 3 + pkg/libstack/compose/bind_mount_hash.go | 149 +++++++++++++++++++ pkg/libstack/compose/bind_mount_hash_test.go | 125 ++++++++++++++++ pkg/libstack/compose/composeplugin.go | 9 ++ pkg/libstack/libstack.go | 4 + 5 files changed, 290 insertions(+) create mode 100644 pkg/libstack/compose/bind_mount_hash.go create mode 100644 pkg/libstack/compose/bind_mount_hash_test.go diff --git a/api/edge/edge.go b/api/edge/edge.go index 696fa91346..a73d582797 100644 --- a/api/edge/edge.go +++ b/api/edge/edge.go @@ -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 diff --git a/pkg/libstack/compose/bind_mount_hash.go b/pkg/libstack/compose/bind_mount_hash.go new file mode 100644 index 0000000000..e49a637af7 --- /dev/null +++ b/pkg/libstack/compose/bind_mount_hash.go @@ -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 +} diff --git a/pkg/libstack/compose/bind_mount_hash_test.go b/pkg/libstack/compose/bind_mount_hash_test.go new file mode 100644 index 0000000000..8c501978dd --- /dev/null +++ b/pkg/libstack/compose/bind_mount_hash_test.go @@ -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]) + }) +} diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go index b6d9638a32..432818fa8c 100644 --- a/pkg/libstack/compose/composeplugin.go +++ b/pkg/libstack/compose/composeplugin.go @@ -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 } diff --git a/pkg/libstack/libstack.go b/pkg/libstack/libstack.go index e4125d3ca5..ea22123b0e 100644 --- a/pkg/libstack/libstack.go +++ b/pkg/libstack/libstack.go @@ -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 {