Files
deploy-felhom-compose/scripts/hdd-setup.sh
T
admin 59ed4bd1c2 feat: orphan stack detection/deletion, filebrowser infra, setup scripts
- Add orphan detection: stacks not in catalog marked as "Elavult"
- Add DELETE /api/stacks/{name} endpoint with HDD data handling
- Add GET /api/stacks/{name}/hdd-data endpoint
- Add delete confirmation modal with HDD data checkbox (Hungarian UI)
- Add filebrowser to protected stacks list
- Add scripts/hdd-setup.sh and scripts/docker-setup.sh for node setup
- Hide "Frissítés" and "Részletek" buttons for orphaned stacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:03:10 +01:00

1252 lines
42 KiB
Bash

#!/bin/bash
#===============================================================================
# HDD Detection & Setup Script v1.0
# Detects unmounted HDDs, checks state, formats, mounts, creates folder structure
#
# This script safely prepares external/additional drives for Felhom homeserver:
# - Detects unmounted block devices (HDDs and SSDs)
# - Shows SMART health, partition tables, existing filesystems
# - Formats with ext4 (only after explicit confirmation + safety checks)
# - Mounts by UUID in /etc/fstab with proper options
# - Creates standard Felhom media + storage folder structure
#
# Usage:
# sudo ./hdd-setup.sh [OPTIONS]
#
# Options:
# --scan Scan and report only (no changes)
# --mount-point PATH Pre-set mount point (skip prompt)
# --skip-smart Skip SMART health check
# --skip-folders Skip folder structure creation
# --dry-run Show what would be done without making changes
# --debug Enable bash debug tracing (set -x)
# -h, --help Show this help message
#
# Safety:
# - Never formats a drive with existing filesystems without triple confirmation
# - Shows all existing data/partitions before any destructive action
# - Writes fstab backup before modifications
# - Uses nofail mount option to prevent boot failures
# - All destructive operations require typing "YES" (not just y/n)
#
#===============================================================================
set -euo pipefail
#-------------------------------------------------------------------------------
# Configuration
#-------------------------------------------------------------------------------
SCRIPT_VERSION="1.0.1"
# Default values
SCAN_ONLY=false
PRESET_MOUNT_POINT=""
SKIP_SMART=false
SKIP_FOLDERS=false
DRY_RUN=false
DEBUG_MODE=false
# Folder structure owner (UID:GID for Docker containers, typically 1000:1000)
FOLDER_UID=1000
FOLDER_GID=1000
# Standard Felhom folder structure
declare -a MEDIA_DIRS=(
"media/downloads/complete"
"media/downloads/incomplete"
"media/movies"
"media/series"
"media/music"
"media/books"
)
declare -a STORAGE_DIRS=(
"storage/immich"
"storage/nextcloud"
"storage/backups/local"
"storage/backups/appdata"
)
declare -a USER_DIRS=(
"Dokumentumok"
)
declare -a APPDATA_DIRS=(
"appdata"
)
#-------------------------------------------------------------------------------
# Colors and logging (matching docker-setup.sh conventions)
#-------------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_skip() { echo -e "${CYAN}[SKIP]${NC} $1"; }
log_debug() { if [[ "$DEBUG_MODE" == true ]]; then echo -e "${CYAN}[DEBUG]${NC} $1"; fi; }
log_danger() { echo -e "${RED}${BOLD}[DANGER]${NC} $1"; }
#-------------------------------------------------------------------------------
# Error handling
#-------------------------------------------------------------------------------
on_error() {
echo ""
log_error "Script failed at line $1. Collecting diagnostics..."
echo "--- Block Devices ---"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT 2>/dev/null || true
echo "--- /etc/fstab ---"
cat /etc/fstab 2>/dev/null || true
echo "--- Mount status ---"
mount 2>/dev/null | grep -E "^/dev/" || true
}
trap 'on_error $LINENO' ERR
# Cleanup trap for temp files
TEMP_FILES=()
LOCK_FILE="/tmp/.hdd_setup.lock"
cleanup() {
rm -f /tmp/.hdd_setup_part_dev 2>/dev/null || true
rm -f "$LOCK_FILE" 2>/dev/null || true
for f in "${TEMP_FILES[@]}"; do
rm -f "$f" 2>/dev/null || true
done
}
trap cleanup EXIT
#-------------------------------------------------------------------------------
# Helpers
#-------------------------------------------------------------------------------
print_banner() {
echo ""
echo -e "${BOLD}${BLUE}-==================================================================¬${NC}"
echo -e "${BOLD}${BLUE}¦ HDD Detection & Setup Script v${SCRIPT_VERSION} ¦${NC}"
echo -e "${BOLD}${BLUE}L==================================================================-${NC}"
echo ""
}
print_help() {
cat << 'EOF'
HDD Detection & Setup Script v1.0
Safely detects, formats, mounts, and prepares external drives for Felhom homeserver.
USAGE:
sudo ./hdd-setup.sh [OPTIONS]
OPTIONS:
--scan Scan and report only (no changes made)
--mount-point PATH Pre-set mount point (e.g., /mnt/hdd_1)
--skip-smart Skip SMART health check
--skip-folders Skip folder structure creation
--dry-run Show what would be done without making changes
--debug Enable verbose debug output
-h, --help Show this help
WHAT THIS SCRIPT DOES:
1. Detects unmounted block devices
2. Shows disk info (size, partitions, SMART health)
3. Lets you select a disk to set up
4. Formats with ext4 (with safety confirmations)
5. Mounts via UUID in /etc/fstab (with nofail)
6. Creates Felhom folder structure
FOLDER STRUCTURE CREATED:
<mount_point>/
├── Dokumentumok/
├── media/
│ ├── downloads/complete/
│ ├── downloads/incomplete/
│ ├── movies/
│ ├── series/
│ ├── music/
│ └── books/
├── storage/
│ ├── immich/
│ ├── nextcloud/
│ └── backups/
│ ├── local/
│ └── appdata/
└── appdata/
SAFETY:
- Drives with existing data require typing "YES" to format
- fstab is backed up before any modification
- Uses 'nofail' mount option (system boots even if drive missing)
- --scan mode for non-destructive inspection
EOF
exit 0
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--scan) SCAN_ONLY=true; shift ;;
--mount-point) PRESET_MOUNT_POINT="$2"; shift 2 ;;
--skip-smart) SKIP_SMART=true; shift ;;
--skip-folders) SKIP_FOLDERS=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--debug) DEBUG_MODE=true; shift ;;
-h|--help) print_help ;;
*) log_error "Unknown option: $1"; print_help ;;
esac
done
}
check_root() {
if [[ $EUID -ne 0 ]]; then
log_error "This script must be run as root (use sudo)"
exit 1
fi
}
install_dependencies() {
local missing=()
# smartmontools for SMART data
if ! command -v smartctl &> /dev/null && [[ "$SKIP_SMART" == false ]]; then
missing+=("smartmontools")
fi
# parted for partition inspection
if ! command -v parted &> /dev/null; then
missing+=("parted")
fi
# util-linux should be present, but check for blkid/lsblk
if ! command -v blkid &> /dev/null; then
missing+=("util-linux")
fi
if [[ ${#missing[@]} -gt 0 ]]; then
log_info "Installing missing dependencies: ${missing[*]}"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would install: ${missing[*]}"
return
fi
apt-get update -qq
apt-get install -qq -y "${missing[@]}" > /dev/null 2>&1
log_success "Dependencies installed"
fi
}
#-------------------------------------------------------------------------------
# Disk detection
#-------------------------------------------------------------------------------
# Get the device name of the root filesystem (e.g., "sda")
get_root_disk() {
local root_dev
root_dev=$(findmnt -n -o SOURCE / 2>/dev/null | head -1)
# Handle LVM: /dev/mapper/vg-root -> find underlying PV
if [[ "$root_dev" == /dev/mapper/* ]]; then
if command -v pvs &> /dev/null; then
root_dev=$(pvs --noheadings -o pv_name 2>/dev/null | head -1 | tr -d ' ')
fi
fi
# Handle partitions: /dev/sda2 -> sda, /dev/nvme0n1p2 -> nvme0n1
local disk_name
disk_name=$(lsblk -no PKNAME "$root_dev" 2>/dev/null | head -1)
if [[ -z "$disk_name" ]]; then
# Fallback: strip partition number
disk_name=$(echo "$root_dev" | sed 's|/dev/||' | sed 's/[0-9]*$//' | sed 's/p$//')
fi
echo "$disk_name"
}
# Get list of disks that are NOT the system disk and NOT mounted
get_candidate_disks() {
local root_disk
root_disk=$(get_root_disk)
log_debug "Root disk detected as: $root_disk"
local candidates=()
# Iterate over whole-disk block devices (not partitions)
while IFS= read -r line; do
local name size type
name=$(echo "$line" | awk '{print $1}')
size=$(echo "$line" | awk '{print $2}')
type=$(echo "$line" | awk '{print $3}')
# Skip if this IS the root disk
if [[ "$name" == "$root_disk" ]]; then
log_debug "Skipping $name (root disk)"
continue
fi
# Skip if type is not "disk"
if [[ "$type" != "disk" ]]; then
log_debug "Skipping $name (type: $type)"
continue
fi
# Skip very small devices (<1GB) - likely USB sticks with boot media, etc.
local size_bytes
size_bytes=$(lsblk -bno SIZE "/dev/$name" 2>/dev/null | head -1)
if [[ -n "$size_bytes" ]] && [[ "$size_bytes" -lt 1073741824 ]]; then
log_debug "Skipping $name (too small: $size)"
continue
fi
# Skip loop, ram, zram devices
if [[ "$name" =~ ^(loop|ram|zram) ]]; then
log_debug "Skipping $name (virtual device)"
continue
fi
# Check if ANY partition of this disk is mounted
local any_mounted=false
while IFS= read -r part_line; do
local part_name part_mount
part_name=$(echo "$part_line" | awk '{print $1}')
part_mount=$(echo "$part_line" | awk '{print $7}')
if [[ -n "$part_mount" ]]; then
any_mounted=true
log_debug "Skipping $name (partition $part_name mounted at $part_mount)"
break
fi
done < <(lsblk -lno NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT "/dev/$name" 2>/dev/null | tail -n +2)
# Also check if the disk itself is mounted (whole-disk filesystem)
local disk_mount
disk_mount=$(lsblk -lno MOUNTPOINT "/dev/$name" 2>/dev/null | head -1)
if [[ -n "$disk_mount" ]]; then
any_mounted=true
log_debug "Skipping $name (disk mounted at $disk_mount)"
fi
if [[ "$any_mounted" == true ]]; then
continue
fi
candidates+=("$name")
done < <(lsblk -lno NAME,SIZE,TYPE 2>/dev/null | grep ' disk$')
echo "${candidates[@]}"
}
#-------------------------------------------------------------------------------
# Disk inspection
#-------------------------------------------------------------------------------
print_disk_info() {
local disk="$1"
local dev="/dev/$disk"
echo ""
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD} Disk: /dev/$disk${NC}"
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Basic info
local size model serial rotational transport
size=$(lsblk -dno SIZE "$dev" 2>/dev/null | tr -d ' ')
model=$(lsblk -dno MODEL "$dev" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
serial=$(lsblk -dno SERIAL "$dev" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
rotational=$(cat /sys/block/"$disk"/queue/rotational 2>/dev/null || echo "?")
transport=$(lsblk -dno TRAN "$dev" 2>/dev/null | tr -d ' ')
local disk_type="Unknown"
if [[ "$rotational" == "1" ]]; then
disk_type="HDD (rotational)"
elif [[ "$rotational" == "0" ]]; then
disk_type="SSD (non-rotational)"
fi
echo -e " ${BOLD}Size:${NC} $size"
echo -e " ${BOLD}Model:${NC} ${model:-Unknown}"
echo -e " ${BOLD}Serial:${NC} ${serial:-Unknown}"
echo -e " ${BOLD}Type:${NC} $disk_type"
echo -e " ${BOLD}Transport:${NC} ${transport:-Unknown}"
# Partition table
echo ""
echo -e " ${BOLD}Partition layout:${NC}"
local part_output
part_output=$(lsblk -o NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINT "$dev" 2>/dev/null)
echo "$part_output" | sed 's/^/ /'
# Check for existing filesystems on whole disk and partitions
echo ""
echo -e " ${BOLD}Filesystem detection:${NC}"
local has_data=false
# Check whole disk
local whole_fs
whole_fs=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "")
if [[ -n "$whole_fs" ]]; then
local whole_label
whole_label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "")
echo -e " ${YELLOW}$dev: filesystem=$whole_fs label=${whole_label:-<none>}${NC}"
has_data=true
fi
# Check each partition
while IFS= read -r part; do
[[ -z "$part" ]] && continue
local part_dev="/dev/$part"
local part_fs part_label part_uuid
part_fs=$(blkid -o value -s TYPE "$part_dev" 2>/dev/null || echo "")
part_label=$(blkid -o value -s LABEL "$part_dev" 2>/dev/null || echo "")
part_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "")
if [[ -n "$part_fs" ]]; then
echo -e " ${YELLOW}$part_dev: filesystem=$part_fs label=${part_label:-<none>} uuid=${part_uuid:-<none>}${NC}"
has_data=true
else
echo -e " $part_dev: ${GREEN}no filesystem detected${NC}"
fi
done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2)
if [[ "$has_data" == false ]]; then
echo -e " ${GREEN}No filesystems detected (clean disk)${NC}"
fi
# SMART health
if [[ "$SKIP_SMART" == false ]] && command -v smartctl &> /dev/null; then
echo ""
echo -e " ${BOLD}SMART Health:${NC}"
local smart_output
smart_output=$(smartctl -H "$dev" 2>/dev/null || echo "SMART not available")
if echo "$smart_output" | grep -q "PASSED"; then
echo -e " ${GREEN}SMART overall: PASSED${NC}"
elif echo "$smart_output" | grep -q "FAILED"; then
echo -e " ${RED}${BOLD}SMART overall: FAILED - DO NOT USE THIS DISK${NC}"
else
echo -e " ${YELLOW}SMART: Not available (USB enclosures often don't support SMART)${NC}"
fi
# Show key SMART attributes if available
local reallocated power_on temp
reallocated=$(smartctl -A "$dev" 2>/dev/null | grep -i "Reallocated_Sector" | awk '{print $NF}' || echo "")
power_on=$(smartctl -A "$dev" 2>/dev/null | grep -i "Power_On_Hours" | awk '{print $NF}' || echo "")
temp=$(smartctl -A "$dev" 2>/dev/null | grep -i "Temperature_Celsius" | awk '{print $NF}' || echo "")
if [[ -n "$reallocated" ]]; then
if [[ "$reallocated" -gt 0 ]] 2>/dev/null; then
echo -e " ${YELLOW}Reallocated sectors: $reallocated (some wear detected)${NC}"
else
echo -e " Reallocated sectors: $reallocated"
fi
fi
[[ -n "$power_on" ]] && echo " Power-on hours: $power_on"
[[ -n "$temp" ]] && echo " Temperature: ${temp}°C"
fi
# Return whether disk has data (for caller to use)
if [[ "$has_data" == true ]]; then
return 1
else
return 0
fi
}
#-------------------------------------------------------------------------------
# Disk formatting
#-------------------------------------------------------------------------------
# Check if a disk has any filesystem signatures anywhere
disk_has_data() {
local disk="$1"
local dev="/dev/$disk"
# Check whole disk
if blkid "$dev" &>/dev/null; then
return 0 # has data
fi
# Check all partitions
while IFS= read -r part; do
[[ -z "$part" ]] && continue
if blkid "/dev/$part" &>/dev/null; then
return 0 # has data
fi
done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2)
return 1 # no data
}
# Check if disk is referenced anywhere in fstab
disk_in_fstab() {
local disk="$1"
local dev="/dev/$disk"
# Check by device path
if grep -q "$dev" /etc/fstab 2>/dev/null; then
return 0
fi
# Check by UUID of any partition
while IFS= read -r part; do
[[ -z "$part" ]] && continue
local part_uuid
part_uuid=$(blkid -o value -s UUID "/dev/$part" 2>/dev/null || echo "")
if [[ -n "$part_uuid" ]] && grep -q "$part_uuid" /etc/fstab 2>/dev/null; then
return 0
fi
done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2)
# Check whole disk UUID
local disk_uuid
disk_uuid=$(blkid -o value -s UUID "$dev" 2>/dev/null || echo "")
if [[ -n "$disk_uuid" ]] && grep -q "$disk_uuid" /etc/fstab 2>/dev/null; then
return 0
fi
return 1
}
format_disk() {
local disk="$1"
local dev="/dev/$disk"
echo ""
log_step "Preparing to format /dev/$disk"
# Safety check: Is the disk part of a RAID array?
if command -v mdadm &>/dev/null; then
if mdadm --examine "$dev" &>/dev/null || mdadm --examine "${dev}"* &>/dev/null 2>&1; then
log_error "Disk /dev/$disk appears to be part of a RAID array!"
log_error "Remove it from the array first with: mdadm --zero-superblock $dev"
return 1
fi
fi
# Check for RAID signatures via blkid
if blkid "$dev" 2>/dev/null | grep -qi "linux_raid_member"; then
log_error "Disk /dev/$disk has a RAID superblock! Cannot format."
return 1
fi
# Safety check: Is the disk used by LVM?
if command -v pvs &>/dev/null; then
if pvs "$dev" &>/dev/null 2>&1 || pvs "${dev}"* &>/dev/null 2>&1; then
log_error "Disk /dev/$disk is an LVM physical volume!"
log_error "Remove it first with: pvremove $dev"
return 1
fi
fi
# Safety check: Is the disk or any partition currently in use?
if lsof "$dev"* 2>/dev/null | grep -q .; then
log_error "Disk /dev/$disk has open file handles! Cannot proceed."
log_error "Another process is using this disk."
return 1
fi
# Safety check: Is it in fstab?
if disk_in_fstab "$disk"; then
log_warn "This disk is referenced in /etc/fstab!"
grep -E "(${dev}|$(blkid -o value -s UUID "$dev" 2>/dev/null || echo 'NOUUID'))" /etc/fstab 2>/dev/null | sed 's/^/ /'
echo ""
log_warn "Proceeding will remove the old fstab entry."
fi
local has_existing_data=false
if disk_has_data "$disk"; then
has_existing_data=true
fi
if [[ "$has_existing_data" == true ]]; then
echo ""
log_danger "═══════════════════════════════════════════════════════════════"
log_danger " THIS DISK CONTAINS DATA! Formatting will DESTROY everything."
log_danger "═══════════════════════════════════════════════════════════════"
echo ""
echo -e " Disk: ${BOLD}/dev/$disk${NC}"
echo -e " Existing filesystems:"
blkid "$dev"* 2>/dev/null | sed 's/^/ /' || true
echo ""
# First confirmation
echo -e "${RED}${BOLD}Type the disk name (e.g., sdb) to confirm you want to DESTROY ALL DATA:${NC}"
read -r confirm_name
if [[ "$confirm_name" != "$disk" ]]; then
log_info "Disk name did not match. Aborting format."
return 1
fi
# Second confirmation
echo -e "${RED}${BOLD}Type YES (uppercase) to confirm formatting /dev/$disk:${NC}"
read -r confirm_yes
if [[ "$confirm_yes" != "YES" ]]; then
log_info "Confirmation not received. Aborting format."
return 1
fi
else
echo ""
echo "This disk appears empty (no filesystems detected)."
read -p "Format /dev/$disk with ext4? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Skipping format."
return 1
fi
fi
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would format /dev/$disk with ext4"
return 0
fi
log_info "Wiping existing partition table on /dev/$disk..."
wipefs -a "$dev" 2>/dev/null || true
log_info "Creating GPT partition table..."
parted -s "$dev" mklabel gpt
log_info "Creating single ext4 partition spanning entire disk..."
parted -s "$dev" mkpart primary ext4 0% 100%
# Wait for kernel to pick up partition
sleep 2
partprobe "$dev" 2>/dev/null || true
sleep 1
# Determine partition device name (sdb1 or nvme0n1p1)
local part_dev=""
# Try common naming patterns
if [[ -b "${dev}1" ]]; then
part_dev="${dev}1"
elif [[ -b "${dev}p1" ]]; then
part_dev="${dev}p1"
else
# Wait a bit more and retry
sleep 3
partprobe "$dev" 2>/dev/null || true
if [[ -b "${dev}1" ]]; then
part_dev="${dev}1"
elif [[ -b "${dev}p1" ]]; then
part_dev="${dev}p1"
else
log_error "Could not find partition device after partitioning!"
log_error "Expected ${dev}1 or ${dev}p1"
lsblk "$dev"
return 1
fi
fi
log_info "Formatting ${part_dev} as ext4..."
mkfs.ext4 -F -L "felhom_data" "$part_dev"
log_success "Disk formatted successfully"
echo " Partition: $part_dev"
echo " Filesystem: ext4"
echo " Label: felhom_data"
# Return the partition device for mounting
echo "$part_dev" > /tmp/.hdd_setup_part_dev
return 0
}
#-------------------------------------------------------------------------------
# Mount point selection and mounting
#-------------------------------------------------------------------------------
suggest_mount_point() {
# Find next available /mnt/hdd_N
local n=1
while [[ -d "/mnt/hdd_${n}" ]] && mountpoint -q "/mnt/hdd_${n}" 2>/dev/null; do
((n++))
done
# If dir exists but not mounted, it might be a leftover — still suggest it
if [[ -d "/mnt/hdd_${n}" ]] && [[ -z "$(ls -A /mnt/hdd_${n} 2>/dev/null)" ]]; then
echo "/mnt/hdd_${n}"
elif [[ ! -d "/mnt/hdd_${n}" ]]; then
echo "/mnt/hdd_${n}"
else
# Directory exists and has content, try next
((n++))
echo "/mnt/hdd_${n}"
fi
}
select_mount_point() {
local mount_point=""
if [[ -n "$PRESET_MOUNT_POINT" ]]; then
mount_point="$PRESET_MOUNT_POINT"
log_info "Using pre-set mount point: $mount_point" >&2
else
local suggested
suggested=$(suggest_mount_point)
echo "" >&2
log_step "Select mount point" >&2
echo "" >&2
echo " Suggested: $suggested" >&2
echo "" >&2
read -rp " Enter mount point [${suggested}]: " user_mount
mount_point="${user_mount:-$suggested}"
fi
# Validate mount point
if [[ ! "$mount_point" =~ ^/ ]]; then
log_error "Mount point must be an absolute path (start with /)" >&2
return 1
fi
# Check if something is already mounted there
if mountpoint -q "$mount_point" 2>/dev/null; then
log_error "$mount_point is already a mount point!" >&2
mount | grep "$mount_point" >&2
return 1
fi
# Check if directory exists and has content
if [[ -d "$mount_point" ]] && [[ -n "$(ls -A "$mount_point" 2>/dev/null)" ]]; then
log_warn "$mount_point exists and is not empty!" >&2
ls -la "$mount_point" >&2
echo "" >&2
log_warn "Mounting here will HIDE existing content (it won't be deleted, but inaccessible while mounted)." >&2
read -p "Continue with this mount point? [y/N] " -n 1 -r
echo >&2
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
return 1
fi
fi
echo "$mount_point"
}
mount_disk() {
local part_dev="$1"
local mount_point="$2"
echo ""
log_step "Mounting $part_dev at $mount_point"
# Get UUID
local uuid
uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null)
if [[ -z "$uuid" ]]; then
log_error "Could not determine UUID for $part_dev"
return 1
fi
log_info "Partition UUID: $uuid"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would create $mount_point"
log_info "[DRY RUN] Would add to /etc/fstab: UUID=$uuid $mount_point ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2"
log_info "[DRY RUN] Would mount $part_dev at $mount_point"
return 0
fi
# Create mount point
mkdir -p "$mount_point"
# Backup fstab
local fstab_backup
fstab_backup="/etc/fstab.backup.$(date +%Y%m%d_%H%M%S)"
cp /etc/fstab "$fstab_backup"
log_info "fstab backed up to: $fstab_backup"
# Remove any existing entry for this UUID (in case of re-setup)
if grep -q "$uuid" /etc/fstab 2>/dev/null; then
log_warn "Removing existing fstab entry for UUID=$uuid"
sed -i "\|$uuid|d" /etc/fstab
fi
# Add fstab entry
# nofail: system boots even if drive is missing
# x-systemd.device-timeout=10: don't wait forever during boot
# noatime: skip updating access timestamps (better performance)
local fstab_entry="UUID=$uuid $mount_point ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2"
{
echo ""
echo "# Felhom data drive - added by hdd-setup.sh on $(date '+%Y-%m-%d %H:%M:%S')"
echo "$fstab_entry"
} >> /etc/fstab
log_info "Added to /etc/fstab:"
echo " $fstab_entry"
# Verify fstab syntax (multiple methods - findmnt segfaults on some Debian 13 builds)
local fstab_ok=false
# Method 1: findmnt --verify (preferred, but may segfault on trixie)
local verify_exit=0
findmnt --verify --tab-file /etc/fstab &>/dev/null || verify_exit=$?
if [[ $verify_exit -eq 0 ]]; then
fstab_ok=true
log_debug "fstab verified via findmnt"
elif [[ $verify_exit -eq 139 ]] || [[ $verify_exit -gt 128 ]]; then
log_debug "findmnt segfaulted (exit $verify_exit), falling back to mount --fake"
fi
# Method 2: mount --fake -a (dry-run mount test)
if [[ "$fstab_ok" == false ]]; then
if mount --fake -a &>/dev/null; then
fstab_ok=true
log_debug "fstab verified via mount --fake"
fi
fi
# Method 3: Basic sanity check on the entry we just wrote
if [[ "$fstab_ok" == false ]]; then
# Verify our entry has all required fields (UUID, mountpoint, fstype, options, dump, pass)
local field_count
field_count=$(echo "$fstab_entry" | awk '{print NF}')
if [[ "$field_count" -eq 6 ]] && [[ "$fstab_entry" == UUID=* ]]; then
fstab_ok=true
log_debug "fstab verified via field count sanity check"
fi
fi
if [[ "$fstab_ok" == false ]]; then
log_error "fstab verification failed! Restoring backup..."
cp "$fstab_backup" /etc/fstab
log_info "fstab restored from backup"
return 1
fi
log_success "fstab syntax verified"
# Mount
mount "$mount_point"
if mountpoint -q "$mount_point"; then
log_success "Disk mounted at $mount_point"
# Show mounted disk info
df -h "$mount_point" | sed 's/^/ /'
else
log_error "Mount failed! Check dmesg for errors."
dmesg | tail -5
return 1
fi
}
#-------------------------------------------------------------------------------
# Folder structure creation
#-------------------------------------------------------------------------------
create_folder_structure() {
local mount_point="$1"
echo ""
log_step "Creating Felhom folder structure at $mount_point"
echo ""
echo -e " ${BOLD}Planned structure:${NC}"
echo " $mount_point/"
for dir in "${USER_DIRS[@]}"; do
echo " ├── $dir/"
done
for dir in "${MEDIA_DIRS[@]}"; do
echo " ├── $dir/"
done
for dir in "${STORAGE_DIRS[@]}"; do
echo " ├── $dir/"
done
for dir in "${APPDATA_DIRS[@]}"; do
echo " └── $dir/"
done
echo ""
echo " Owner: ${FOLDER_UID}:${FOLDER_GID} (standard Docker user)"
echo ""
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would create the above folder structure"
return 0
fi
read -p "Create this folder structure? [Y/n] " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
log_skip "Folder structure creation skipped"
return 0
fi
for dir in "${USER_DIRS[@]}" "${MEDIA_DIRS[@]}" "${STORAGE_DIRS[@]}" "${APPDATA_DIRS[@]}"; do
mkdir -p "${mount_point}/${dir}"
log_debug "Created: ${mount_point}/${dir}"
done
# Set ownership recursively
chown -R "${FOLDER_UID}:${FOLDER_GID}" "$mount_point"
# Set permissions: owner rwx, group rwx, others r-x
# This allows Docker containers running as 1000:1000 to read/write
chmod -R 775 "$mount_point"
# Verify
local created_count
created_count=$(find "$mount_point" -type d | wc -l)
log_success "Folder structure created ($created_count directories)"
echo ""
echo -e " ${BOLD}Result:${NC}"
# Use find to show the tree structure (works without 'tree' command)
(cd "$mount_point" && find . -type d -maxdepth 3 | sort | sed 's|^\.||' | sed 's|/| |g' | sed 's/^/ /')
}
#-------------------------------------------------------------------------------
# Existing partition handling
#-------------------------------------------------------------------------------
# Handle a disk that already has a usable ext4 partition
handle_existing_partition() {
local disk="$1"
local dev="/dev/$disk"
echo ""
log_step "Checking for usable existing partitions on /dev/$disk"
local usable_parts=()
local part_info=()
# Check whole disk for ext4
local whole_fs
whole_fs=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "")
if [[ "$whole_fs" == "ext4" ]]; then
usable_parts+=("$dev")
local whole_label whole_uuid
whole_label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "<none>")
whole_uuid=$(blkid -o value -s UUID "$dev" 2>/dev/null || echo "")
part_info+=("$dev fs=ext4 label=$whole_label uuid=$whole_uuid")
fi
# Check partitions
while IFS= read -r part; do
[[ -z "$part" ]] && continue
local part_dev="/dev/$part"
local part_fs
part_fs=$(blkid -o value -s TYPE "$part_dev" 2>/dev/null || echo "")
if [[ "$part_fs" == "ext4" ]]; then
usable_parts+=("$part_dev")
local p_label p_uuid
p_label=$(blkid -o value -s LABEL "$part_dev" 2>/dev/null || echo "<none>")
p_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "")
part_info+=("$part_dev fs=ext4 label=$p_label uuid=$p_uuid")
fi
done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2)
if [[ ${#usable_parts[@]} -eq 0 ]]; then
return 1 # No usable ext4 partitions
fi
echo ""
echo -e " ${GREEN}Found usable ext4 partition(s):${NC}"
for info in "${part_info[@]}"; do
echo " $info"
done
echo ""
echo "Options:"
echo " 1) Use existing ext4 partition (no format, no data loss)"
echo " 2) Wipe and reformat the entire disk"
echo " 3) Cancel"
echo ""
read -rp "Choose [1/2/3]: " choice
case "$choice" in
1)
# Use the first (or only) usable ext4 partition
local selected_part="${usable_parts[0]}"
if [[ ${#usable_parts[@]} -gt 1 ]]; then
echo ""
echo "Multiple ext4 partitions found. Select one:"
for i in "${!usable_parts[@]}"; do
echo " $((i+1))) ${part_info[$i]}"
done
read -rp "Choose [1-${#usable_parts[@]}]: " part_choice
local idx=$((part_choice - 1))
if [[ $idx -ge 0 && $idx -lt ${#usable_parts[@]} ]]; then
selected_part="${usable_parts[$idx]}"
else
log_error "Invalid selection"
return 1
fi
fi
log_info "Using existing partition: $selected_part"
# Run fsck before mounting
log_info "Running filesystem check on $selected_part..."
if ! fsck.ext4 -n "$selected_part" 2>/dev/null; then
log_warn "Filesystem has issues. Run 'fsck.ext4 -y $selected_part' manually to fix."
read -p "Try auto-fix now? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
fsck.ext4 -y "$selected_part" || true
fi
else
log_success "Filesystem check passed"
fi
echo "$selected_part" > /tmp/.hdd_setup_part_dev
return 0
;;
2)
# Will proceed to format
return 1
;;
3)
log_info "Cancelled."
exit 0
;;
*)
log_error "Invalid choice"
return 1
;;
esac
}
#-------------------------------------------------------------------------------
# Main flow
#-------------------------------------------------------------------------------
main() {
parse_args "$@"
check_root
# Logging
LOG_FILE="/var/log/hdd-setup.log"
mkdir -p /var/log 2>/dev/null || true
exec > >(tee -a "$LOG_FILE") 2>&1
if [[ "$DEBUG_MODE" == true ]]; then
set -x
fi
print_banner
# Prevent concurrent runs
if [[ -f "$LOCK_FILE" ]]; then
local lock_pid
lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
log_error "Another instance is already running (PID: $lock_pid)"
log_error "If this is incorrect, remove $LOCK_FILE"
exit 1
fi
fi
echo $$ > "$LOCK_FILE"
if [[ "$DRY_RUN" == true ]]; then
echo -e "${YELLOW}DRY RUN MODE - no changes will be made${NC}"
echo ""
fi
install_dependencies
#---------------------------------------------------------------------------
# Phase 1: Detect candidate disks
#---------------------------------------------------------------------------
log_step "Phase 1: Scanning for unmounted disks..."
local root_disk
root_disk=$(get_root_disk)
log_info "System disk: /dev/$root_disk (will be excluded)"
local candidates_str
candidates_str=$(get_candidate_disks)
if [[ -z "$candidates_str" ]]; then
echo ""
log_warn "No unmounted disks found!"
echo ""
echo " All detected disks:"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | sed 's/^/ /'
echo ""
echo " Possible reasons:"
echo " - All disks are already mounted"
echo " - External drive not connected/detected"
echo " - Drive may need a different driver"
echo ""
echo " Troubleshooting:"
echo " dmesg | tail -20 # Check kernel messages"
echo " lsusb # Check USB devices"
echo " lsblk # Full block device list"
exit 0
fi
# Convert to array
read -ra candidates <<< "$candidates_str"
echo ""
log_info "Found ${#candidates[@]} unmounted disk(s)"
#---------------------------------------------------------------------------
# Phase 2: Show disk information
#---------------------------------------------------------------------------
log_step "Phase 2: Inspecting disks..."
local disk_has_data_map=()
for disk in "${candidates[@]}"; do
local has_data=false
print_disk_info "$disk" || has_data=true
disk_has_data_map+=("$has_data")
done
if [[ "$SCAN_ONLY" == true ]]; then
echo ""
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD} Scan complete. Use without --scan to set up a disk.${NC}"
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
exit 0
fi
#---------------------------------------------------------------------------
# Phase 3: Select disk
#---------------------------------------------------------------------------
echo ""
log_step "Phase 3: Select a disk to set up"
echo ""
if [[ ${#candidates[@]} -eq 1 ]]; then
echo " Only one unmounted disk found: /dev/${candidates[0]}"
read -p " Set up this disk? [Y/n] " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
log_info "Cancelled."
exit 0
fi
selected_disk="${candidates[0]}"
else
echo " Available disks:"
for i in "${!candidates[@]}"; do
local d="${candidates[$i]}"
local s
s=$(lsblk -dno SIZE "/dev/$d" 2>/dev/null | tr -d ' ')
local m
m=$(lsblk -dno MODEL "/dev/$d" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
echo " $((i+1))) /dev/$d ($s, ${m:-Unknown})"
done
echo ""
read -rp " Select disk [1-${#candidates[@]}]: " disk_choice
local idx=$((disk_choice - 1))
if [[ $idx -lt 0 || $idx -ge ${#candidates[@]} ]]; then
log_error "Invalid selection"
exit 1
fi
selected_disk="${candidates[$idx]}"
fi
log_info "Selected: /dev/$selected_disk"
#---------------------------------------------------------------------------
# Phase 4: Format or use existing
#---------------------------------------------------------------------------
log_step "Phase 4: Prepare filesystem"
local part_dev=""
# Check if disk already has a usable ext4 partition
if handle_existing_partition "$selected_disk"; then
# User chose to use existing partition
part_dev=$(cat /tmp/.hdd_setup_part_dev 2>/dev/null)
rm -f /tmp/.hdd_setup_part_dev
else
# Need to format
if format_disk "$selected_disk"; then
part_dev=$(cat /tmp/.hdd_setup_part_dev 2>/dev/null)
rm -f /tmp/.hdd_setup_part_dev
else
log_error "Format failed or was cancelled."
exit 1
fi
fi
if [[ -z "$part_dev" ]] && [[ "$DRY_RUN" == false ]]; then
log_error "No partition device determined. Cannot proceed."
exit 1
fi
# For dry run, simulate a partition device
if [[ "$DRY_RUN" == true ]] && [[ -z "$part_dev" ]]; then
part_dev="/dev/${selected_disk}1"
fi
#---------------------------------------------------------------------------
# Phase 5: Mount
#---------------------------------------------------------------------------
log_step "Phase 5: Mount disk"
local mount_point
mount_point=$(select_mount_point)
if [[ -z "$mount_point" ]]; then
log_error "No mount point selected. Aborting."
exit 1
fi
mount_disk "$part_dev" "$mount_point"
#---------------------------------------------------------------------------
# Phase 6: Create folder structure
#---------------------------------------------------------------------------
if [[ "$SKIP_FOLDERS" == false ]]; then
log_step "Phase 6: Create folder structure"
create_folder_structure "$mount_point"
fi
#---------------------------------------------------------------------------
# Summary
#---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}${GREEN}-==================================================================¬${NC}"
echo -e "${BOLD}${GREEN}¦ HDD Setup Complete! ¦${NC}"
echo -e "${BOLD}${GREEN}L==================================================================-${NC}"
echo ""
echo -e " ${BOLD}Disk:${NC} /dev/$selected_disk"
echo -e " ${BOLD}Partition:${NC} $part_dev"
if [[ "$DRY_RUN" == false ]]; then
local final_uuid
final_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "N/A")
echo -e " ${BOLD}UUID:${NC} $final_uuid"
fi
echo -e " ${BOLD}Mount point:${NC} $mount_point"
echo -e " ${BOLD}Filesystem:${NC} ext4"
echo -e " ${BOLD}fstab:${NC} Configured (nofail)"
echo ""
if [[ "$DRY_RUN" == false ]]; then
echo -e " ${BOLD}Disk usage:${NC}"
df -h "$mount_point" | sed 's/^/ /'
echo ""
fi
echo -e " ${BOLD}Docker volume mount examples:${NC}"
echo " # Media stack (Sonarr/Radarr/Plex)"
echo " volumes:"
echo " - ${mount_point}/media:/data/media"
echo ""
echo " # Immich photo storage"
echo " volumes:"
echo " - ${mount_point}/storage/immich:/usr/src/app/upload"
echo ""
echo " # Nextcloud data"
echo " volumes:"
echo " - ${mount_point}/storage/nextcloud:/var/www/html/data"
echo ""
echo -e " ${BOLD}Verify mount survives reboot:${NC}"
echo " sudo mount -a # Test fstab now"
echo " sudo reboot # Full test (when convenient)"
echo ""
echo -e " ${BOLD}Quick check commands:${NC}"
echo " lsblk # See all block devices"
echo " df -h ${mount_point} # Disk usage"
echo " ls -la ${mount_point} # Folder structure"
echo ""
}
main "$@"