Files
felhom-controller/controller/internal/appexport/estimate.go
T
admin 63484a0bd4 v0.51.0: offsite-backup UI (felhom-pbs DR) + Model-A double-nest fix
- 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>
2026-06-12 20:26:52 +02:00

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)
}
}