Files
felhom-controller/controller/internal/appexport/estimate.go
T
admin cf9ce01917 .fab exclusion scoping: classes in the manual export (Task 4, v0.136.0)
SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only
ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected
userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared
resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment).
appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains
DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side
floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive
class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning.
All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
2026-07-15 11:28:40 +02:00

237 lines
8.7 KiB
Go

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