95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
4.5 KiB
Go
177 lines
4.5 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// StackDataProvider provides stack data to the backup package without circular imports.
|
|
type StackDataProvider interface {
|
|
GetStackComposePath(name string) (composePath string, ok bool)
|
|
ListDeployedStacks() []StackSummary
|
|
GetStackHDDMounts(name string) []string
|
|
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
|
|
StopStack(name string) error
|
|
StartStack(name string) error
|
|
}
|
|
|
|
// StackSummary holds minimal stack info needed for app data discovery.
|
|
type StackSummary struct {
|
|
Name string
|
|
DisplayName string
|
|
ComposePath string
|
|
NeedsHDD bool
|
|
}
|
|
|
|
// AppBackupInfo holds backup-relevant data paths for a deployed app.
|
|
type AppBackupInfo struct {
|
|
StackName string
|
|
DisplayName string
|
|
NeedsHDD bool
|
|
HDDPaths []AppDataPath
|
|
HDDTotalSize int64
|
|
HDDSizeHuman string
|
|
DockerVolumes []AppDockerVolume
|
|
BackupEnabled bool
|
|
HasHDDData bool
|
|
HasDBDump bool
|
|
StorageLabel string // resolved from registered storage paths
|
|
}
|
|
|
|
// AppDataPath represents a single HDD bind mount path.
|
|
type AppDataPath struct {
|
|
HostPath string
|
|
Exists bool
|
|
SizeHuman string
|
|
SizeBytes int64
|
|
}
|
|
|
|
// AppDockerVolume represents a named Docker volume.
|
|
type AppDockerVolume struct {
|
|
Name string
|
|
Contains string
|
|
}
|
|
|
|
// DiscoverAppData discovers backup-relevant data for all deployed apps.
|
|
// All apps with HDD data are backed up automatically (mandatory — no opt-in).
|
|
func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) []AppBackupInfo {
|
|
if provider == nil {
|
|
return nil
|
|
}
|
|
|
|
var result []AppBackupInfo
|
|
|
|
for _, stack := range provider.ListDeployedStacks() {
|
|
info := AppBackupInfo{
|
|
StackName: stack.Name,
|
|
DisplayName: stack.DisplayName,
|
|
NeedsHDD: stack.NeedsHDD,
|
|
}
|
|
|
|
// Discover HDD bind mounts via adapter
|
|
hddMounts := provider.GetStackHDDMounts(stack.Name)
|
|
for _, mount := range hddMounts {
|
|
path := AppDataPath{HostPath: mount}
|
|
if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
|
|
path.Exists = true
|
|
path.SizeBytes, path.SizeHuman = appDirSize(mount)
|
|
}
|
|
info.HDDPaths = append(info.HDDPaths, path)
|
|
info.HDDTotalSize += path.SizeBytes
|
|
}
|
|
info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
|
|
info.HasHDDData = len(info.HDDPaths) > 0
|
|
|
|
// Discover Docker named volumes from compose
|
|
info.DockerVolumes = ParseComposeNamedVolumes(stack.ComposePath)
|
|
|
|
// Check if app has a DB container (already backed up via DB dump)
|
|
for _, db := range discoveredDBs {
|
|
if db.StackName == stack.Name {
|
|
info.HasDBDump = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// All apps with HDD data are backed up automatically (mandatory)
|
|
info.BackupEnabled = info.HasHDDData
|
|
|
|
result = append(result, info)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ParseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
|
|
func ParseComposeNamedVolumes(composePath string) []AppDockerVolume {
|
|
data, err := os.ReadFile(composePath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var compose struct {
|
|
Volumes map[string]interface{} `yaml:"volumes"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var volumes []AppDockerVolume
|
|
for name, cfg := range compose.Volumes {
|
|
// Skip external volumes
|
|
if cfgMap, ok := cfg.(map[string]interface{}); ok {
|
|
if ext, ok := cfgMap["external"]; ok && ext == true {
|
|
continue
|
|
}
|
|
}
|
|
volumes = append(volumes, AppDockerVolume{Name: name})
|
|
}
|
|
return volumes
|
|
}
|
|
|
|
// appDirSize returns the total byte count and a human-readable string for a directory.
|
|
// H2/H3: Single du invocation with 30s timeout replaces two separate calls.
|
|
func appDirSize(path string) (int64, string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "du", "-sb", path)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, "?"
|
|
}
|
|
fields := strings.Fields(string(output))
|
|
if len(fields) == 0 {
|
|
return 0, "?"
|
|
}
|
|
var size int64
|
|
if n, _ := fmt.Sscanf(fields[0], "%d", &size); n != 1 {
|
|
return 0, "?"
|
|
}
|
|
return size, humanizeBytes(size)
|
|
}
|
|
|
|
// humanizeBytes converts bytes to a human-readable string.
|
|
func humanizeBytes(b int64) string {
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
switch {
|
|
case b >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
|
|
case b >= MB:
|
|
return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
|
|
case b >= KB:
|
|
return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
}
|