package appexport import ( "bytes" "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"` // SizeUnknown is set (v0.129.0 F-A) when a volume's size could not be read (docker helper // failed). When true, DataSizeBytes is a partial/understated sum and FitsOnDest is FORCED false // — a failed read must NEVER render as "fits". The UI shows "ismeretlen méret". SizeUnknown bool `json:"size_unknown"` // Task 4 class split (classified apps only; empty for legacy — existing fields above are // unchanged, so old JSON consumers keep working). BaseBytes = config + DB + volumes + mandatory // (always in the bundle). OptionalItems are pre-selected, ExcludedItems are opt-in — each carries // its own size so the UI recomputes the total client-side per checkbox toggle (no extra du calls). HasClassification bool `json:"has_classification"` BaseBytes int64 `json:"base_bytes"` BaseHuman string `json:"base_human"` MandatoryItems []FabItem `json:"mandatory_items,omitempty"` OptionalItems []FabItem `json:"optional_items,omitempty"` ExcludedItems []FabItem `json:"excluded_items,omitempty"` } // FabItem is one class-scoped path in the `.fab` selection UI: Key is the DeselectOptional/OptInExcluded // value ("root/rel"), RelPath is the display path, Bytes/Human its du size. type FabItem struct { Key string `json:"key"` Root string `json:"root"` RelPath string `json:"rel_path"` Bytes int64 `json:"bytes"` Human string `json:"human"` } // 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 PLUS Docker volumes. v0.130.0 (C6B-F1): additive, mirroring the // export itself — a needs_hdd app bundles BOTH, so the fits-on-dest gate must count both. 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 } } volumes := e.provider.GetDockerVolumes(stackName) e.debugf("EstimateExport: Docker volumes: %v", volumes) var volumeBytes int64 for _, vol := range volumes { volSize, err := volumeSizer(vol) if err != nil { // F-A: the controller runs containerized, so a failed helper read must not // silently become 0-that-reads-as-fits. Mark unknown and keep going. e.logger.Printf("[WARN] appexport: volume size unknown for %s: %v", vol, err) est.SizeUnknown = true continue } e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize)) est.DataSizeBytes += volSize volumeBytes += volSize } if est.SizeUnknown { est.DataSizeHuman = "ismeretlen méret" } else { est.DataSizeHuman = humanizeBytes(est.DataSizeBytes) } est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes) // Task 4: the class split (classified apps only). Independent du over each bucket path — additive, // never touches the fields/fits gate above. e.fabEstimateSplit(stackName, est, volumeBytes) // 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. F-A: a size we could not read // must never render as "fits" — an unknown-size estimate is conservatively not-fits. needed := est.TotalSizeBytes + est.TotalSizeBytes/10 est.FitsOnDest = !est.SizeUnknown && 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 } // volumeSizer returns the byte size of a named Docker volume as seen from a CONTAINER view. // Package var so unit tests inject a fake (returning a known size or an error) without shelling out // to real docker. F-A (v0.129.0): the old dockerVolumeSize `du`d the host mountpoint from // `docker volume inspect`, which is NOT visible inside the containerized controller → always 0. var volumeSizer = realVolumeSize // realVolumeSize `du -sb`s the volume mounted read-only into a throwaway helper container — the same // container-view pattern the export path uses (appexport/export.go withVolumeHelper). It mounts the // NAMED VOLUME by name (never a controller-host path — the v0.125.0 strand class). Returns an error // on any failure; callers treat that as "unknown size", never as 0. func realVolumeSize(volumeName string) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var out bytes.Buffer stderr, err := dockerExec(ctx, nil, &out, "run", "--rm", "-v", volumeName+":/vol:ro", "alpine", "du", "-sb", "/vol") if err != nil { return 0, fmt.Errorf("sizing volume %s: %s: %w", volumeName, stderr, err) } fields := strings.Fields(out.String()) if len(fields) == 0 { return 0, fmt.Errorf("sizing volume %s: empty du output", volumeName) } var size int64 if _, err := fmt.Sscanf(fields[0], "%d", &size); err != nil { return 0, fmt.Errorf("sizing volume %s: parse %q: %w", volumeName, fields[0], err) } return size, nil } // DiskFree returns available bytes on the filesystem containing path (0 on any error). // Exported since v0.128.0 — the browser-upload space gate reuses it via a web-package seam. 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) } }