Files
deploy-felhom-compose/TASK.md
T

27 KiB

TASK: Storage Namespace (felhom-data/) + Test Node Wipe Script (v0.26.0)

Controller: v0.25.0 → v0.26.0

Overview

All felhom-managed data on external drives moves under a felhom-data/ subdirectory, cleanly separating our data from user files. Plus a wipe script for repeatable testing.

Key design principle: HDD_PATH env var stays as the mount point (e.g., /mnt/hdd_1). The felhom-data segment is embedded in path helpers and compose templates, not in HDD_PATH.


Phase 1: Path Helpers (internal/backup/paths.go)

This is the foundation. All other changes depend on this.

File: controller/internal/backup/paths.go

Add constant at top of file (after imports):

// FelhomDataDir is the namespace directory on storage drives for all felhom-managed data.
const FelhomDataDir = "felhom-data"

Update 8 functions to include FelhomDataDir in the path. Each function gets FelhomDataDir inserted between drivePath and the next segment:

Function Current first segment New first segment
PrimaryBackupPath "backups" FelhomDataDir, "backups"
PrimaryResticRepoPath "backups" FelhomDataDir, "backups"
AppDBDumpPath "backups" FelhomDataDir, "backups"
SecondaryBackupPath "backups" FelhomDataDir, "backups"
AppSecondaryRsyncPath "backups" FelhomDataDir, "backups"
SecondaryResticRepoPath "backups" FelhomDataDir, "backups"
SecondaryInfraPath "backups" FelhomDataDir, "backups"
AppDataDir "appdata" FelhomDataDir, "appdata"

DO NOT change InfraBackupDir — it stays at drive root (DR scanner needs it).

Example transformation for each function — replace:

return filepath.Join(drivePath, "backups", "primary")

with:

return filepath.Join(drivePath, FelhomDataDir, "backups", "primary")

And for AppDataDir replace:

return filepath.Join(drivePath, "appdata", stackName)

with:

return filepath.Join(drivePath, FelhomDataDir, "appdata", stackName)

Phase 2: Fix Hardcoded Paths

2a. controller/internal/stacks/delete.goProtectedHDDPaths()

Line 54-65. Cannot import backup package (architectural boundary via StackDataProvider).

Add local constant at top of file (after imports, before types):

// felhomDataDir matches backup.FelhomDataDir — duplicated to avoid circular import via StackDataProvider.
const felhomDataDir = "felhom-data"

Update ProtectedHDDPaths():

func ProtectedHDDPaths(hddPath string) map[string]bool {
	if hddPath == "" {
		return nil
	}
	return map[string]bool{
		hddPath:                                                true,
		filepath.Join(hddPath, felhomDataDir):                  true,
		filepath.Join(hddPath, felhomDataDir, "appdata"):       true,
		filepath.Join(hddPath, felhomDataDir, "backups"):       true,
		filepath.Join(hddPath, "media"):                        true,
		filepath.Join(hddPath, "Dokumentumok"):                 true,
	}
}

2b. controller/internal/stacks/delete.goGetStackBackupData()

Line 355 and 359. Replace hardcoded paths with the local constant:

Replace:

dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps")

with:

dbDumpPath := filepath.Join(drivePath, felhomDataDir, "backups", "primary", name, "db-dumps")

Replace:

rsyncPath := filepath.Join(drivePath, "backups", "secondary", name, "rsync")

with:

rsyncPath := filepath.Join(drivePath, felhomDataDir, "backups", "secondary", name, "rsync")

2c. controller/internal/storage/migrate_drive.go — Conflict check & verify

Import backup package (safe — backup does NOT import storage):

"gitea.dooplex.hu/admin/felhom-controller/internal/backup"

Line 183 — conflict check before migration. Replace:

destAppData := filepath.Join(req.DestPath, "appdata", app.Name)

with:

destAppData := backup.AppDataDir(req.DestPath, app.Name)

Line 325 — verify after copy. Replace:

destAppData := filepath.Join(req.DestPath, "appdata", app.Name)

with:

destAppData := backup.AppDataDir(req.DestPath, app.Name)

2d. controller/internal/storage/migrate_drive.go — rsync exclude paths

CRITICAL BUG FIX. Line 254-256. The rsync copies the entire drive root. The exclude patterns are relative paths. After namespace change, restic repos are under felhom-data/backups/*/restic/.

Replace:

rsyncCmd := exec.CommandContext(ctx, "rsync", "-a", "--info=progress2",
    "--exclude=backups/primary/restic/",
    "--exclude=backups/secondary/restic/",
    req.SourcePath+"/", req.DestPath+"/",
)

with:

rsyncCmd := exec.CommandContext(ctx, "rsync", "-a", "--info=progress2",
    "--exclude=felhom-data/backups/primary/restic/",
    "--exclude=felhom-data/backups/secondary/restic/",
    req.SourcePath+"/", req.DestPath+"/",
)

Use backup.FelhomDataDir constant in the string construction if preferred, but since these are string literals for rsync args, hardcoding "felhom-data" is acceptable.

2e. controller/internal/storage/migrate_drive.go — Size estimation

BUG FIX. Lines 196-208. This scans the drive root for size estimation. After namespace change, entry.Name() == "backups" never matches because backups are now under felhom-data/.

Replace the size estimation block:

// Estimate total size (exclude restic repos)
var totalBytes int64
entries, _ := os.ReadDir(req.SourcePath)
for _, entry := range entries {
    entryPath := filepath.Join(req.SourcePath, entry.Name())
    if entry.IsDir() {
        // Skip restic repos in size estimate
        if entry.Name() == "backups" {
            totalBytes += dirSizeExcluding(entryPath, "restic")
        } else {
            totalBytes += dirSize(entryPath)
        }
    }
}

with:

// Estimate total size (exclude restic repos inside felhom-data/backups/)
var totalBytes int64
entries, _ := os.ReadDir(req.SourcePath)
for _, entry := range entries {
    if !entry.IsDir() {
        continue
    }
    entryPath := filepath.Join(req.SourcePath, entry.Name())
    if entry.Name() == backup.FelhomDataDir {
        // Scan inside namespace dir, excluding restic repos from estimate
        subEntries, _ := os.ReadDir(entryPath)
        for _, sub := range subEntries {
            if !sub.IsDir() {
                continue
            }
            subPath := filepath.Join(entryPath, sub.Name())
            if sub.Name() == "backups" {
                totalBytes += dirSizeExcluding(subPath, "restic")
            } else {
                totalBytes += dirSize(subPath)
            }
        }
    } else {
        totalBytes += dirSize(entryPath)
    }
}

2f. controller/internal/storage/migrate.go — Post-migration DB dump copy

Lines 397-398. Add import for backup package (same import section as 2c — storage package can safely import backup).

Replace:

srcDBDumps := filepath.Join(req.CurrentHDDPath, "backups", "primary", req.StackName, "db-dumps")
dstDBDumps := filepath.Join(req.TargetPath, "backups", "primary", req.StackName, "db-dumps")

with:

srcDBDumps := backup.AppDBDumpPath(req.CurrentHDDPath, req.StackName)
dstDBDumps := backup.AppDBDumpPath(req.TargetPath, req.StackName)

Add to the import block:

"gitea.dooplex.hu/admin/felhom-controller/internal/backup"

2g. controller/internal/web/handlers.go — Legacy "storage" path (pre-existing bug)

Line 1223. Replace legacy dead code that uses "storage" instead of "appdata".

The backup package is already imported in handlers.go, so use the helper directly:

Replace:

appDataDir := filepath.Join(storagePath, "storage", stack.Name)

with:

appDataDir := backup.AppDataDir(storagePath, stack.Name)

No import addition needed.


Phase 3: Format & Attach — Create felhom-data/ Directory

3a. controller/internal/storage/format_linux.go

Line 211. Replace "storage" with "felhom-data" in the subdirectory creation list:

Replace:

for _, subdir := range []string{"storage", "Dokumentumok"} {

with:

for _, subdir := range []string{"felhom-data", "Dokumentumok"} {

3b. controller/internal/storage/attach_linux.go

Line 313. Same change:

Replace:

for _, subdir := range []string{"storage", "Dokumentumok"} {

with:

for _, subdir := range []string{"felhom-data", "Dokumentumok"} {

Phase 4: Verification Grep

After making all the above changes, run these greps to verify no hardcoded paths remain:

grep -rn '"appdata"' controller/internal/ --include='*.go' | grep -v '_test.go\|paths.go\|CLAUDE\|README'
grep -rn '"backups"' controller/internal/ --include='*.go' | grep -v '_test.go\|paths.go\|CLAUDE\|README'

Expected remaining hits (all OK — not filesystem path construction):

  • web/handlers.go"backups" as page name for template routing (display context)
  • web/templates/layout.html — navigation link text
  • web/templates/backups.html — template define name
  • Any template/HTML display contexts

Any remaining filepath.Join(*, "appdata", *) or filepath.Join(*, "backups", *) outside of paths.go is a BUG — fix it.


Phase 5: Verification of Auto-Propagating Changes

These components use path helpers and automatically get the namespace change from Phase 1:

5a. controller/internal/backup/crossdrive.go

The syncInfraConfig() method (line 504) uses SecondaryInfraPath(dest) — auto-updated. The runRsyncBackup() method (line 367) uses AppSecondaryRsyncPath() — auto-updated. The copyStackDBDumps() method (line 455) uses AppDBDumpPath() — auto-updated.

No changes needed. Just verify by reading the file.

5b. controller/internal/backup/restore.go

Line 65: AppDBDumpPath(drivePath, stackName) — auto-updated. Line 84: PrimaryResticRepoPath(drivePath) — auto-updated.

No changes needed.

5c. controller/internal/backup/backup.go

Multiple uses of AppDataDir(), AppDBDumpPath(), PrimaryResticRepoPath() — all auto-updated.

No changes needed.


Phase 6: Build & Verify

cd controller && go build ./... && go vet ./...

Must compile cleanly with no errors or warnings.


Phase 7: Wipe Script

File: controller/scripts/felhom-wipe.sh

Create the following script. It must be executable (chmod +x).

#!/usr/bin/env bash
set -euo pipefail

# ===================================================================
# felhom-wipe.sh — Clean felhom data from a test node
# Usage: ./felhom-wipe.sh --level <soft|controller|full|nuclear> [--yes]
# ===================================================================

# --- Colors ---
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

# --- Defaults ---
LEVEL=""
DRY_RUN=true
INCLUDE_PROTECTED=false

# --- Configuration (auto-detected) ---
CONTROLLER_YAML="/opt/docker/felhom-controller/controller.yaml"
DATA_DIR="/opt/docker/felhom-controller/data"
COMPOSE_DIR="/opt/docker/felhom-controller"
STACKS_DIR="/opt/docker/stacks"
SETTINGS_JSON="$DATA_DIR/settings.json"

# --- Helpers ---
die()  { echo -e "${RED}ERROR: $1${NC}" >&2; exit 1; }
info() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
bold() { echo -e "${BOLD}$1${NC}"; }

human_size() {
    local path="$1"
    if [ -e "$path" ]; then
        du -sh "$path" 2>/dev/null | cut -f1 || echo "?"
    else
        echo "n/a"
    fi
}

usage() {
    cat <<EOF
Usage: $(basename "$0") --level <level> [--yes] [--include-protected]

Levels:
  soft        Controller state only (settings.json, metrics.db, session data)
  controller  Soft + remove all app containers, volumes, stack dirs, app.yaml files
  full        Controller + felhom-data/ on all drives (appdata, backups)
  nuclear     Full + controller.yaml, controller container, Traefik, Portainer, all Docker data

Options:
  --yes                Execute the wipe (default: dry run)
  --include-protected  Also remove protected stacks (controller level only)

EOF
    exit 1
}

# --- Parse Args ---
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --level)       LEVEL="$2"; shift 2 ;;
            --yes)         DRY_RUN=false; shift ;;
            --include-protected) INCLUDE_PROTECTED=true; shift ;;
            -h|--help)     usage ;;
            *)             die "Unknown argument: $1" ;;
        esac
    done

    [[ -z "$LEVEL" ]] && usage
    case "$LEVEL" in
        soft|controller|full|nuclear) ;;
        *) die "Invalid level: $LEVEL (must be soft|controller|full|nuclear)" ;;
    esac
}

# --- Detect Paths ---
detect_paths() {
    # Auto-detect from controller.yaml if present
    if [ -f "$CONTROLLER_YAML" ]; then
        local sd
        sd=$(grep -oP 'stacks_dir:\s*\K\S+' "$CONTROLLER_YAML" 2>/dev/null || true)
        [ -n "$sd" ] && STACKS_DIR="$sd"
        local dd
        dd=$(grep -oP 'data_dir:\s*\K\S+' "$CONTROLLER_YAML" 2>/dev/null || true)
        [ -n "$dd" ] && DATA_DIR="$dd" && SETTINGS_JSON="$dd/settings.json"
    fi
}

# --- Detect Storage Paths ---
declare -a STORAGE_PATHS=()

detect_storage_paths() {
    # From settings.json
    if [ -f "$SETTINGS_JSON" ]; then
        while IFS= read -r p; do
            [ -n "$p" ] && STORAGE_PATHS+=("$p")
        done < <(python3 -c "
import json, sys
try:
    d = json.load(open('$SETTINGS_JSON'))
    for sp in d.get('storage_paths', []):
        print(sp.get('path', ''))
except: pass
" 2>/dev/null || true)
    fi

    # Also scan /mnt/* for felhom-data dirs not in registry
    for d in /mnt/*/; do
        [ -d "${d}felhom-data" ] || [ -d "${d}appdata" ] || continue
        local already=false
        for sp in "${STORAGE_PATHS[@]:-}"; do
            [ "$sp" = "${d%/}" ] && already=true && break
        done
        $already || STORAGE_PATHS+=("${d%/}")
    done
}

# --- List App Containers (non-infra) ---
list_app_containers() {
    docker ps -a --format '{{.Names}}' 2>/dev/null | grep -v -E '^(felhom-controller|traefik|cloudflared|portainer)$' || true
}

# --- List App Volumes (non-infra) ---
list_app_volumes() {
    docker volume ls -q 2>/dev/null | grep -v -E '^(portainer_data)$' || true
}

# --- Protected Stacks ---
get_protected_stacks() {
    if [ -f "$CONTROLLER_YAML" ]; then
        grep -A 20 'protected_stacks:' "$CONTROLLER_YAML" 2>/dev/null | grep -oP '^\s*-\s*\K\S+' || true
    fi
}

# --- Print Plan ---
print_plan() {
    echo ""
    bold "=== Felhom Wipe — Level: $LEVEL ==="
    echo ""

    # State files
    echo -e "${CYAN}Controller state:${NC}"
    local state_files=("$DATA_DIR/settings.json" "$DATA_DIR/metrics.db" "$DATA_DIR/setup-state.json" "$DATA_DIR/update-state.json" "$DATA_DIR/session-data.json" "$DATA_DIR/snapshot-history.json")
    for f in "${state_files[@]}"; do
        if [ -f "$f" ]; then
            echo -e "  ${YELLOW}DELETE${NC} $f ($(human_size "$f"))"
        fi
    done

    if [[ "$LEVEL" == "controller" || "$LEVEL" == "full" || "$LEVEL" == "nuclear" ]]; then
        echo ""
        echo -e "${CYAN}Docker containers:${NC}"
        local containers
        containers=$(list_app_containers)
        if [ -n "$containers" ]; then
            echo "$containers" | while read -r c; do
                echo -e "  ${YELLOW}REMOVE${NC} $c"
            done
        else
            echo -e "  ${GREEN}(none)${NC}"
        fi

        echo ""
        echo -e "${CYAN}Docker volumes:${NC}"
        local volumes
        volumes=$(list_app_volumes)
        if [ -n "$volumes" ]; then
            echo "$volumes" | while read -r v; do
                echo -e "  ${YELLOW}REMOVE${NC} $v"
            done
        else
            echo -e "  ${GREEN}(none)${NC}"
        fi

        echo ""
        echo -e "${CYAN}Stack directories:${NC}"
        if [ -d "$STACKS_DIR" ]; then
            for sd in "$STACKS_DIR"/*/; do
                [ -d "$sd" ] || continue
                local stack_name
                stack_name=$(basename "$sd")
                local protected_stacks
                protected_stacks=$(get_protected_stacks)
                if echo "$protected_stacks" | grep -qx "$stack_name" && ! $INCLUDE_PROTECTED; then
                    echo -e "  ${GREEN}KEEP${NC}   $sd (protected)"
                else
                    echo -e "  ${YELLOW}DELETE${NC} $sd"
                fi
            done
        else
            echo -e "  ${GREEN}(not found)${NC}"
        fi
    fi

    if [[ "$LEVEL" == "full" || "$LEVEL" == "nuclear" ]]; then
        echo ""
        echo -e "${CYAN}Storage data:${NC}"
        if [ ${#STORAGE_PATHS[@]} -gt 0 ]; then
            for sp in "${STORAGE_PATHS[@]}"; do
                if [ -d "$sp/felhom-data" ]; then
                    echo -e "  ${YELLOW}DELETE${NC} $sp/felhom-data/ ($(human_size "$sp/felhom-data"))"
                fi
                # Old-style paths
                if [ -d "$sp/appdata" ]; then
                    echo -e "  ${YELLOW}DELETE${NC} $sp/appdata/ ($(human_size "$sp/appdata")) [old-style]"
                fi
                if [ -d "$sp/backups" ]; then
                    echo -e "  ${YELLOW}DELETE${NC} $sp/backups/ ($(human_size "$sp/backups")) [old-style]"
                fi
            done
        else
            echo -e "  ${GREEN}(no storage paths found)${NC}"
        fi
    fi

    if [[ "$LEVEL" == "nuclear" ]]; then
        echo ""
        echo -e "${RED}Nuclear:${NC}"
        echo -e "  ${RED}DELETE${NC} controller.yaml"
        echo -e "  ${RED}DELETE${NC} controller container + image"
        echo -e "  ${RED}DELETE${NC} Traefik container"
        echo -e "  ${RED}DELETE${NC} Cloudflared container"
        echo -e "  ${RED}DELETE${NC} Portainer container + volume"
        echo -e "  ${RED}DELETE${NC} .felhom-infra-backup/ (DR markers on all drives)"
        echo -e "  ${RED}DELETE${NC} All Docker data (docker system prune -af --volumes)"
    fi

    echo ""
    echo -e "${CYAN}Will preserve:${NC}"
    echo -e "  ${GREEN}- OS and system files${NC}"
    if [[ "$LEVEL" != "nuclear" ]]; then
        echo -e "  ${GREEN}- Controller container (felhom-controller)${NC}"
        echo -e "  ${GREEN}- Controller image${NC}"
        echo -e "  ${GREEN}- Traefik, Cloudflare Tunnel${NC}"
        echo -e "  ${GREEN}- controller.yaml${NC}"
        echo -e "  ${GREEN}- .felhom-infra-backup/ (DR markers on drives)${NC}"
    fi
    if [[ "$LEVEL" != "full" && "$LEVEL" != "nuclear" ]]; then
        echo -e "  ${GREEN}- Storage data on drives${NC}"
    fi
    echo -e "  ${GREEN}- User files (Dokumentumok, media, etc.)${NC}"
    echo ""
}

# --- Wipe Functions ---

do_soft_wipe() {
    info "Soft wipe: removing controller state..."
    local state_files=("$DATA_DIR/settings.json" "$DATA_DIR/metrics.db" "$DATA_DIR/setup-state.json" "$DATA_DIR/update-state.json" "$DATA_DIR/session-data.json" "$DATA_DIR/snapshot-history.json")
    for f in "${state_files[@]}"; do
        [ -f "$f" ] && rm -f "$f" && info "  Removed: $f"
    done
}

do_controller_wipe() {
    do_soft_wipe

    info "Controller wipe: stopping and removing app containers..."

    # Stop and remove app containers
    local containers
    containers=$(list_app_containers)
    if [ -n "$containers" ]; then
        echo "$containers" | while read -r c; do
            docker rm -f "$c" 2>/dev/null && info "  Removed container: $c" || warn "  Failed to remove: $c"
        done
    fi

    # Remove app volumes
    info "Removing app volumes..."
    local volumes
    volumes=$(list_app_volumes)
    if [ -n "$volumes" ]; then
        echo "$volumes" | while read -r v; do
            docker volume rm "$v" 2>/dev/null && info "  Removed volume: $v" || warn "  Failed to remove: $v"
        done
    fi

    # Remove stack directories
    info "Removing stack directories..."
    if [ -d "$STACKS_DIR" ]; then
        local protected_stacks
        protected_stacks=$(get_protected_stacks)
        for sd in "$STACKS_DIR"/*/; do
            [ -d "$sd" ] || continue
            local stack_name
            stack_name=$(basename "$sd")
            if echo "$protected_stacks" | grep -qx "$stack_name" && ! $INCLUDE_PROTECTED; then
                warn "  Skipping protected stack: $stack_name"
                continue
            fi
            rm -rf "$sd" && info "  Removed: $sd"
        done
    fi

    # NOTE: No restart here — callers handle restart after all cleanup is done.
}

do_full_wipe() {
    do_controller_wipe

    info "Full wipe: removing storage data..."
    for sp in "${STORAGE_PATHS[@]}"; do
        # New-style namespace
        if [ -d "$sp/felhom-data" ]; then
            rm -rf "$sp/felhom-data" && info "  Removed: $sp/felhom-data/"
        fi
        # Old-style paths
        if [ -d "$sp/appdata" ]; then
            rm -rf "$sp/appdata" && info "  Removed: $sp/appdata/ [old-style]"
        fi
        if [ -d "$sp/backups" ]; then
            rm -rf "$sp/backups" && info "  Removed: $sp/backups/ [old-style]"
        fi
    done

    # Restart controller after all cleanup is done
    info "Restarting controller..."
    docker restart felhom-controller 2>/dev/null || warn "Could not restart controller"
}

do_nuclear_wipe() {
    do_full_wipe

    info "Nuclear wipe: removing all infrastructure..."

    # Stop infrastructure containers
    for c in felhom-controller traefik cloudflared portainer; do
        docker rm -f "$c" 2>/dev/null && info "  Removed: $c" || true
    done

    # Remove controller.yaml
    [ -f "$CONTROLLER_YAML" ] && rm -f "$CONTROLLER_YAML" && info "  Removed: controller.yaml"

    # Remove DR markers (nuclear = brand-new machine simulation)
    for sp in "${STORAGE_PATHS[@]}"; do
        if [ -d "$sp/.felhom-infra-backup" ]; then
            rm -rf "$sp/.felhom-infra-backup" && info "  Removed: $sp/.felhom-infra-backup/"
        fi
    done

    # Remove all Docker data
    warn "Pruning all Docker data..."
    docker system prune -af --volumes 2>/dev/null || warn "Docker prune failed"

    echo ""
    info "Nuclear wipe complete."
    echo -e "${CYAN}To redeploy, run:${NC}"
    echo "  curl -fsSL https://gitea.dooplex.hu/admin/deploy-felhom-compose/raw/branch/main/scripts/docker-setup.sh | bash"
}

# --- Main ---
main() {
    # Must run as root
    if [ "$(id -u)" -ne 0 ]; then
        die "Must run as root (use sudo)"
    fi

    # Check Docker
    if ! docker info >/dev/null 2>&1; then
        die "Docker is not running"
    fi

    parse_args "$@"
    detect_paths
    detect_storage_paths
    print_plan

    if $DRY_RUN; then
        warn "Dry run — nothing deleted. Use --yes to execute."
        exit 0
    fi

    # Confirmation
    echo -e "${RED}${BOLD}This will permanently delete the data listed above.${NC}"
    read -rp "Type YES to confirm: " confirm
    if [ "$confirm" != "YES" ]; then
        echo "Aborted."
        exit 1
    fi

    echo ""
    case "$LEVEL" in
        soft)       do_soft_wipe ;;
        controller) do_controller_wipe
                    info "Restarting controller..."
                    docker restart felhom-controller 2>/dev/null || warn "Could not restart controller"
                    ;;
        full)       do_full_wipe ;;
        nuclear)    do_nuclear_wipe ;;
    esac

    echo ""
    info "Wipe complete (level: $LEVEL)."
}

main "$@"

Phase 8: Version Bump

File: controller/cmd/controller/main.go

Find the Version constant and update:

const Version = "0.26.0"

Phase 9: Documentation

9a. CHANGELOG.md

Read first 30 lines for format reference, then insert new entry at top of changelog:

## v0.26.0 — 2026-02-XX

### Changed
- **Storage namespace**: All felhom-managed data on external drives now lives under `felhom-data/` subdirectory, cleanly separating from user files
  - Path helpers (`paths.go`) updated: 8 functions now include `felhom-data` segment
  - `InfraBackupDir()` unchanged — stays at drive root for DR scanner
  - `ProtectedHDDPaths()` updated with namespace-aware paths
  - Fixed hardcoded paths in `delete.go`, `migrate.go`, `migrate_drive.go`
  - Format and attach wizards now create `felhom-data/` instead of legacy `storage/` subdirectory
  - Fixed legacy `"storage"` path reference in `handlers.go` (was dead code)
  - Drive migration rsync excludes updated for new path structure
  - Drive migration size estimation updated for namespace directory

### Added
- `scripts/felhom-wipe.sh` — Test node cleanup script with 4 wipe levels (soft, controller, full, nuclear)
- `backup.FelhomDataDir` constant for namespace directory name

### Notes
- Pre-v0.26.0 restic snapshots use old path structure without `felhom-data/`
- App-catalog compose templates need separate update (see post-implementation task)
- `HDD_PATH` env var value unchanged — still the mount point (e.g., `/mnt/hdd_1`)

9b. Controller README.md

Update the "Storage Layout" section to show the new felhom-data/ structure. Update path helper documentation. Add wipe script to scripts section. Add note about pre-v0.26.0 restic snapshot paths.


Complete File Change List

# File Action Phase
1 internal/backup/paths.go Edit: add constant + update 8 functions 1
2 internal/stacks/delete.go Edit: add local constant + fix ProtectedHDDPaths() + fix GetStackBackupData() 2a, 2b
3 internal/storage/migrate_drive.go Edit: add import + fix conflict check + fix verify + fix rsync excludes + fix size estimation 2c, 2d, 2e
4 internal/storage/migrate.go Edit: add import + fix DB dump copy paths 2f
5 internal/web/handlers.go Edit: fix legacy "storage" path 2g
6 internal/storage/format_linux.go Edit: replace "storage" with "felhom-data" 3a
7 internal/storage/attach_linux.go Edit: replace "storage" with "felhom-data" 3b
8 scripts/felhom-wipe.sh New file 7
9 cmd/controller/main.go Edit: version bump 8
10 CHANGELOG.md Edit: add v0.26.0 entry 9a
11 README.md Edit: update storage layout + paths + scripts sections 9b

Testing Checklist

  1. go build ./... succeeds
  2. go vet ./... passes
  3. grep -rn '"appdata"' controller/internal/ --include='*.go' | grep -v paths.go — only display/template contexts
  4. grep -rn '"backups"' controller/internal/ --include='*.go' | grep -v paths.go — only display/template contexts
  5. Verify felhom-wipe.sh has correct bash syntax: bash -n scripts/felhom-wipe.sh

Post-Implementation: App Catalog Update (SEPARATE TASK)

After this task, update app-catalog-felhom.eu compose templates:

${HDD_PATH}/appdata/  →  ${HDD_PATH}/felhom-data/appdata/
${HDD_PATH}/backups/  →  ${HDD_PATH}/felhom-data/backups/

Media paths (${HDD_PATH}/media/) stay at drive root (user data, not felhom-managed).


Important Reminders

  • FelhomDataDir = "felhom-data" (hyphen, not underscore)
  • InfraBackupDir() does NOT get namespace — stays at drive root
  • stacks/delete.go duplicates the constant (can't import backup)
  • storage/migrate.go and storage/migrate_drive.go CAN import backup (no cycle)
  • Wipe script handles BOTH old-style and new-style paths
  • HDD_PATH env var in app.yaml stays as mount point — does NOT include felhom-data