chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
package filesystem
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/logs"
)
// CopyPath copies file or directory defined by the path to the toDir path
func CopyPath(path string, toDir string) error {
info, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
// skip copy if file does not exist
return nil
} else if err != nil {
return err
}
if !info.IsDir() {
destination := filepath.Join(toDir, info.Name())
return copyFile(path, destination)
}
return CopyDir(path, toDir, true)
}
// CopyDir copies contents of fromDir to toDir.
// When keepParent is true, contents will be copied with their immediate parent dir,
// i.e. given /from/dirA and /to/dirB with keepParent == true, result will be /to/dirB/dirA/<children>
func CopyDir(fromDir, toDir string, keepParent bool) error {
cleanedSourcePath := filepath.Clean(fromDir)
parentDirectory := filepath.Dir(cleanedSourcePath)
err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
var destination string
if keepParent {
destination = filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory))
} else {
destination = filepath.Join(toDir, strings.TrimPrefix(path, cleanedSourcePath))
}
if destination == "" {
return nil
}
if info.IsDir() {
return nil // skip directory creations
}
if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink
return nil // don't copy symlinks
}
return copyFile(path, destination)
})
return err
}
// copies regular a file from src to dst
func copyFile(src, dst string) error {
from, err := os.Open(src)
if err != nil {
return err
}
defer logs.CloseAndLogErr(from)
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
to, err := os.Create(dst)
if err != nil {
return err
}
defer logs.CloseAndLogErr(to)
_, err = io.Copy(to, from)
return err
}
+111
View File
@@ -0,0 +1,111 @@
package filesystem
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
err := copyFile("does-not-exist", tmpdir)
require.Error(t, err)
}
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
content := []byte("content")
err := os.WriteFile(JoinPaths(tmpdir, "origin"), content, 0600)
require.NoError(t, err)
err = copyFile(JoinPaths(tmpdir, "origin"), JoinPaths(tmpdir, "copy"))
require.NoError(t, err)
copyContent, err := os.ReadFile(JoinPaths(tmpdir, "copy"))
require.NoError(t, err)
assert.Equal(t, content, copyContent)
}
func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
t.Parallel()
destination := t.TempDir()
err := CopyDir("./testdata/copy_test", destination, true)
require.NoError(t, err)
assert.FileExists(t, JoinPaths(destination, "copy_test", "outer"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
}
func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
t.Parallel()
destination := t.TempDir()
err := CopyDir("./testdata/copy_test", destination, false)
require.NoError(t, err)
assert.FileExists(t, JoinPaths(destination, "outer"))
assert.FileExists(t, JoinPaths(destination, "dir", ".dotfile"))
assert.FileExists(t, JoinPaths(destination, "dir", "inner"))
}
func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
err := CopyPath("does-not-exists", tmpdir)
require.NoError(t, err)
assert.NoFileExists(t, tmpdir)
}
func Test_CopyPath_shouldCopyFile(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
content := []byte("content")
err := os.WriteFile(JoinPaths(tmpdir, "file"), content, 0600)
require.NoError(t, err)
err = os.MkdirAll(JoinPaths(tmpdir, "backup"), 0700)
require.NoError(t, err)
err = CopyPath(JoinPaths(tmpdir, "file"), JoinPaths(tmpdir, "backup"))
require.NoError(t, err)
copyContent, err := os.ReadFile(JoinPaths(tmpdir, "backup", "file"))
require.NoError(t, err)
assert.Equal(t, content, copyContent)
}
func Test_CopyPath_shouldCopyDir(t *testing.T) {
t.Parallel()
destination := t.TempDir()
err := CopyPath("./testdata/copy_test", destination)
require.NoError(t, err)
assert.FileExists(t, JoinPaths(destination, "copy_test", "outer"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
}
func TestCopyPathPanic(t *testing.T) {
t.Parallel()
dir := t.TempDir()
p := JoinPaths(dir, "myfile")
err := os.WriteFile(p, []byte("contents"), 0644)
require.NoError(t, err)
err = os.Chmod(dir, 0)
require.NoError(t, err)
err = CopyPath(p, t.TempDir())
require.Error(t, err)
err = os.Chmod(dir, 0755)
require.NoError(t, err)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
package filesystem
import (
"fmt"
"math/rand"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
t.Parallel()
service := createService(t)
testHelperFileExists_fileExists(t, service.FileExists)
}
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
t.Parallel()
service := createService(t)
testHelperFileExists_fileNotExists(t, service.FileExists)
}
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
t.Parallel()
testHelperFileExists_fileExists(t, FileExists)
}
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
t.Parallel()
testHelperFileExists_fileNotExists(t, FileExists)
}
func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) {
file, err := os.CreateTemp(t.TempDir(), t.Name())
require.NoError(t, err, "CreateTemp should not fail")
t.Cleanup(func() {
err := os.RemoveAll(file.Name())
require.NoError(t, err)
})
exists, err := checker(file.Name())
require.NoError(t, err, "FileExists should not fail")
assert.True(t, exists)
}
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
filePath := JoinPaths(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
err := os.RemoveAll(filePath)
require.NoError(t, err, "RemoveAll should not fail")
exists, err := checker(filePath)
require.NoError(t, err, "FileExists should not fail")
assert.False(t, exists)
}
+71
View File
@@ -0,0 +1,71 @@
package filesystem
import "testing"
func TestJoinPaths(t *testing.T) {
t.Parallel()
var ts = []struct {
trusted string
untrusted string
expected string
}{
{"", "", "."},
{"", ".", "."},
{"", "d/e/f", "d/e/f"},
{"", "./d/e/f", "d/e/f"},
{"", "../d/e/f", "d/e/f"},
{"", "/d/e/f", "d/e/f"},
{"", "../../../etc/shadow", "etc/shadow"},
{".", "", "."},
{".", ".", "."},
{".", "d/e/f", "d/e/f"},
{".", "./d/e/f", "d/e/f"},
{".", "../d/e/f", "d/e/f"},
{".", "/d/e/f", "d/e/f"},
{".", "../../../etc/shadow", "etc/shadow"},
{"./", "", "."},
{"./", ".", "."},
{"./", "d/e/f", "d/e/f"},
{"./", "./d/e/f", "d/e/f"},
{"./", "../d/e/f", "d/e/f"},
{"./", "/d/e/f", "d/e/f"},
{"./", "../../../etc/shadow", "etc/shadow"},
{"a/b/c", "", "a/b/c"},
{"a/b/c", ".", "a/b/c"},
{"a/b/c", "d/e/f", "a/b/c/d/e/f"},
{"a/b/c", "./d/e/f", "a/b/c/d/e/f"},
{"a/b/c", "../d/e/f", "a/b/c/d/e/f"},
{"a/b/c", "../../../etc/shadow", "a/b/c/etc/shadow"},
{"/a/b/c", "", "/a/b/c"},
{"/a/b/c", ".", "/a/b/c"},
{"/a/b/c", "d/e/f", "/a/b/c/d/e/f"},
{"/a/b/c", "./d/e/f", "/a/b/c/d/e/f"},
{"/a/b/c", "../d/e/f", "/a/b/c/d/e/f"},
{"/a/b/c", "../../../etc/shadow", "/a/b/c/etc/shadow"},
{"./a/b/c", "", "a/b/c"},
{"./a/b/c", ".", "a/b/c"},
{"./a/b/c", "d/e/f", "a/b/c/d/e/f"},
{"./a/b/c", "./d/e/f", "a/b/c/d/e/f"},
{"./a/b/c", "../d/e/f", "a/b/c/d/e/f"},
{"./a/b/c", "../../../etc/shadow", "a/b/c/etc/shadow"},
{"../a/b/c", "", "../a/b/c"},
{"../a/b/c", ".", "../a/b/c"},
{"../a/b/c", "d/e/f", "../a/b/c/d/e/f"},
{"../a/b/c", "./d/e/f", "../a/b/c/d/e/f"},
{"../a/b/c", "../d/e/f", "../a/b/c/d/e/f"},
{"../a/b/c", "../../../etc/shadow", "../a/b/c/etc/shadow"},
}
for _, c := range ts {
r := JoinPaths(c.trusted, c.untrusted)
if r != c.expected {
t.Fatalf("expected '%s', got '%s'. Inputs = '%s', '%s'", c.expected, r, c.trusted, c.untrusted)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package filesystem
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var content = []byte("content")
func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
t.Parallel()
sourceDir := "missing"
destinationDir := t.TempDir()
file1 := addFile(t, destinationDir, "dir", "file")
file2 := addFile(t, destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, false)
require.Error(t, err, "move directory should fail when source path is missing")
assert.FileExists(t, file1, "destination dir contents should remain")
assert.FileExists(t, file2, "destination dir contents should remain")
}
func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
t.Parallel()
sourceDir := t.TempDir()
file1 := addFile(t, sourceDir, "dir", "file")
file2 := addFile(t, sourceDir, "file")
destinationDir := t.TempDir()
file3 := addFile(t, destinationDir, "dir", "file")
file4 := addFile(t, destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, false)
require.Error(t, err, "move directory should fail when destination directory already exists")
assert.FileExists(t, file1, "source dir contents should remain")
assert.FileExists(t, file2, "source dir contents should remain")
assert.FileExists(t, file3, "destination dir contents should remain")
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
t.Parallel()
sourceDir := t.TempDir()
file1 := addFile(t, sourceDir, "dir", "file")
file2 := addFile(t, sourceDir, "file")
destinationDir := t.TempDir()
file3 := addFile(t, destinationDir, "dir", "file")
file4 := addFile(t, destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, true)
require.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")
assert.FileExists(t, file3, "destination dir contents should remain")
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
sourceDir := JoinPaths(tmp, "source")
err := os.Mkdir(sourceDir, 0766)
require.NoError(t, err)
file1 := addFile(t, sourceDir, "dir", "file")
file2 := addFile(t, sourceDir, "file")
destinationDir := JoinPaths(tmp, "destination")
err = MoveDirectory(sourceDir, destinationDir, false)
require.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")
assertFileContent(t, JoinPaths(destinationDir, "file"))
assertFileContent(t, JoinPaths(destinationDir, "dir", "file"))
}
func addFile(t *testing.T, fileParts ...string) (filepath string) {
if len(fileParts) > 2 {
dir := JoinPaths(fileParts[0], fileParts[1:len(fileParts)-1]...)
err := os.MkdirAll(dir, 0766)
require.NoError(t, err)
}
p := JoinPaths(fileParts[0], fileParts[1:]...)
err := os.WriteFile(p, content, 0766)
require.NoError(t, err)
return p
}
func assertFileContent(t *testing.T, filePath string) {
actualContent, err := os.ReadFile(filePath)
require.NoError(t, err, "failed to read file %s", filePath)
assert.Equal(t, content, actualContent, "file %s content doesn't match", filePath)
}
+22
View File
@@ -0,0 +1,22 @@
package filesystem
import (
"os"
"testing"
"github.com/stretchr/testify/require"
)
func createService(t *testing.T) *Service {
dataStorePath := JoinPaths(t.TempDir(), t.Name())
service, err := NewService(dataStorePath, "")
require.NoError(t, err, "NewService should not fail")
t.Cleanup(func() {
err := os.RemoveAll(dataStorePath)
require.NoError(t, err)
})
return service
}
+121
View File
@@ -0,0 +1,121 @@
package filesystem
import "testing"
func TestJoinPaths(t *testing.T) {
t.Parallel()
var ts = []struct {
trusted string
untrusted string
expected string
}{
{"", "", "."},
{"", ".", "."},
{"", "d/e/f", `d\e\f`},
{"", "./d/e/f", `d\e\f`},
{"", "../d/e/f", `d\e\f`},
{"", "/d/e/f", `d\e\f`},
{"", "../../../windows/system.ini", `windows\system.ini`},
{"", `C:\windows\system.ini`, `windows\system.ini`},
{"", `..\..\..\..\C:\windows\system.ini`, `windows\system.ini`},
{"", `\\server\a\b\c`, `server\a\b\c`},
{"", `..\..\..\..\\server\a\b\c`, `server\a\b\c`},
{".", "", "."},
{".", ".", "."},
{".", "d/e/f", `d\e\f`},
{".", "./d/e/f", `d\e\f`},
{".", "../d/e/f", `d\e\f`},
{".", "/d/e/f", `d\e\f`},
{".", "../../../windows/system.ini", `windows\system.ini`},
{".", `C:\windows\system.ini`, `windows\system.ini`},
{".", `..\..\..\..\C:\windows\system.ini`, `windows\system.ini`},
{".", `\\server\a\b\c`, `server\a\b\c`},
{".", `..\..\..\..\\server\a\b\c`, `server\a\b\c`},
{"./", "", "."},
{"./", ".", "."},
{"./", "d/e/f", `d\e\f`},
{"./", "./d/e/f", `d\e\f`},
{"./", "../d/e/f", `d\e\f`},
{"./", "/d/e/f", `d\e\f`},
{"./", "../../../windows/system.ini", `windows\system.ini`},
{"./", `C:\windows\system.ini`, `windows\system.ini`},
{"./", `..\..\..\..\C:\windows\system.ini`, `windows\system.ini`},
{"./", `\\server\a\b\c`, `server\a\b\c`},
{"./", `..\..\..\..\\server\a\b\c`, `server\a\b\c`},
{"a/b/c", "", `a\b\c`},
{"a/b/c", ".", `a\b\c`},
{"a/b/c", "d/e/f", `a\b\c\d\e\f`},
{"a/b/c", "./d/e/f", `a\b\c\d\e\f`},
{"a/b/c", "../d/e/f", `a\b\c\d\e\f`},
{"a/b/c", "../../../windows/system.ini", `a\b\c\windows\system.ini`},
{"a/b/c", `C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`},
{"a/b/c", `..\..\..\..\C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`},
{"a/b/c", `\\server\a\b\c`, `a\b\c\server\a\b\c`},
{"a/b/c", `..\..\..\..\\server\a\b\c`, `a\b\c\server\a\b\c`},
{"/a/b/c", "", `\a\b\c`},
{"/a/b/c", ".", `\a\b\c`},
{"/a/b/c", "d/e/f", `\a\b\c\d\e\f`},
{"/a/b/c", "./d/e/f", `\a\b\c\d\e\f`},
{"/a/b/c", "../d/e/f", `\a\b\c\d\e\f`},
{"/a/b/c", "../../../windows/system.ini", `\a\b\c\windows\system.ini`},
{"/a/b/c", `C:\windows\system.ini`, `\a\b\c\C:\windows\system.ini`},
{"/a/b/c", `..\..\..\..\C:\windows\system.ini`, `\a\b\c\C:\windows\system.ini`},
{"/a/b/c", `\\server\a\b\c`, `\a\b\c\server\a\b\c`},
{"/a/b/c", `..\..\..\..\\server\a\b\c`, `\a\b\c\server\a\b\c`},
{"./a/b/c", "", `a\b\c`},
{"./a/b/c", ".", `a\b\c`},
{"./a/b/c", "d/e/f", `a\b\c\d\e\f`},
{"./a/b/c", "./d/e/f", `a\b\c\d\e\f`},
{"./a/b/c", "../d/e/f", `a\b\c\d\e\f`},
{"./a/b/c", "../../../windows/system.ini", `a\b\c\windows\system.ini`},
{"./a/b/c", `C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`},
{"./a/b/c", `..\..\..\..\C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`},
{"./a/b/c", `\\server\a\b\c`, `a\b\c\server\a\b\c`},
{"./a/b/c", `..\..\..\..\\server\a\b\c`, `a\b\c\server\a\b\c`},
{"../a/b/c", "", `..\a\b\c`},
{"../a/b/c", ".", `..\a\b\c`},
{"../a/b/c", "d/e/f", `..\a\b\c\d\e\f`},
{"../a/b/c", "./d/e/f", `..\a\b\c\d\e\f`},
{"../a/b/c", "../d/e/f", `..\a\b\c\d\e\f`},
{"../a/b/c", "../../../windows/system.ini", `..\a\b\c\windows\system.ini`},
{"../a/b/c", `C:\windows\system.ini`, `..\a\b\c\C:\windows\system.ini`},
{"../a/b/c", `..\..\..\..\C:\windows\system.ini`, `..\a\b\c\C:\windows\system.ini`},
{"../a/b/c", `\\server\a\b\c`, `..\a\b\c\server\a\b\c`},
{"../a/b/c", `..\..\..\..\\server\a\b\c`, `..\a\b\c\server\a\b\c`},
{"C:/a/b/c", "", `C:\a\b\c`},
{"C:/a/b/c", ".", `C:\a\b\c`},
{"C:/a/b/c", "d/e/f", `C:\a\b\c\d\e\f`},
{"C:/a/b/c", "./d/e/f", `C:\a\b\c\d\e\f`},
{"C:/a/b/c", "../d/e/f", `C:\a\b\c\d\e\f`},
{"C:/a/b/c", "../../../windows/system.ini", `C:\a\b\c\windows\system.ini`},
{"C:/a/b/c", `C:\windows\system.ini`, `C:\a\b\c\C:\windows\system.ini`},
{"C:/a/b/c", `..\..\..\..\C:\windows\system.ini`, `C:\a\b\c\C:\windows\system.ini`},
{"C:/a/b/c", `\\server\a\b\c`, `C:\a\b\c\server\a\b\c`},
{"C:/a/b/c", `..\..\..\..\\server\a\b\c`, `C:\a\b\c\server\a\b\c`},
{`\\server\a\b\c`, "", `\\server\a\b\c`},
{`\\server\a\b\c`, ".", `\\server\a\b\c`},
{`\\server\a\b\c`, "d/e/f", `\\server\a\b\c\d\e\f`},
{`\\server\a\b\c`, "./d/e/f", `\\server\a\b\c\d\e\f`},
{`\\server\a\b\c`, "../d/e/f", `\\server\a\b\c\d\e\f`},
{`\\server\a\b\c`, "../../../windows/system.ini", `\\server\a\b\c\windows\system.ini`},
{`\\server\a\b\c`, `C:\windows\system.ini`, `\\server\a\b\c\C:\windows\system.ini`},
{`\\server\a\b\c`, `..\..\..\C:\windows\system.ini`, `\\server\a\b\c\C:\windows\system.ini`},
{`\\server\a\b\c`, `\\server\a\b\c`, `\\server\a\b\c\server\a\b\c`},
{`\\server\a\b\c`, `..\..\..\\server\a\b\c`, `\\server\a\b\c\server\a\b\c`},
}
for _, c := range ts {
r := JoinPaths(c.trusted, c.untrusted)
if r != c.expected {
t.Fatalf("expected '%s', got '%s'. Inputs = '%s', '%s'", c.expected, r, c.trusted, c.untrusted)
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package filesystem
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/mod/semver"
)
type DirEntry struct {
Name string
Content string
IsFile bool
Permissions os.FileMode `swaggertype:"integer"`
}
// FilterDirForEntryFile filers the given dirEntries, returns entries of the entryFile and .env file
func FilterDirForEntryFile(dirEntries []DirEntry, entryFile string) []DirEntry {
var filteredDirEntries []DirEntry
dotEnvFile := filepath.Join(filepath.Dir(entryFile), ".env")
filters := []string{entryFile, dotEnvFile}
for _, dirEntry := range dirEntries {
match := false
if dirEntry.IsFile {
if slices.Contains(filters, dirEntry.Name) {
match = true
}
} else {
for _, filter := range filters {
if strings.HasPrefix(filter, dirEntry.Name) {
match = true
break
}
}
}
if match {
filteredDirEntries = append(filteredDirEntries, dirEntry)
}
}
return filteredDirEntries
}
// FilterDirForCompatibility returns the content of the entry file if agent version is less than 2.19.0
func FilterDirForCompatibility(dirEntries []DirEntry, entryFilePath, agentVersion string) (string, error) {
if semver.Compare("v"+agentVersion, "v2.19.0") == -1 {
for _, dirEntry := range dirEntries {
if dirEntry.IsFile {
if dirEntry.Name == entryFilePath {
return DecodeFileContent(dirEntry.Content)
}
}
}
return "", fmt.Errorf("Entry file %s not found in dir entries", entryFilePath)
}
return "", nil
}
// LoadDir reads all files and folders recursively from the given directory
// File content is base64-encoded
func LoadDir(dir string) ([]DirEntry, error) {
var dirEntries []DirEntry
err := filepath.WalkDir(
dir,
func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
fileInfo, err := d.Info()
if err != nil {
return err
}
relativePath, err := filepath.Rel(dir, path)
if err != nil {
return err
}
if relativePath == "." {
return nil
}
dirEntry := DirEntry{
Name: relativePath,
Permissions: fileInfo.Mode().Perm(),
}
if !fileInfo.IsDir() {
// Read file contents
fileContent, err := os.ReadFile(path)
if err != nil {
return err
}
dirEntry.Content = base64.StdEncoding.EncodeToString(fileContent)
dirEntry.IsFile = true
}
dirEntries = append(dirEntries, dirEntry)
return nil
})
if err != nil {
return nil, err
}
return dirEntries, nil
}
// PersistDir writes the provided array of files and folders back to the given directory.
func PersistDir(dir string, dirEntries []DirEntry) error {
for _, dirEntry := range dirEntries {
path := filepath.Join(dir, dirEntry.Name)
if dirEntry.IsFile {
// Create the directory path if it doesn't exist
err := os.MkdirAll(filepath.Dir(path), 0744)
if err != nil {
return err
}
// Write file contents
err = os.WriteFile(path, []byte(dirEntry.Content), dirEntry.Permissions)
if err != nil {
return err
}
} else {
// Create the directory
err := os.MkdirAll(path, dirEntry.Permissions)
if err != nil {
return err
}
}
}
return nil
}
func DecodeFileContent(encodedFileContent string) (string, error) {
decodedBytes, err := base64.StdEncoding.DecodeString(encodedFileContent)
if err != nil {
return "", err
}
return string(decodedBytes), nil
}
func DecodeDirEntries(dirEntries []DirEntry) error {
for index, dirEntry := range dirEntries {
if dirEntry.IsFile && dirEntry.Content != "" {
decodedFile, err := DecodeFileContent(dirEntry.Content)
if err != nil {
return err
}
dirEntries[index].Content = decodedFile
}
}
return nil
}
// GetDirEntriesByFilenames returns the dir entries that are files and match the provided filenames
func GetDirEntriesByFilenames(dirEntries []DirEntry, names []string) []DirEntry {
var filteredDirEntries []DirEntry
for _, dirEntry := range dirEntries {
if !dirEntry.IsFile {
continue
}
for _, name := range names {
if dirEntry.Name == name {
filteredDirEntries = append(filteredDirEntries, dirEntry)
}
}
}
return filteredDirEntries
}
+156
View File
@@ -0,0 +1,156 @@
package filesystem
import (
"fmt"
"os"
"path/filepath"
"strings"
portainer "github.com/portainer/portainer/api"
)
type MultiFilterArgs []struct {
FilterKey string
FilterType portainer.PerDevConfigsFilterType
}
// MultiFilterDirForPerDevConfigs filers the given dirEntries with multiple filter args, returns the merged entries for the given device
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
var envFiles []string
for _, multiFilterArg := range multiFilterArgs {
tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
filteredDirEntries = append(filteredDirEntries, tmp...)
envFiles = append(envFiles, efs...)
}
return deduplicate(filteredDirEntries), envFiles
}
// MultiFilterDirForPerDevConfigsWithDefaults filers the given dirEntries with multiple filter args, returns the merged entries for the given device
// and always includes the defaultFilenames
func MultiFilterDirForPerDevConfigsWithDefaults(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string) ([]DirEntry, []string) {
filteredDirEntries, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
// Add files that should always be included
// e.g. entrypoint files
defaultDirEntries := GetDirEntriesByFilenames(dirEntries, defaultFilenames)
filteredDirEntries = append(filteredDirEntries, defaultDirEntries...)
return deduplicate(filteredDirEntries), envFiles
}
func deduplicate(dirEntries []DirEntry) []DirEntry {
var deduplicatedDirEntries []DirEntry
marks := make(map[string]struct{})
for _, dirEntry := range dirEntries {
if _, ok := marks[dirEntry.Name]; !ok {
marks[dirEntry.Name] = struct{}{}
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
}
}
return deduplicatedDirEntries
}
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
// For given configPath A/B/C, return entries:
// 1. all entries outside of dir A/B/C
// 2. For filterType file:
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
// 3. For filterType dir:
// dir entry: A/B/C/<deviceName>
// all entries: A/B/C/<deviceName>/*
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
var envFiles []string
for _, dirEntry := range dirEntries {
if shouldIncludeEntry(dirEntry, deviceName, configPath, filterType) {
filteredDirEntries = append(filteredDirEntries, dirEntry)
if shouldParseEnvVars(dirEntry, deviceName, configPath, filterType) {
envFiles = append(envFiles, dirEntry.Name)
}
}
}
return filteredDirEntries, envFiles
}
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
// Include all entries outside of dir A
if !isInConfigDir(dirEntry, configPath) {
return true
}
if filterType == portainer.PerDevConfigsTypeFile {
// Include file entries A/B/C/<deviceName> or A/B/C/<deviceName>.*
return shouldIncludeFile(dirEntry, deviceName, configPath)
}
if filterType == portainer.PerDevConfigsTypeDir {
// Include:
// dir entry A/B/C/<deviceName>
// all entries A/B/C/<deviceName>/*
return shouldIncludeDir(dirEntry, deviceName, configPath)
}
return false
}
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
// return true if entry name starts with "A/B"
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
}
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
if !dirEntry.IsFile {
return false
}
// example: A/B/C/<deviceName>
filterEqual := filepath.Join(configPath, deviceName)
// example: A/B/C/<deviceName>/
filterPrefix := filterEqual + "."
// include file entries: A/B/C/<deviceName> or A/B/C/<deviceName>.*
return dirEntry.Name == filterEqual || strings.HasPrefix(dirEntry.Name, filterPrefix)
}
func shouldIncludeDir(dirEntry DirEntry, deviceName, configPath string) bool {
// example: A/B/C/'/<deviceName>
filterEqual := filepath.Join(configPath, deviceName)
// example: A/B/C/<deviceName>/
filterPrefix := appendTailSeparator(filterEqual)
// include dir entry: A/B/C/<deviceName>
if !dirEntry.IsFile && dirEntry.Name == filterEqual {
return true
}
// include all entries A/B/C/<deviceName>/*
return strings.HasPrefix(dirEntry.Name, filterPrefix)
}
func shouldParseEnvVars(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
if !dirEntry.IsFile {
return false
}
return isInConfigDir(dirEntry, configPath) &&
filepath.Base(dirEntry.Name) == deviceName+".env"
}
func appendTailSeparator(path string) string {
return fmt.Sprintf("%s%c", path, os.PathSeparator)
}
@@ -0,0 +1,242 @@
package filesystem
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
t.Parallel()
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
t.Helper()
dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
require.Equal(t, wantDirEntries, dirEntries)
}
baseDirEntries := []DirEntry{
{".env", "", true, 420},
{"docker-compose.yaml", "", true, 420},
{"configs", "", false, 420},
{"configs/file1.conf", "", true, 420},
{"configs/file2.conf", "", true, 420},
{"configs/folder1", "", false, 420},
{"configs/folder1/config1", "", true, 420},
{"configs/folder2", "", false, 420},
{"configs/folder2/config2", "", true, 420},
}
// Filter file1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
)
// Filter folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"folder1", portainer.PerDevConfigsTypeDir},
},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and file2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
)
// Filter folder1 and folder2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
)
}
func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
t.Parallel()
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string, wantDirEntries []DirEntry) {
t.Helper()
dirEntries, _ = MultiFilterDirForPerDevConfigsWithDefaults(dirEntries, configPath, multiFilterArgs, defaultFilenames)
require.Equal(t, wantDirEntries, dirEntries)
}
baseDirEntries := []DirEntry{
{".env", "", true, 420},
{"docker-compose.yaml", "", true, 420},
{"configs", "", false, 420},
{"configs/file1.conf", "", true, 420},
{"configs/file2.conf", "", true, 420},
{"configs/folder1", "", false, 420},
{"configs/folder1/config1", "", true, 420},
{"configs/folder2", "", false, 420},
{"configs/folder2/config2", "", true, 420},
{"configs/docker-compose-2.yaml", "", true, 420},
{"configs/folder2/docker-compose-3.yaml", "", true, 420},
}
// Filter file1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
)
// Filter folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"folder1", portainer.PerDevConfigsTypeDir},
},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and file2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
)
// Filter folder1 and folder2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8], baseDirEntries[10]},
)
// Filter file1 and folder1 and docker-compose-2.yaml
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"folder1", portainer.PerDevConfigsTypeDir},
},
[]string{"configs/docker-compose-2.yaml"},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6], baseDirEntries[9]},
)
// Filter file1 and docker-compose-3.yaml
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
},
[]string{"configs/folder2/docker-compose-3.yaml"},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[10]},
)
}
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
t.Parallel()
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
t.Helper()
_, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
require.Equal(t, wantEnvFiles, envFiles)
}
baseDirEntries := []DirEntry{
{".env", "", true, 420},
{"docker-compose.yaml", "", true, 420},
{"configs", "", false, 420},
{"configs/edge-id/edge-id.env", "", true, 420},
}
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}},
[]string{"configs/edge-id/edge-id.env"},
)
}
func TestIsInConfigDir(t *testing.T) {
t.Parallel()
f := func(dirEntry DirEntry, configPath string, expect bool) {
t.Helper()
actual := isInConfigDir(dirEntry, configPath)
assert.Equal(t, expect, actual)
}
f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
}
func TestShouldIncludeDir(t *testing.T) {
t.Parallel()
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
t.Helper()
actual := shouldIncludeDir(dirEntry, deviceName, configPath)
assert.Equal(t, expect, actual)
}
f(DirEntry{Name: "app/blue-app", IsFile: false}, "blue-app", "app", true)
f(DirEntry{Name: "app/blue-app/values.yaml", IsFile: true}, "blue-app", "app", true)
}
+1
View File
@@ -0,0 +1 @@
content
+1
View File
@@ -0,0 +1 @@
content
+1
View File
@@ -0,0 +1 @@
content
+25
View File
@@ -0,0 +1,25 @@
package filesystem
import (
"os"
"path/filepath"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/logs"
)
// WriteToFile creates a file in the filesystem storage
func WriteToFile(dst string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
return errors.Wrapf(err, "failed to create filestructure for the path %q", dst)
}
file, err := os.Create(dst)
if err != nil {
return errors.Wrapf(err, "failed to open a file %q", dst)
}
defer logs.CloseAndLogErr(file)
_, err = file.Write(content)
return errors.Wrapf(err, "failed to write a file %q", dst)
}
+51
View File
@@ -0,0 +1,51 @@
package filesystem
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpFilePath := JoinPaths(tmpDir, "dummy")
content := []byte("content")
err := WriteToFile(tmpFilePath, content)
require.NoError(t, err)
fileContent, _ := os.ReadFile(tmpFilePath)
assert.Equal(t, content, fileContent)
}
func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpFilePath := JoinPaths(tmpDir, "dummy")
err := WriteToFile(tmpFilePath, []byte("content"))
require.NoError(t, err)
content := []byte("new content")
err = WriteToFile(tmpFilePath, content)
require.NoError(t, err)
fileContent, _ := os.ReadFile(tmpFilePath)
assert.Equal(t, content, fileContent)
}
func Test_WriteFile_CanWriteANestedPath(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpFilePath := JoinPaths(tmpDir, "dir", "sub-dir", "dummy")
content := []byte("content")
err := WriteToFile(tmpFilePath, content)
require.NoError(t, err)
fileContent, _ := os.ReadFile(tmpFilePath)
assert.Equal(t, content, fileContent)
}