mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-05-06 14:03:40 +08:00
enhance: config restore with mount point handling #1419
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/internal/nginx"
|
||||
"github.com/0xJacky/Nginx-UI/settings"
|
||||
@@ -345,9 +346,9 @@ func verifyHashes(restoreDir, nginxUIZipPath, nginxZipPath string) (bool, error)
|
||||
// parseHashInfo parses hash info from content string
|
||||
func parseHashInfo(content string) HashInfo {
|
||||
info := HashInfo{}
|
||||
lines := strings.Split(content, "\n")
|
||||
lines := strings.SplitSeq(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
for line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
@@ -383,22 +384,31 @@ func restoreNginxConfigs(nginxBackupDir string) error {
|
||||
return ErrNginxConfigDirEmpty
|
||||
}
|
||||
|
||||
logger.Infof("Starting Nginx config restore from %s to %s", nginxBackupDir, destDir)
|
||||
|
||||
// Recursively clean destination directory preserving the directory structure
|
||||
logger.Info("Cleaning destination directory before restore")
|
||||
if err := cleanDirectoryPreservingStructure(destDir); err != nil {
|
||||
logger.Errorf("Failed to clean directory %s: %v", destDir, err)
|
||||
return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to clean directory: "+err.Error())
|
||||
}
|
||||
|
||||
// Copy files from backup to nginx config directory
|
||||
logger.Infof("Copying backup files to destination: %s", destDir)
|
||||
if err := copyDirectory(nginxBackupDir, destDir); err != nil {
|
||||
logger.Errorf("Failed to copy backup files: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("Nginx config restore completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanDirectoryPreservingStructure removes all files and symlinks in a directory
|
||||
// but preserves the directory structure itself
|
||||
// cleanDirectoryPreservingStructure removes all files and subdirectories in a directory
|
||||
// but preserves the directory structure itself and handles mount points correctly.
|
||||
func cleanDirectoryPreservingStructure(dir string) error {
|
||||
logger.Infof("Cleaning directory: %s", dir)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -406,12 +416,114 @@ func cleanDirectoryPreservingStructure(dir string) error {
|
||||
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
err = os.RemoveAll(path)
|
||||
if err != nil {
|
||||
|
||||
if err := removeOrClearPath(path, entry.IsDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Successfully cleaned directory: %s", dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeOrClearPath removes a path or clears it if it's a mount point
|
||||
func removeOrClearPath(path string, isDir bool) error {
|
||||
// Try to remove the path first
|
||||
err := os.RemoveAll(path)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle removal failures
|
||||
if !isDeviceBusyError(err) {
|
||||
return fmt.Errorf("failed to remove %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Device busy - check if it's a mount point or directory
|
||||
if !isDir {
|
||||
return fmt.Errorf("file is busy and cannot be removed: %s: %w", path, err)
|
||||
}
|
||||
|
||||
logger.Warnf("Path is busy (mount point): %s, clearing contents only", path)
|
||||
return clearDirectoryContents(path)
|
||||
}
|
||||
|
||||
// isMountPoint checks if a path is a mount point by comparing device IDs
|
||||
// or checking /proc/mounts on Linux systems
|
||||
func isMountPoint(path string) bool {
|
||||
if isDeviceDifferent(path) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isInMountTable(path)
|
||||
}
|
||||
|
||||
// isDeviceDifferent and isInMountTable are implemented in platform-specific files:
|
||||
// - restore_unix.go for Linux/Unix systems
|
||||
// - restore_windows.go for Windows systems
|
||||
|
||||
// unescapeOctal converts octal escape sequences like \040 to their character equivalents
|
||||
func unescapeOctal(s string) string {
|
||||
var result strings.Builder
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
if char, skip := tryParseOctal(s, i); skip > 0 {
|
||||
result.WriteByte(char)
|
||||
i += skip - 1 // -1 because loop will increment
|
||||
continue
|
||||
}
|
||||
result.WriteByte(s[i])
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// tryParseOctal attempts to parse octal sequence at position i
|
||||
// returns (char, skip) where skip > 0 if successful
|
||||
func tryParseOctal(s string, i int) (byte, int) {
|
||||
if s[i] != '\\' || i+3 >= len(s) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var char byte
|
||||
if _, err := fmt.Sscanf(s[i:i+4], "\\%03o", &char); err == nil {
|
||||
return char, 4
|
||||
}
|
||||
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// isDeviceBusyError checks if an error is a "device or resource busy" error
|
||||
func isDeviceBusyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if errno, ok := err.(syscall.Errno); ok && errno == syscall.EBUSY {
|
||||
return true
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
return strings.Contains(errMsg, "device or resource busy") ||
|
||||
strings.Contains(errMsg, "resource busy")
|
||||
}
|
||||
|
||||
// clearDirectoryContents removes all files and subdirectories within a directory
|
||||
// but preserves the directory itself. This is useful for cleaning mount points.
|
||||
func clearDirectoryContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
|
||||
if err := removeOrClearPath(path, entry.IsDir()); err != nil {
|
||||
logger.Warnf("Failed to clear %s: %v, continuing", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
289
internal/backup/restore_test.go
Normal file
289
internal/backup/restore_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
cosylogger "github.com/uozi-tech/cosy/logger"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize logging system to avoid nil pointer exceptions during tests
|
||||
cosylogger.Init("debug")
|
||||
}
|
||||
|
||||
// TestIsDeviceBusyError tests the device busy error detection
|
||||
func TestIsDeviceBusyError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "EBUSY syscall error",
|
||||
err: syscall.EBUSY,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "device or resource busy string",
|
||||
err: fmt.Errorf("device or resource busy"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "resource busy string",
|
||||
err: fmt.Errorf("resource busy"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "other error",
|
||||
err: fmt.Errorf("permission denied"),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isDeviceBusyError(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnescapeOctal tests the octal escape sequence unescaping
|
||||
func TestUnescapeOctal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no escape sequences",
|
||||
input: "/mnt/data",
|
||||
expected: "/mnt/data",
|
||||
},
|
||||
{
|
||||
name: "space escape \\040",
|
||||
input: "/mnt/my\\040folder",
|
||||
expected: "/mnt/my folder",
|
||||
},
|
||||
{
|
||||
name: "multiple escapes",
|
||||
input: "/mnt\\040test\\040dir",
|
||||
expected: "/mnt test dir",
|
||||
},
|
||||
{
|
||||
name: "incomplete escape at end",
|
||||
input: "/mnt\\04",
|
||||
expected: "/mnt\\04",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := unescapeOctal(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsMountPoint tests mount point detection
|
||||
func TestIsMountPoint(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "mount-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a subdirectory
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test regular directory (should not be a mount point)
|
||||
isMountResult := isMountPoint(subDir)
|
||||
assert.False(t, isMountResult, "Regular subdirectory should not be detected as mount point")
|
||||
|
||||
// Test root directory
|
||||
// Root is typically a mount point on Linux
|
||||
rootIsMountResult := isMountPoint("/")
|
||||
// We don't assert true here because it depends on the system
|
||||
// But we verify the function doesn't panic
|
||||
t.Logf("Root directory mount check result: %v", rootIsMountResult)
|
||||
|
||||
// Test non-existent path
|
||||
nonExistentIsMountResult := isMountPoint("/non/existent/path")
|
||||
assert.False(t, nonExistentIsMountResult, "Non-existent path should return false")
|
||||
}
|
||||
|
||||
// TestClearDirectoryContents tests the directory contents clearing
|
||||
func TestClearDirectoryContents(t *testing.T) {
|
||||
// Create a temporary directory structure
|
||||
tempDir, err := os.MkdirTemp("", "clear-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create files and subdirectories
|
||||
testFile1 := filepath.Join(tempDir, "file1.txt")
|
||||
testFile2 := filepath.Join(tempDir, "file2.txt")
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
subFile := filepath.Join(subDir, "subfile.txt")
|
||||
|
||||
err = os.WriteFile(testFile1, []byte("test content 1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(testFile2, []byte("test content 2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(subDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(subFile, []byte("sub content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify files exist before clearing
|
||||
assert.FileExists(t, testFile1)
|
||||
assert.FileExists(t, testFile2)
|
||||
assert.FileExists(t, subFile)
|
||||
assert.DirExists(t, subDir)
|
||||
|
||||
// Clear directory contents
|
||||
err = clearDirectoryContents(tempDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify directory still exists
|
||||
assert.DirExists(t, tempDir)
|
||||
|
||||
// Verify all contents are removed
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, entries, "Directory should be empty after clearing")
|
||||
}
|
||||
|
||||
// TestClearDirectoryContentsWithNestedDirs tests clearing nested directory structures
|
||||
func TestClearDirectoryContentsWithNestedDirs(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "clear-nested-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create nested structure: tempDir/level1/level2/level3
|
||||
level1 := filepath.Join(tempDir, "level1")
|
||||
level2 := filepath.Join(level1, "level2")
|
||||
level3 := filepath.Join(level2, "level3")
|
||||
|
||||
err = os.MkdirAll(level3, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Add files at each level
|
||||
err = os.WriteFile(filepath.Join(level1, "file1.txt"), []byte("level1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(level2, "file2.txt"), []byte("level2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(level3, "file3.txt"), []byte("level3"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clear contents
|
||||
err = clearDirectoryContents(tempDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify root directory exists but is empty
|
||||
assert.DirExists(t, tempDir)
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
// TestCleanDirectoryPreservingStructure tests the main cleaning function
|
||||
func TestCleanDirectoryPreservingStructure(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "clean-structure-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a complex directory structure
|
||||
dir1 := filepath.Join(tempDir, "dir1")
|
||||
dir2 := filepath.Join(tempDir, "dir2")
|
||||
file1 := filepath.Join(tempDir, "file1.txt")
|
||||
file2 := filepath.Join(dir1, "file2.txt")
|
||||
|
||||
err = os.MkdirAll(dir1, 0755)
|
||||
assert.NoError(t, err)
|
||||
err = os.MkdirAll(dir2, 0755)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file1, []byte("content1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file2, []byte("content2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean the directory
|
||||
err = cleanDirectoryPreservingStructure(tempDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify root directory exists
|
||||
assert.DirExists(t, tempDir)
|
||||
|
||||
// Verify all contents are removed
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, entries, "Directory should be empty after cleaning")
|
||||
}
|
||||
|
||||
// TestCleanDirectoryPreservingStructureEmptyDir tests cleaning an already empty directory
|
||||
func TestCleanDirectoryPreservingStructureEmptyDir(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "clean-empty-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Clean already empty directory
|
||||
err = cleanDirectoryPreservingStructure(tempDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify directory still exists
|
||||
assert.DirExists(t, tempDir)
|
||||
}
|
||||
|
||||
// TestCleanDirectoryPreservingStructureWithSymlinks tests cleaning with symbolic links
|
||||
func TestCleanDirectoryPreservingStructureWithSymlinks(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "clean-symlink-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a target file
|
||||
targetFile := filepath.Join(tempDir, "target.txt")
|
||||
err = os.WriteFile(targetFile, []byte("target content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a symlink
|
||||
symlinkPath := filepath.Join(tempDir, "link.txt")
|
||||
err = os.Symlink(targetFile, symlinkPath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify symlink exists
|
||||
_, err = os.Lstat(symlinkPath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean directory
|
||||
err = cleanDirectoryPreservingStructure(tempDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify directory exists and is empty
|
||||
assert.DirExists(t, tempDir)
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
// TestCleanDirectoryPreservingStructureNonExistent tests error handling for non-existent directory
|
||||
func TestCleanDirectoryPreservingStructureNonExistent(t *testing.T) {
|
||||
nonExistentDir := "/tmp/non-existent-dir-12345"
|
||||
|
||||
err := cleanDirectoryPreservingStructure(nonExistentDir)
|
||||
assert.Error(t, err, "Should return error for non-existent directory")
|
||||
}
|
||||
|
||||
48
internal/backup/restore_unix.go
Normal file
48
internal/backup/restore_unix.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build unix
|
||||
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// isDeviceDifferent checks if path is on a different device than its parent
|
||||
func isDeviceDifferent(path string) bool {
|
||||
var pathStat, parentStat syscall.Stat_t
|
||||
|
||||
if syscall.Stat(path, &pathStat) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if syscall.Stat(filepath.Dir(path), &parentStat) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return pathStat.Dev != parentStat.Dev
|
||||
}
|
||||
|
||||
// isInMountTable checks if path is listed in /proc/mounts
|
||||
func isInMountTable(path string) bool {
|
||||
file, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) >= 2 && unescapeOctal(fields[1]) == cleanPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
16
internal/backup/restore_windows.go
Normal file
16
internal/backup/restore_windows.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package backup
|
||||
|
||||
// isDeviceDifferent always returns false on Windows
|
||||
// Windows mount points work differently and are not a concern for this use case
|
||||
func isDeviceDifferent(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isInMountTable always returns false on Windows
|
||||
// /proc/mounts doesn't exist on Windows
|
||||
func isInMountTable(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func copyDirectory(src, dst string) error {
|
||||
}
|
||||
|
||||
// Create destination directory with same permissions as source
|
||||
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ func copyDirectory(src, dst string) error {
|
||||
|
||||
// Create directories with original permissions
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(targetPath, info.Mode())
|
||||
return os.MkdirAll(targetPath, 0755)
|
||||
}
|
||||
|
||||
// Copy regular files
|
||||
|
||||
Reference in New Issue
Block a user