63484a0bd4
- Backups page: whole-guest backup shown as real DR — target label "Biztonsági szerver – külön hardver (PBS)"; app-data "Távoli mentés" card now reflects the PBS offsite tier (guestBackupView.Offsite) instead of "nincs beállítva". - Model-A double-nest fix: appbackup path helpers take a felhom-data NAMESPACE ROOT (no internal felhom-data join); backup.Manager.namespaceRoot/AppNamespaceRoot resolve HDD-vs-systemDataPath provenance so a drive-resident app's backups land single-nested (<drive>/backups/... on the guest = <drive>/felhom-data/backups/... on the host) instead of .../felhom-data/felhom-data/.... Writes, deletion (GetStackBackupData/RemoveStack/ ProtectedHDDPaths), wipe-warning scan, and export updated coherently; legacy double-nest dirs kept protected. New appbackup test asserts no doubled segment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
5.4 KiB
Go
180 lines
5.4 KiB
Go
package appexport
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ExportEstimate holds pre-export size and space estimation.
|
|
type ExportEstimate struct {
|
|
ConfigSizeBytes int64 `json:"config_size_bytes"`
|
|
ConfigSizeHuman string `json:"config_size_human"`
|
|
DataSizeBytes int64 `json:"data_size_bytes"`
|
|
DataSizeHuman string `json:"data_size_human"`
|
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
|
TotalSizeHuman string `json:"total_size_human"`
|
|
EstimatedMinutes int `json:"estimated_minutes"`
|
|
DestFreeBytes int64 `json:"dest_free_bytes"`
|
|
DestFreeHuman string `json:"dest_free_human"`
|
|
FitsOnDest bool `json:"fits_on_dest"`
|
|
}
|
|
|
|
// EstimateExport calculates size estimates for an app export.
|
|
func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate, error) {
|
|
stackDir, ok := e.provider.GetStackDir(stackName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("stack %q not found", stackName)
|
|
}
|
|
|
|
e.debugf("EstimateExport: stack=%s stackDir=%s destDrive=%s", stackName, stackDir, destDrive)
|
|
est := &ExportEstimate{}
|
|
|
|
// Config size: sum of all files in the stack directory
|
|
est.ConfigSizeBytes = dirSize(stackDir)
|
|
est.ConfigSizeHuman = humanizeBytes(est.ConfigSizeBytes)
|
|
e.debugf("EstimateExport: configSize=%s (%d bytes)", est.ConfigSizeHuman, est.ConfigSizeBytes)
|
|
|
|
// Data size: HDD bind mounts or Docker volumes
|
|
if e.provider.GetStackNeedsHDD(stackName) {
|
|
mounts := e.provider.GetStackHDDMounts(stackName)
|
|
e.debugf("EstimateExport: HDD mounts: %v", mounts)
|
|
for _, mount := range mounts {
|
|
mountSize := duBytes(mount)
|
|
e.debugf("EstimateExport: mount %s = %s", mount, humanizeBytes(mountSize))
|
|
est.DataSizeBytes += mountSize
|
|
}
|
|
} else {
|
|
volumes := e.provider.GetDockerVolumes(stackName)
|
|
e.debugf("EstimateExport: Docker volumes: %v", volumes)
|
|
for _, vol := range volumes {
|
|
volSize := dockerVolumeSize(vol)
|
|
e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
|
|
est.DataSizeBytes += volSize
|
|
}
|
|
}
|
|
est.DataSizeHuman = humanizeBytes(est.DataSizeBytes)
|
|
|
|
est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
|
|
est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)
|
|
|
|
// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
|
|
minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
|
|
if minutes < 1 {
|
|
minutes = 1
|
|
}
|
|
est.EstimatedMinutes = minutes
|
|
|
|
// Destination free space
|
|
exportDir := ExportDir(destDrive)
|
|
os.MkdirAll(exportDir, 0755)
|
|
est.DestFreeBytes = diskFree(exportDir)
|
|
est.DestFreeHuman = humanizeBytes(est.DestFreeBytes)
|
|
|
|
// Need ~10% overhead for tar.gz metadata + compression margin
|
|
needed := est.TotalSizeBytes + est.TotalSizeBytes/10
|
|
est.FitsOnDest = est.DestFreeBytes >= needed
|
|
|
|
e.debugf("EstimateExport: total=%s free=%s fits=%v needed=%s minutes=%d",
|
|
est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest, humanizeBytes(needed), est.EstimatedMinutes)
|
|
|
|
return est, nil
|
|
}
|
|
|
|
// dirSize returns the total size of all files in a directory (non-recursive for config dirs).
|
|
func dirSize(dir string) int64 {
|
|
var total int64
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
total += info.Size()
|
|
}
|
|
return total
|
|
}
|
|
|
|
// duBytes runs du -sb on a path and returns the byte count.
|
|
func duBytes(path string) int64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "du", "-sb", path).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var size int64
|
|
fmt.Sscanf(strings.Fields(string(out))[0], "%d", &size)
|
|
return size
|
|
}
|
|
|
|
// dockerVolumeSize estimates the size of a Docker named volume.
|
|
func dockerVolumeSize(volumeName string) int64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
// Use docker system df -v and parse, or inspect the volume mount path
|
|
out, err := exec.CommandContext(ctx, "docker", "volume", "inspect",
|
|
"--format", "{{.Mountpoint}}", volumeName).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
mountpoint := strings.TrimSpace(string(out))
|
|
if mountpoint == "" {
|
|
return 0
|
|
}
|
|
return duBytes(mountpoint)
|
|
}
|
|
|
|
// diskFree returns available bytes on the filesystem containing path.
|
|
func diskFree(path string) int64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
if len(lines) < 2 {
|
|
return 0
|
|
}
|
|
var size int64
|
|
fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &size)
|
|
return size
|
|
}
|
|
|
|
// ExportDir returns the exports directory on a drive. Model A (slice 10): a registered drive's
|
|
// in-guest mount IS the felhom-data namespace root, so exports/ sits directly under it (no
|
|
// felhom-data segment — avoids the .../felhom-data/felhom-data/... double-nest).
|
|
func ExportDir(drivePath string) string {
|
|
return filepath.Join(drivePath, "exports")
|
|
}
|
|
|
|
// humanizeBytes converts bytes to human-readable format.
|
|
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)
|
|
}
|
|
}
|