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. func ExportDir(drivePath string) string { return filepath.Join(drivePath, "felhom-data", "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) } }