Files
felhom-controller/controller/internal/stacks/delete.go
T
admin a96c3d9473
gates / gates (push) Successful in 9s
R-203: the export-mount resolver takes the namespace root too (its own commit)
ExportDataMounts lives in delete.go, which reads as a destructive path. IT IS NOT: its single
production caller is the .fab export adapter, and nothing deletes based on its result. The
delete path's own guard, ProtectedHDDPaths, is layout-agnostic by construction -- it protects
BOTH <hdd>/... and <hdd>/felhom-data/... -- so deletion was never affected by the
namespace-root defect. That scope note is now in the function's doc comment, because the file
placement will mislead the next reader exactly as it misled the spec for this change.

Separated into its own commit anyway, so a change to a function whose filename says "delete"
is reviewable on its own.

An empty nsRoot falls back to hddPath -- the pre-R-203 shape -- so any caller not yet updated
keeps working on enrolled drives.

Tests cover both drive kinds and assert the NEGATIVE: no emitted path lies outside the app's
own data roots. Red-proof: leaving the site bare fails the system-drive row, emitting
/mnt/sys_drive/userdata where the canonical root is /mnt/sys_drive/felhom-data/userdata.
2026-08-04 18:21:17 +02:00

696 lines
25 KiB
Go

package stacks
import (
"bufio"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// felhomDataDir matches backup.FelhomDataDir — duplicated to avoid circular import via StackDataProvider.
const felhomDataDir = "felhom-data"
// DeleteResponse holds the result of a stack deletion (orphan delete).
type DeleteResponse struct {
Deleted string `json:"deleted"`
VolumesRemoved []string `json:"volumes_removed"`
HDDPathsRemoved []string `json:"hdd_paths_removed"`
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
}
// RemoveResponse holds the result of removing a deployed (non-orphaned) stack.
type RemoveResponse struct {
Removed string `json:"removed"`
VolumesRemoved []string `json:"volumes_removed"`
HDDPathsRemoved []string `json:"hdd_paths_removed"`
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"`
}
// BackupDataResponse holds information about backup data associated with a stack.
type BackupDataResponse struct {
Stack string `json:"stack"`
BackupPaths []HDDPath `json:"backup_paths"` // reuses HDDPath (path, size, exists)
HasBackups bool `json:"has_backups"`
}
// HDDDataResponse holds information about HDD data associated with a stack.
type HDDDataResponse struct {
Stack string `json:"stack"`
HDDPaths []HDDPath `json:"hdd_paths"`
HasHDDData bool `json:"has_hdd_data"`
}
// HDDPath represents a single HDD bind mount path and its status.
type HDDPath struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
SizeHuman string `json:"size_human"`
Exists bool `json:"exists"`
}
// ProtectedHDDPaths returns the set of top-level HDD directories that must never be deleted.
func ProtectedHDDPaths(hddPath string) map[string]bool {
if hddPath == "" {
return nil
}
return map[string]bool{
// Model A: the in-guest drive mount IS the felhom-data namespace root, so backups/ and
// appdata/ sit directly under it (no felhom-data segment).
hddPath: true,
filepath.Join(hddPath, "appdata"): true,
filepath.Join(hddPath, "backups"): true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
// Legacy pre-Model-A double-nest location; kept protected so any leftover data there is
// never wiped by a removal.
filepath.Join(hddPath, felhomDataDir): true,
filepath.Join(hddPath, felhomDataDir, "appdata"): true,
filepath.Join(hddPath, felhomDataDir, "backups"): true,
}
}
// DeleteStack removes an orphaned stack: stops containers, removes volumes,
// optionally removes HDD data, and deletes the stack directory.
func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, error) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack called: name=%q, removeHDDData=%v", name, removeHDDData)
}
// Safety: never delete protected stacks
if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be deleted", name)
}
stack, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: state=%s, deployed=%v, orphaned=%v, deploying=%v",
name, stack.State, stack.Deployed, stack.Orphaned, stack.Deploying)
}
// Must be orphaned
if !stack.Orphaned {
return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name)
}
// Must not be deploying (H2 fix)
if stack.Deploying {
return nil, fmt.Errorf("stack %q is currently being deployed — wait for deployment to finish", name)
}
// Must be stopped (not running)
// StateDegraded (R-51) counts as running here: a degraded stack still has LIVE containers, and
// deleting its directory out from under them would leave orphans behind.
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting || stack.State == StateDegraded {
return nil, fmt.Errorf("stack %q is still running — stop it first before deleting", name)
}
stackDir := filepath.Dir(stack.ComposePath)
hddPath := m.cfg.Paths.HDDPath
m.logger.Printf("[INFO] Deleting orphaned stack: %s (removeHDDData=%v)", name, removeHDDData)
start := time.Now()
resp := &DeleteResponse{
Deleted: name,
}
// Step 1: Parse compose file for HDD bind mounts
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: found %d HDD mounts from compose file", name, len(hddMounts))
for i, mount := range hddMounts {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: HDD mount[%d]=%s", name, i, mount)
}
}
// Step 2: Run docker compose down --rmi local --volumes
// H14: Return error if docker compose down fails — continuing would leave orphaned containers.
env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: compose down output: %s", name, truncateStr(output, 500))
}
if err != nil {
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200))
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
}
// Step 3: Identify removed volumes from compose output
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
}
}
// Step 4: Handle HDD data
protected := ProtectedHDDPaths(hddPath)
for _, mount := range hddMounts {
// Safety: never delete protected top-level dirs
cleanPath := filepath.Clean(mount)
if protected != nil && protected[cleanPath] {
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
continue
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: HDD path does not exist, skipping: %s", name, cleanPath)
}
continue // path doesn't exist, nothing to do
}
if removeHDDData {
// Get size before removal
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: removing HDD path %s (%s)", name, cleanPath, sizeHuman)
}
if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
} else {
m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman)
resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
} else {
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: preserving HDD path %s (%s)", name, cleanPath, sizeHuman)
}
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
}
// Step 5: Remove stack directory
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: removing stack directory %s", name, stackDir)
}
if err := os.RemoveAll(stackDir); err != nil {
m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err)
return resp, fmt.Errorf("failed to remove stack directory: %w", err)
}
m.logger.Printf("[INFO] Stack %s deleted successfully (took %.1fs)", name, time.Since(start).Seconds())
// Step 6: Remove from in-memory map and rescan
m.mu.Lock()
delete(m.stacks, name)
m.mu.Unlock()
if err := m.ScanStacks(); err != nil {
m.logger.Printf("[WARN] Rescan after delete failed: %v", err)
}
return resp, nil
}
// GetStackHDDData returns information about HDD bind mounts for a stack.
func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
stack, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
}
hddPath := m.cfg.Paths.HDDPath
resp := &HDDDataResponse{
Stack: name,
}
if hddPath == "" {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: no HDD path configured, returning empty", name)
}
return resp, nil
}
mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
protected := ProtectedHDDPaths(hddPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: found %d raw HDD mounts from compose", name, len(mounts))
}
for _, mount := range mounts {
cleanPath := filepath.Clean(mount)
// Skip protected top-level dirs
if protected != nil && protected[cleanPath] {
continue
}
hddItem := HDDPath{
Path: cleanPath,
}
info, err := os.Stat(cleanPath)
if err != nil {
hddItem.Exists = false
} else {
hddItem.Exists = true
if info.IsDir() {
hddItem.SizeBytes = getDirSizeBytes(cleanPath)
hddItem.SizeHuman = getDirSizeHuman(cleanPath)
}
}
resp.HDDPaths = append(resp.HDDPaths, hddItem)
}
resp.HasHDDData = len(resp.HDDPaths) > 0
if m.isDebug() {
for _, p := range resp.HDDPaths {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: path=%s exists=%v size=%s", name, p.Path, p.Exists, p.SizeHuman)
}
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: hasHDDData=%v, %d paths returned", name, resp.HasHDDData, len(resp.HDDPaths))
}
return resp, nil
}
// RemoveStack removes a deployed (non-orphaned) stack: stops containers, removes
// volumes, optionally removes HDD data and backup data, then removes app.yaml
// so the stack reverts to "not deployed" state. The template files (docker-compose.yml,
// .felhom.yml) are preserved so the user can redeploy.
func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemove []string) (*RemoveResponse, error) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack called: name=%q, removeHDDData=%v, backupPathsToRemove=%d", name, removeHDDData, len(backupPathsToRemove))
}
// Safety: never remove protected stacks
if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be removed", name)
}
stack, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: state=%s, deployed=%v, orphaned=%v, deploying=%v",
name, stack.State, stack.Deployed, stack.Orphaned, stack.Deploying)
}
// Must be deployed
if !stack.Deployed {
return nil, fmt.Errorf("stack %q is not deployed", name)
}
// Must not be deploying (H2 fix)
if stack.Deploying {
return nil, fmt.Errorf("stack %q is currently being deployed — wait for deployment to finish", name)
}
// Must be stopped (not running)
// StateDegraded (R-51) counts as running here: a degraded stack still has LIVE containers, and
// deleting its directory out from under them would leave orphans behind.
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting || stack.State == StateDegraded {
return nil, fmt.Errorf("stack %q is still running — stop it first before removing", name)
}
stackDir := filepath.Dir(stack.ComposePath)
hddPath := m.cfg.Paths.HDDPath
m.logger.Printf("[INFO] Removing deployed stack: %s (removeHDDData=%v, backupPaths=%d)", name, removeHDDData, len(backupPathsToRemove))
start := time.Now()
resp := &RemoveResponse{
Removed: name,
}
// Step 1: Parse compose file for HDD bind mounts
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: found %d HDD mounts from compose file", name, len(hddMounts))
for i, mount := range hddMounts {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: HDD mount[%d]=%s", name, i, mount)
}
}
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: compose down output: %s", name, truncateStr(output, 500))
}
if err != nil {
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200))
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
}
// Step 3: Identify removed volumes from compose output
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
}
}
// Step 4: Handle HDD data
protected := ProtectedHDDPaths(hddPath)
for _, mount := range hddMounts {
cleanPath := filepath.Clean(mount)
if protected != nil && protected[cleanPath] {
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
continue
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: HDD path does not exist, skipping: %s", name, cleanPath)
}
continue
}
if removeHDDData {
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: removing HDD path %s (%s)", name, cleanPath, sizeHuman)
}
if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
} else {
m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman)
resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
} else {
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: preserving HDD path %s (%s)", name, cleanPath, sizeHuman)
}
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
}
// Step 5: Handle backup data cleanup. Model A: backups/ sits directly under the namespace-root
// drive mount (no felhom-data segment).
backupsBase := filepath.Join(hddPath, "backups")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: processing %d backup paths for removal (base=%s)", name, len(backupPathsToRemove), backupsBase)
}
for _, bkPath := range backupPathsToRemove {
cleanPath := filepath.Clean(bkPath)
// Validate path is under the expected backups directory
if hddPath == "" || !strings.HasPrefix(cleanPath, backupsBase+string(filepath.Separator)) {
m.logger.Printf("[WARN] Refusing to remove backup path outside expected directory: %s", cleanPath)
continue
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
continue
}
sizeHuman := getDirSizeHuman(cleanPath)
if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove backup data %s: %v", cleanPath, err)
} else {
m.logger.Printf("[INFO] Removed backup data: %s (%s)", cleanPath, sizeHuman)
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
}
// Step 6: Remove app.yaml only (keep template files for redeploy)
appYAMLPath := filepath.Join(stackDir, "app.yaml")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: removing app.yaml at %s", name, appYAMLPath)
}
if err := os.Remove(appYAMLPath); err != nil && !os.IsNotExist(err) {
m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err)
return resp, fmt.Errorf("failed to remove app.yaml: %w", err)
}
m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds())
// Step 7: Update in-memory state and rescan
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.Deployed = false
s.AppConfig = nil
}
m.mu.Unlock()
if err := m.ScanStacks(); err != nil {
m.logger.Printf("[WARN] Rescan after remove failed: %v", err)
}
return resp, nil
}
// GetStackBackupData returns information about backup data for a stack.
// drivePath is the app's home drive (HDD or system data path).
func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupDataResponse, error) {
_, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
}
resp := &BackupDataResponse{
Stack: name,
}
if drivePath == "" {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: no drive path provided, returning empty", name)
}
return resp, nil
}
// Check DB dump directory. drivePath is the felhom-data namespace ROOT (Model A: the in-guest
// drive mount itself), so backups/ sits directly under it: <nsRoot>/backups/primary/<stack>/db-dumps
dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps")
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(dbDumpPath))
// Check cross-drive rsync directory: <nsRoot>/backups/secondary/<stack>/rsync
rsyncPath := filepath.Join(drivePath, "backups", "secondary", name, "rsync")
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath))
if m.isDebug() {
for _, p := range resp.BackupPaths {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: checked path=%s exists=%v size=%s", name, p.Path, p.Exists, p.SizeHuman)
}
}
for _, p := range resp.BackupPaths {
if p.Exists {
resp.HasBackups = true
break
}
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: hasBackups=%v", name, resp.HasBackups)
}
return resp, nil
}
// buildPathInfo creates an HDDPath with size info for a given path.
func buildPathInfo(path string) HDDPath {
item := HDDPath{Path: path}
info, err := os.Stat(path)
if err != nil {
item.Exists = false
return item
}
item.Exists = true
if info.IsDir() {
item.SizeBytes = getDirSizeBytes(path)
item.SizeHuman = getDirSizeHuman(path)
}
return item
}
// ParseComposeUserdataMounts reads a docker-compose.yml and extracts the host bind-source paths that
// reference ${USERDATA_PATH} (resolved to userdataPath) — the dirs the deploy belt must pre-create
// with the userdata convention.
//
// R-75: this is now a thin RESOLVER over ParseComposeClassifiableBinds, which is the ONE authoritative
// compose-bind scanner. The two used to be byte-for-byte duplicate scanners (SPIKE §3) differing only
// in what they threw away, so a fix to one silently skipped the other; the classifier won because it
// is the richer of the two (it keeps the root and the :ro flag, both of which this function discards
// but the classification and derivation paths need).
//
// ONE deliberate behaviour drop, recorded rather than hidden: the old textual
// strings.ReplaceAll("${USERDATA_PATH}", …) + containment check also accepted a bind written as a
// LITERAL absolute path that happened to fall under userdataPath. The classifier matches the ${VAR}
// reference only. No catalog template has ever used the literal form (verified across all 53 in
// SPIKE §2 — every host token is a ${VAR}, a named volume, or the docker socket), and such a compose
// would be pinned to one machine's drive layout, so the capability was dead.
func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
if userdataPath == "" {
return nil
}
var out []string
for _, b := range ParseComposeClassifiableBinds(composePath) {
if b.Root != appbackup.RootUserdata {
continue
}
out = append(out, filepath.Join(userdataPath, filepath.FromSlash(b.RelPath)))
}
return out
}
// ExportDataMounts returns the host directories a .fab export must capture for an app: the
// ${HDD_PATH}-referencing bind mounts PLUS — when the compose binds ${USERDATA_PATH} (the standard
// felhom convention; USERDATA_PATH = <HDD_PATH>/userdata, injected at deploy by withUserdataPath) —
// the userdata ROOT as a single entry. C6B-F1 (v0.130.0): ParseComposeHDDMounts alone never
// resolves ${USERDATA_PATH}, so 12/13 needs_hdd catalog apps exported ZERO userdata (a silent
// hollow bundle that passed the v0.125.0 guard).
//
// The userdata subtree is deliberately captured at its ROOT, not per-bind: the .fab manifest keys
// HDD tars by basename, and the import side maps a basename either to a resolved ${HDD_PATH} mount
// or to <HDD_PATH>/<basename> — "userdata" round-trips through that mapping exactly, while a
// nested bind like ${USERDATA_PATH}/media/tv would base to "tv" and restore to the wrong place.
// The root also covers sibling dirs the app created beyond its declared binds (same philosophy as
// the tier-2 namespace-wholesale copy).
//
// Dedupe is containment-aware in both directions: the userdata root is skipped when an HDD mount
// already covers it (an app binding ${HDD_PATH} itself), and HDD mounts inside the userdata root
// are dropped when the root is added (a literal ${HDD_PATH}/userdata/x bind would otherwise
// double-tar and basename-collide with the root).
// R-203 — nsRoot is the app's felhom-data NAMESPACE ROOT, which is what UserdataDir takes. It is
// NOT hddPath: identical on an enrolled drive, one segment shorter on the system-data fallback.
//
// SCOPE NOTE, because this function lives in delete.go and that is misleading: it is EXPORT-only.
// Its single production caller is the .fab export adapter (cmd/controller/main.go, exportAdapter.
// GetStackDataMounts). Nothing deletes based on this result. The delete path's own guard,
// ProtectedHDDPaths above, is layout-agnostic by construction — it protects BOTH <hdd>/... and
// <hdd>/felhom-data/... — so it was never affected by the namespace-root defect.
func ExportDataMounts(composePath, hddPath, nsRoot string) []string {
if hddPath == "" {
return nil
}
hddMounts := ParseComposeHDDMounts(composePath, hddPath)
if nsRoot == "" {
nsRoot = hddPath // an enrolled drive, or a caller with nothing better — the pre-R-203 shape
}
ud := appbackup.UserdataDir(filepath.Clean(nsRoot))
if len(ParseComposeUserdataMounts(composePath, ud)) == 0 {
return hddMounts
}
for _, m := range hddMounts {
if m == ud || strings.HasPrefix(ud, m+string(filepath.Separator)) {
// an HDD mount already covers the userdata root — nothing to add
return hddMounts
}
}
mounts := make([]string, 0, len(hddMounts)+1)
for _, m := range hddMounts {
if strings.HasPrefix(m, ud+string(filepath.Separator)) {
continue // inside the userdata root — the root tar covers it
}
mounts = append(mounts, m)
}
return append(mounts, ud)
}
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
// that reference the HDD path from volume bind mounts.
func ParseComposeHDDMounts(composePath, hddPath string) []string {
if hddPath == "" {
return nil
}
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var mounts []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
inVolumes := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Track when we're in a volumes section (service-level, not top-level)
if strings.HasPrefix(line, "volumes:") {
inVolumes = true
continue
}
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
inVolumes = false
}
if !inVolumes || !strings.HasPrefix(line, "- ") {
continue
}
// Parse bind mount: "- /host/path:/container/path:options"
mountStr := strings.TrimPrefix(line, "- ")
mountStr = strings.Trim(mountStr, "\"'")
parts := strings.SplitN(mountStr, ":", 3)
if len(parts) < 2 {
continue
}
hostPath := parts[0]
// Resolve ${HDD_PATH} variable reference
hostPath = strings.ReplaceAll(hostPath, "${HDD_PATH}", hddPath)
// C10: Clean path BEFORE prefix check to prevent traversal like ${HDD_PATH}/../../etc/passwd.
cleanPath := filepath.Clean(hostPath)
cleanHDD := filepath.Clean(hddPath)
// Check if this is an HDD mount (must be cleanHDD itself or a direct subpath)
if cleanPath != cleanHDD && !strings.HasPrefix(cleanPath, cleanHDD+string(filepath.Separator)) {
continue
}
if !seen[cleanPath] {
seen[cleanPath] = true
mounts = append(mounts, cleanPath)
}
}
log.Printf("[INFO] [stacks] ParseComposeHDDMounts: found %d HDD mounts for %s", len(mounts), composePath)
return mounts
}
// getDirSizeHuman returns a human-readable size string for a directory using du.
func getDirSizeHuman(path string) string {
cmd := exec.Command("du", "-sh", path)
output, err := cmd.Output()
if err != nil {
return "unknown"
}
fields := strings.Fields(string(output))
if len(fields) > 0 {
return fields[0]
}
return "unknown"
}
// getDirSizeBytes returns the total size in bytes for a directory.
func getDirSizeBytes(path string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sb", path)
output, err := cmd.Output()
if err != nil {
return 0
}
fields := strings.Fields(string(output))
if len(fields) > 0 {
var size int64
if n, _ := fmt.Sscanf(fields[0], "%d", &size); n != 1 {
return 0
}
return size
}
return 0
}