7465713a2f
v0.129.0: CAMPAIGN-4 fixes — rate-limiter key (F-B) + volume-blind estimate (F-A) + no-op claim status (F-C) F-B (MED, security): shared clientIP(r) helper (XFF first-hop, else SplitHostPort host, else raw) replaces requestIP + the duplicated inline derivation in handleLogin, so login AND escrow re-auth key on the port-stripped host IP — distinct direct connections no longer evade the failed-attempt counter. XFF-trust out of scope (commented). F-A (MED, honesty): volumeSizer seam reads volume size from a container view (docker run --rm -v vol:/vol:ro alpine du -sb /vol), replacing the host-path du that returned 0 inside the containerized controller. Failed read -> size_unknown + fits_on_dest forced false (never "fits"). Export pre-flight hard-aborts only on a KNOWN doesn.t-fit. HDD branch unchanged. F-C (LOW-MED): escrowClaimAPIHandler relays agent 404 -> clean 404 and 409 -> 409; 410 and genuine-unreachable 502 unchanged (was: 404 fell through to 502). Tests + red-proofs: ratelimit_ip_test.go (F-B x6), estimate_volsize_test.go (F-A x3), TestEscrowClaim_ProxySemantics +3 (F-C). Alpine busybox du -sb verified prod-valid. Claude-Session: https://claude.ai/code/session_01LbMm4T7Ayzs1unB9pN6Uqd @
210 lines
7.3 KiB
Go
210 lines
7.3 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"`
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
}
|
|
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)
|
|
|
|
// 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)
|
|
}
|
|
}
|