From 4843fae0ebe98ece550ebc94087734b209a81ba4 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 12 Apr 2026 20:32:34 +0200 Subject: [PATCH] Update scripts --- AppImage/components/login.tsx | 20 +- AppImage/components/storage-overview.tsx | 809 +++++++++++++- AppImage/scripts/flask_server.py | 590 ++++++++++ AppImage/scripts/notification_templates.py | 58 + scripts/global/disk_ops_helpers.sh | 385 +++++++ scripts/global/gpu_hook_guard_helpers.sh | 8 +- scripts/global/vm_storage_helpers.sh | 361 +++++- scripts/gpu_tpu/add_gpu_vm.sh | 252 +++-- scripts/gpu_tpu/gpu-tpu-manual-guide.sh | 160 +++ scripts/gpu_tpu/nvidia_installer.sh | 28 + scripts/gpu_tpu/nvidia_update.sh | 54 +- scripts/gpu_tpu/switch_gpu_mode.sh | 110 ++ scripts/gpu_tpu/switch_gpu_mode_direct.sh | 109 ++ scripts/help_info_menu.sh | 116 +- scripts/menus/create_vm_menu.sh | 9 +- scripts/menus/hw_grafics_menu.sh | 11 +- scripts/menus/main_menu.sh | 6 +- scripts/menus/menu_Helper_Scripts.sh | 4 +- scripts/menus/security_menu.sh | 5 +- scripts/menus/share_menu.sh | 35 +- scripts/menus/storage_menu.sh | 21 +- scripts/menus/utilities_menu.sh | 22 +- scripts/share/commands_share.sh | 550 +++++----- scripts/share/disk_host.sh | 765 ++++++++++--- scripts/share/iscsi_host.sh | 6 +- scripts/share/local-shared-manager.sh | 232 +++- scripts/share/lxc-mount-manager_minimal.sh | 266 ++++- scripts/share/nfs_host.sh | 2 +- scripts/storage/add_controller_nvme_vm.sh | 441 ++++++-- scripts/storage/disk-passthrough.sh | 454 ++++---- scripts/storage/disk-passthrough_ct.sh | 848 +++++++++------ scripts/storage/disk-storage-manual-guide.sh | 183 ++++ scripts/storage/format-disk.sh | 1023 +++++++++++++----- scripts/storage/import-disk-image.sh | 520 +++++---- scripts/storage/mount-disk-on-host.sh | 353 ------ scripts/storage/mount-point-to-ct.sh | 146 --- scripts/storage/mount_disk_host_bk.sh | 446 -------- scripts/storage/smart-disk-test.sh | 399 +++++++ scripts/storage/unmount-disk-from-host.sh | 73 -- scripts/utilities/export_vm_ova_ovf.sh | 628 +++++++++++ scripts/utilities/import_vm_ova_ovf.sh | 612 +++++++++++ scripts/vm/disk_selector.sh | 14 +- scripts/vm/select_linux_iso.sh | 6 +- scripts/vm/select_windows_iso.sh | 2 +- scripts/vm/synology.sh | 81 +- scripts/vm/vm_creator.sh | 23 +- scripts/vm/zimaos.sh | 81 +- 47 files changed, 8313 insertions(+), 3014 deletions(-) create mode 100644 scripts/global/disk_ops_helpers.sh create mode 100644 scripts/gpu_tpu/gpu-tpu-manual-guide.sh create mode 100644 scripts/storage/disk-storage-manual-guide.sh delete mode 100644 scripts/storage/mount-disk-on-host.sh delete mode 100644 scripts/storage/mount-point-to-ct.sh delete mode 100644 scripts/storage/mount_disk_host_bk.sh create mode 100644 scripts/storage/smart-disk-test.sh delete mode 100644 scripts/storage/unmount-disk-from-host.sh create mode 100755 scripts/utilities/export_vm_ova_ovf.sh create mode 100755 scripts/utilities/import_vm_ova_ovf.sh diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index 56036c0b..6f55e85d 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -7,7 +7,7 @@ import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Checkbox } from "./ui/checkbox" -import { Lock, User, AlertCircle, Server, Shield } from "lucide-react" +import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react" import { getApiUrl } from "../lib/api-config" import Image from "next/image" @@ -21,6 +21,7 @@ export function Login({ onLogin }: LoginProps) { const [totpCode, setTotpCode] = useState("") const [requiresTotp, setRequiresTotp] = useState(false) const [rememberMe, setRememberMe] = useState(false) + const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState("") const [loading, setLoading] = useState(false) @@ -161,14 +162,27 @@ export function Login({ onLogin }: LoginProps) { setPassword(e.target.value)} - className="pl-10 text-base" + className="pl-10 pr-10 text-base" disabled={loading} autoComplete="current-password" /> + diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 04da43a1..fd107a2f 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb } from "lucide-react" +import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" import { fetchApi } from "../lib/api-config" interface DiskInfo { @@ -44,6 +45,8 @@ interface DiskInfo { observations_count?: number connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown' removable?: boolean + is_system_disk?: boolean + system_usage?: string[] } interface DiskObservation { @@ -118,6 +121,7 @@ export function StorageOverview() { const [detailsOpen, setDetailsOpen] = useState(false) const [diskObservations, setDiskObservations] = useState([]) const [loadingObservations, setLoadingObservations] = useState(false) + const [activeModalTab, setActiveModalTab] = useState<"overview" | "smart">("overview") const fetchStorageData = async () => { try { @@ -838,12 +842,18 @@ export function StorageOverview() { >
{/* Row 1: Device name and type badge */} -
+

/dev/{disk.name}

{getDiskTypeBadge(disk.name, disk.rotation_rate).label} + {disk.is_system_disk && ( + + + System + + )}
{/* Row 2: Model, temperature, and health status */} @@ -930,6 +940,12 @@ export function StorageOverview() { {getDiskTypeBadge(disk.name, disk.rotation_rate).label} + {disk.is_system_disk && ( + + + System + + )}
{/* Row 2: Model, temperature, and health status */} @@ -1187,9 +1203,12 @@ export function StorageOverview() { )} {/* Disk Details Dialog */} - - - + { + setDetailsOpen(open) + if (!open) setActiveModalTab("overview") + }}> + + {selectedDisk?.connection_type === 'usb' ? ( @@ -1200,10 +1219,47 @@ export function StorageOverview() { {selectedDisk?.connection_type === 'usb' && ( USB )} + {selectedDisk?.is_system_disk && ( + + + System + + )} - Complete SMART information and health status + + {selectedDisk?.model !== "Unknown" ? selectedDisk?.model : "Physical disk"} - {selectedDisk?.size_formatted} + - {selectedDisk && ( + + {/* Tab Navigation */} +
+ + +
+ + {/* Tab Content */} +
+ {selectedDisk && activeModalTab === "overview" && (
@@ -1402,6 +1458,745 @@ export function StorageOverview() { )}
)} + + {/* SMART Test Tab */} + {selectedDisk && activeModalTab === "smart" && ( + + )} +
+ +
+
+ ) +} + +// SMART Test Tab Component +interface SmartTestTabProps { + disk: DiskInfo +} + +interface SmartTestStatus { + status: 'idle' | 'running' | 'completed' | 'failed' + test_type?: string + progress?: number + result?: string + last_test?: { + type: string + status: string + timestamp: string + duration?: string + } + smart_data?: { + device: string + model: string + serial: string + firmware: string + smart_status: string + temperature: number + power_on_hours: number + attributes: Array<{ + id: number + name: string + value: number + worst: number + threshold: number + raw_value: string + status: 'ok' | 'warning' | 'critical' + }> + } +} + +function SmartTestTab({ disk }: SmartTestTabProps) { + const [testStatus, setTestStatus] = useState({ status: 'idle' }) + const [loading, setLoading] = useState(true) + const [runningTest, setRunningTest] = useState<'short' | 'long' | null>(null) + const [showReport, setShowReport] = useState(false) + const [reportTab, setReportTab] = useState<'overview' | 'attributes' | 'history' | 'recommendations'>('overview') + + // Fetch current SMART status on mount + useEffect(() => { + fetchSmartStatus() + }, [disk.name]) + + const fetchSmartStatus = async () => { + try { + setLoading(true) + const data = await fetchApi(`/api/storage/smart/${disk.name}`) + setTestStatus(data) + } catch { + setTestStatus({ status: 'idle' }) + } finally { + setLoading(false) + } + } + + const runSmartTest = async (testType: 'short' | 'long') => { + try { + setRunningTest(testType) + await fetchApi(`/api/storage/smart/${disk.name}/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test_type: testType }) + }) + // Poll for status updates + const pollInterval = setInterval(async () => { + try { + const data = await fetchApi(`/api/storage/smart/${disk.name}`) + setTestStatus(data) + if (data.status !== 'running') { + clearInterval(pollInterval) + setRunningTest(null) + } + } catch { + clearInterval(pollInterval) + setRunningTest(null) + } + }, 5000) + } catch { + setRunningTest(null) + } + } + + if (loading) { + return ( +
+ +

Loading SMART data...

+
+ ) + } + + return ( +
+ {/* Quick Actions */} +
+

+ + Run SMART Test +

+
+ + + +
+

+ Short test takes ~2 minutes. Extended test runs in the background and can take several hours for large disks. + You will receive a notification when the test completes. +

+
+ + {/* Test Progress */} + {testStatus.status === 'running' && ( +
+
+ +
+

+ {testStatus.test_type === 'short' ? 'Short' : 'Extended'} test in progress +

+

+ Please wait while the test completes... +

+
+
+ {testStatus.progress !== undefined && ( + + )} +
+ )} + + {/* Last Test Result */} + {testStatus.last_test && ( +
+

+ + Last Test Result +

+
+
+
+ {testStatus.last_test.status === 'passed' ? ( + + ) : ( + + )} + + {testStatus.last_test.type === 'short' ? 'Short' : 'Extended'} Test - {' '} + {testStatus.last_test.status === 'passed' ? 'Passed' : 'Failed'} + +
+ + {testStatus.last_test.status} + +
+
+
+

Completed

+

{testStatus.last_test.timestamp}

+
+ {testStatus.last_test.duration && ( +
+

Duration

+

{testStatus.last_test.duration}

+
+ )} +
+
+
+ )} + + {/* SMART Attributes Summary */} + {testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 && ( +
+

+ + SMART Attributes +

+
+
+
ID
+
Attribute
+
Value
+
Worst
+
Status
+
+
+ {testStatus.smart_data.attributes.slice(0, 15).map((attr) => ( +
+
{attr.id}
+
{attr.name}
+
{attr.value}
+
{attr.worst}
+
+ {attr.status === 'ok' ? ( + + ) : attr.status === 'warning' ? ( + + ) : ( + + )} +
+
+ ))} +
+
+
+ )} + + {/* View Full Report Button */} +
+ +

+ Generate a comprehensive professional report with detailed analysis and recommendations. +

+
+ + {/* Full SMART Report Dialog */} + + + + + + SMART Health Report: /dev/{disk.name} + + + Comprehensive analysis of disk health, SMART attributes, and recommendations + + + + {/* Report Tabs */} +
+ + + + +
+ + {/* Report Content */} +
+ {/* Overview Tab */} + {reportTab === 'overview' && ( +
+ {/* Health Score Card */} +
+
+

Overall Health

+
+ {testStatus.smart_status === 'passed' ? ( + + ) : testStatus.smart_status === 'failed' ? ( + + ) : ( + + )} + + {testStatus.smart_status || 'Unknown'} + +
+
+ +
+

Temperature

+
+ + + {disk.temperature > 0 ? `${disk.temperature}°C` : 'N/A'} + +
+
+ +
+

Power On Time

+
+ + + {disk.power_on_hours ? `${disk.power_on_hours.toLocaleString()}h` : 'N/A'} + +
+
+
+ + {/* Executive Summary */} +
+

+ + Executive Summary +

+
+

+ {testStatus.smart_status === 'passed' ? ( + <> + This disk is operating within normal parameters. All SMART attributes are within acceptable thresholds, + indicating good health. The disk has been powered on for approximately{' '} + + {disk.power_on_hours ? `${Math.round(disk.power_on_hours / 24)} days` : 'an unknown period'} + {' '} + and is currently operating at{' '} + {disk.temperature || 'N/A'}°C. + {disk.reallocated_sectors === 0 && disk.pending_sectors === 0 + ? ' No bad sectors have been detected.' + : disk.reallocated_sectors && disk.reallocated_sectors > 0 + ? ` ${disk.reallocated_sectors} sectors have been reallocated, which may indicate early signs of wear.` + : ''} + + ) : testStatus.smart_status === 'failed' ? ( + <> + Warning: This disk has failed SMART health assessment.{' '} + One or more critical SMART attributes have exceeded their failure threshold. + It is strongly recommended to backup all data immediately and consider replacing this disk. + {disk.reallocated_sectors && disk.reallocated_sectors > 0 + ? ` The disk has ${disk.reallocated_sectors} reallocated sectors, indicating physical media degradation.` + : ''} + + ) : ( + <> + The disk health status could not be fully determined. Some SMART attributes may be showing warning signs. + It is recommended to run a full SMART self-test and monitor the disk closely. + + )} +

+
+
+ + {/* Key Metrics */} +
+

Key Metrics

+
+
+

Model

+

{disk.model || 'Unknown'}

+
+
+

Serial

+

{disk.serial?.replace(/\\x[0-9a-fA-F]{2}/g, '') || 'Unknown'}

+
+
+

Capacity

+

{disk.size_formatted || 'Unknown'}

+
+
+

Power Cycles

+

{disk.power_cycles?.toLocaleString() || 'N/A'}

+
+
+

Reallocated Sectors

+

0 ? 'text-yellow-500' : ''}`}> + {disk.reallocated_sectors ?? 0} +

+
+
+

Pending Sectors

+

0 ? 'text-yellow-500' : ''}`}> + {disk.pending_sectors ?? 0} +

+
+
+

CRC Errors

+

0 ? 'text-yellow-500' : ''}`}> + {disk.crc_errors ?? 0} +

+
+
+

Disk Type

+

+ {disk.name.startsWith('nvme') ? 'NVMe' : !disk.rotation_rate || disk.rotation_rate === 0 ? 'SSD' : 'HDD'} +

+
+
+
+
+ )} + + {/* Attributes Tab */} + {reportTab === 'attributes' && ( +
+
+

Understanding SMART Attributes

+

+ SMART (Self-Monitoring, Analysis and Reporting Technology) attributes are sensors built into hard drives and SSDs. + Each attribute has a current value, a worst recorded value, and a threshold. When the current value drops below the threshold, + the attribute is considered failed. Values typically decrease from 100 (or 200/253 on some drives) as the attribute degrades. +

+
+ + {testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 ? ( +
+
+
ID
+
Attribute Name
+
Value
+
Worst
+
Threshold
+
Status
+
+
+ {testStatus.smart_data.attributes.map((attr) => ( +
+
{attr.id}
+
+

{attr.name.replace(/_/g, ' ')}

+

Raw: {attr.raw_value}

+
+
{attr.value}
+
{attr.worst}
+
{attr.threshold}
+
+ {attr.status === 'ok' ? ( + + ) : attr.status === 'warning' ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ ) : ( +
+ +

No SMART attribute data available.

+

Run a SMART test to collect attribute data.

+
+ )} +
+ )} + + {/* History Tab */} + {reportTab === 'history' && ( +
+ {testStatus.last_test ? ( +
+
+

+ {testStatus.last_test.status === 'passed' ? ( + + ) : ( + + )} + Last Test Result +

+ + {testStatus.last_test.status} + +
+
+
+

Test Type

+

{testStatus.last_test.type}

+
+
+

Completed

+

{testStatus.last_test.timestamp}

+
+
+
+ ) : ( +
+ +

No test history available.

+

Run a SMART self-test to see results here.

+
+ )} + +
+

About Self-Tests

+
+

+ Short Test (~2 minutes): Performs a quick check of the disk's + basic functionality including read/seek tests on a small portion of the disk surface. +

+

+ Extended Test (hours): Performs a comprehensive surface scan + of the entire disk. Duration depends on disk size - typically 1-2 hours per TB. +

+
+
+
+ )} + + {/* Recommendations Tab */} + {reportTab === 'recommendations' && ( +
+ {/* Status-based recommendations */} + {testStatus.smart_status === 'passed' && ( +
+
+ +
+

Disk is Healthy

+

+ All SMART attributes are within normal ranges. Continue with regular monitoring. +

+
+
+
+ )} + + {testStatus.smart_status === 'failed' && ( +
+
+ +
+

Critical: Disk Replacement Recommended

+

+ This disk has failed SMART health assessment. Backup all data immediately and plan for disk replacement. +

+
+
+
+ )} + + {/* Conditional recommendations */} +
+

Recommendations

+ + {(disk.reallocated_sectors ?? 0) > 0 && ( +
+
+ +
+

Reallocated Sectors Detected

+

+ {disk.reallocated_sectors} sectors have been reallocated. This indicates the disk has found and + remapped bad sectors. Monitor this value - if it increases rapidly, consider replacing the disk. +

+
+
+
+ )} + + {(disk.pending_sectors ?? 0) > 0 && ( +
+
+ +
+

Pending Sectors Detected

+

+ {disk.pending_sectors} sectors are pending reallocation. These sectors may be unreadable. + Run an extended self-test to force reallocation attempts. +

+
+
+
+ )} + + {disk.temperature > 55 && ( +
+
+ +
+

Elevated Temperature

+

+ Current temperature ({disk.temperature}°C) is above optimal. Improve airflow or reduce disk activity. + Sustained high temperatures can reduce disk lifespan. +

+
+
+
+ )} + + {(disk.power_on_hours ?? 0) > 35000 && ( +
+
+ +
+

High Power-On Hours

+

+ This disk has been running for {Math.round((disk.power_on_hours ?? 0) / 8760)} years. + While still operational, consider planning for replacement as disks typically have a 3-5 year lifespan. +

+
+
+
+ )} + + {/* General best practices */} +
+

Best Practices

+
    +
  • + + Run a short SMART test monthly to catch early issues +
  • +
  • + + Run an extended test quarterly for comprehensive verification +
  • +
  • + + Maintain regular backups - SMART can detect some failures but not all +
  • +
  • + + Keep disk temperatures below 50°C for optimal lifespan +
  • +
  • + + Replace disks proactively after 4-5 years of heavy use +
  • +
+
+
+
+ )} +
+ + {/* Report Footer */} +
+

+ Report generated by ProxMenux Monitor +

+ +
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 4cd385d2..fe978537 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1784,6 +1784,270 @@ def is_disk_removable(disk_name): return False +def _is_system_mount(mountpoint): + """Check if mountpoint is a critical system path (matching bash scripts logic).""" + system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc', + '/lib', '/lib64', '/run', '/proc', '/sys') + if mountpoint in system_mounts: + return True + # Also check if it's under these paths + for prefix in ('/usr/', '/var/', '/lib/', '/lib64/'): + if mountpoint.startswith(prefix): + return True + return False + + +def _get_zfs_root_pool(): + """Get the ZFS pool containing the root filesystem, if any (matches bash _get_zfs_root_pool).""" + try: + result = subprocess.run(['df', '/'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + if len(lines) >= 2: + root_fs = lines[1].split()[0] + # A ZFS dataset looks like "rpool/ROOT/pve-1" — not /dev/ + if not root_fs.startswith('/dev/') and '/' in root_fs: + return root_fs.split('/')[0] + except Exception: + pass + return None + + +def _resolve_zfs_entry(entry): + """ + Resolve a ZFS device entry to a base disk name. + Handles: /dev/paths, by-id names, short kernel names (matches bash _resolve_zfs_entry). + """ + path = None + + try: + if entry.startswith('/dev/'): + path = os.path.realpath(entry) + elif os.path.exists(f'/dev/disk/by-id/{entry}'): + path = os.path.realpath(f'/dev/disk/by-id/{entry}') + elif os.path.exists(f'/dev/{entry}'): + path = os.path.realpath(f'/dev/{entry}') + + if path: + # Get parent disk (base disk without partition number) + result = subprocess.run( + ['lsblk', '-no', 'PKNAME', path], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + # If no parent, path is the base disk itself + return os.path.basename(path) + except Exception: + pass + return None + + +def get_system_disks(): + """ + Detect which physical disks are used by the system (Proxmox). + + Returns a dict mapping disk names to their system usage info: + { + 'sda': {'is_system': True, 'usage': ['root', 'boot']}, + 'nvme0n1': {'is_system': True, 'usage': ['zfs:rpool']}, + } + + Detects (matching logic from disk-passthrough.sh, format-disk.sh, disk_host.sh): + - Root filesystem (/) and critical system mounts (/boot, /usr, /var, etc.) + - Boot partition (/boot, /boot/efi) + - Active swap partitions + - ZFS pools (especially root pool - these disks are critical) + - LVM physical volumes (especially 'pve' volume group) + - RAID members (mdadm) + - Disks with Proxmox partition labels + """ + system_disks = {} + + def add_usage(disk_name, usage_type): + """Helper to add a usage type to a disk.""" + if not disk_name: + return + # Normalize disk name (strip partition numbers to get base disk) + base_disk = disk_name + if disk_name and disk_name[-1].isdigit(): + if 'nvme' in disk_name or 'mmcblk' in disk_name: + # NVMe/eMMC: nvme0n1p1 -> nvme0n1 + if 'p' in disk_name: + base_disk = disk_name.rsplit('p', 1)[0] + else: + # SATA/SAS: sda1 -> sda + base_disk = disk_name.rstrip('0123456789') + + if base_disk not in system_disks: + system_disks[base_disk] = {'is_system': True, 'usage': []} + if usage_type not in system_disks[base_disk]['usage']: + system_disks[base_disk]['usage'].append(usage_type) + + # Get ZFS root pool first (critical - these disks should never be touched) + zfs_root_pool = _get_zfs_root_pool() + + try: + # 1. Check mounted filesystems for system-critical mounts + with open('/proc/mounts', 'r') as f: + for line in f: + parts = line.split() + if len(parts) < 2: + continue + device, mountpoint = parts[0], parts[1] + + # Skip non-block devices + if not device.startswith('/dev/'): + continue + + disk_name = device.replace('/dev/', '').replace('mapper/', '') + + # Identify system mountpoints (expanded from bash _is_system_mount) + if _is_system_mount(mountpoint): + if mountpoint == '/': + add_usage(disk_name, 'root') + elif mountpoint.startswith('/boot'): + add_usage(disk_name, 'boot') + else: + add_usage(disk_name, 'system') + except Exception: + pass + + try: + # 2. Check active swap partitions + result = subprocess.run( + ['swapon', '--noheadings', '--raw', '--show=NAME'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + device = line.strip() + if device.startswith('/dev/'): + disk_name = device.replace('/dev/', '') + add_usage(disk_name, 'swap') + except Exception: + # Fallback to /proc/swaps + try: + with open('/proc/swaps', 'r') as f: + lines = f.readlines()[1:] # Skip header + for line in lines: + parts = line.split() + if len(parts) >= 1: + device = parts[0] + if device.startswith('/dev/'): + disk_name = device.replace('/dev/', '').replace('mapper/', '') + add_usage(disk_name, 'swap') + except Exception: + pass + + try: + # 3. Check ZFS pools using zpool list -v -H (matches bash _build_pool_disks) + result = subprocess.run( + ['zpool', 'list', '-v', '-H'], + capture_output=True, text=True, timeout=8 + ) + if result.returncode == 0: + current_pool = None + for line in result.stdout.split('\n'): + if not line.strip(): + continue + parts = line.split() + if not parts: + continue + + # First column is pool name or device + entry = parts[0] + + # Skip metadata entries + if entry in ('-', 'mirror', 'raidz', 'raidz1', 'raidz2', 'raidz3', 'spare', 'log', 'cache'): + continue + + # Check if this is a pool name (pools have no leading whitespace) + if not line.startswith('\t') and not line.startswith(' '): + current_pool = entry + continue + + # This is a device entry - resolve it + base_disk = _resolve_zfs_entry(entry) + if base_disk: + pool_label = current_pool or 'unknown' + # Mark root pool specially + if zfs_root_pool and pool_label == zfs_root_pool: + add_usage(base_disk, f'zfs:{pool_label} (root)') + else: + add_usage(base_disk, f'zfs:{pool_label}') + except Exception: + pass + + try: + # 4. Check LVM physical volumes + result = subprocess.run( + ['pvs', '--noheadings', '-o', 'pv_name,vg_name'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line.strip(): + parts = line.split() + if len(parts) >= 2: + pv_device = parts[0] + vg_name = parts[1] + if pv_device.startswith('/dev/'): + # Resolve to real path + try: + real_path = os.path.realpath(pv_device) + disk_name = os.path.basename(real_path) + except Exception: + disk_name = pv_device.replace('/dev/', '') + + # Proxmox typically uses 'pve' volume group + if 'pve' in vg_name.lower(): + add_usage(disk_name, f'lvm:{vg_name} (pve)') + else: + add_usage(disk_name, f'lvm:{vg_name}') + except Exception: + pass + + try: + # 5. Check active RAID arrays + if os.path.exists('/proc/mdstat'): + with open('/proc/mdstat', 'r') as f: + content = f.read() + if 'active' in content: + # Parse mdstat for active arrays + for line in content.split('\n'): + if 'active raid' in line: + # Extract device names from line like "md0 : active raid1 sda1[0] sdb1[1]" + parts = line.split() + for part in parts: + if '[' in part: + dev = part.split('[')[0] + add_usage(dev, 'raid') + except Exception: + pass + + try: + # 6. Check for disks with Proxmox/system partition labels + result = subprocess.run( + ['lsblk', '-o', 'NAME,PARTLABEL', '-n', '-l'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + parts = line.split(None, 1) + if len(parts) >= 2: + disk_name = parts[0] + partlabel = parts[1].lower() if len(parts) > 1 else '' + # Proxmox-specific and system partition labels + system_labels = ['pve', 'proxmox', 'bios', 'esp', 'efi', 'boot', 'grub'] + if any(label in partlabel for label in system_labels): + add_usage(disk_name, 'system-partition') + except Exception: + pass + + return system_disks + + def get_storage_info(): """Get storage and disk information""" try: @@ -1802,6 +2066,9 @@ def get_storage_info(): physical_disks = {} total_disk_size_bytes = 0 + # Get system disk information (disks used by Proxmox) + system_disks = get_system_disks() + try: # List all block devices result = subprocess.run(['lsblk', '-b', '-d', '-n', '-o', 'NAME,SIZE,TYPE'], @@ -1843,6 +2110,11 @@ def get_storage_info(): conn_type = get_disk_connection_type(disk_name) removable = is_disk_removable(disk_name) + # Check if this disk is used by the system + sys_info = system_disks.get(disk_name, {}) + is_system_disk = sys_info.get('is_system', False) + system_usage = sys_info.get('usage', []) + physical_disks[disk_name] = { 'name': disk_name, 'size': disk_size_kb, # In KB for formatMemory() in Storage Summary @@ -1866,6 +2138,8 @@ def get_storage_info(): 'ssd_life_left': smart_data.get('ssd_life_left'), 'connection_type': conn_type, 'removable': removable, + 'is_system_disk': is_system_disk, + 'system_usage': system_usage, } except Exception as e: @@ -6075,6 +6349,322 @@ def api_proxmox_storage(): """Get Proxmox storage information""" return jsonify(get_proxmox_storage()) + +# ─── SMART Disk Testing API ─────────────────────────────────────────────────── + +SMART_DIR = '/usr/local/share/proxmenux/smart' + +def _is_nvme(disk_name): + """Check if disk is NVMe.""" + return disk_name.startswith('nvme') + +def _get_smart_json_path(disk_name): + """Get path to SMART JSON file for a disk.""" + return os.path.join(SMART_DIR, f"{disk_name}.json") + +def _ensure_smart_tools(): + """Check if SMART tools are installed.""" + has_smartctl = shutil.which('smartctl') is not None + has_nvme = shutil.which('nvme') is not None + return {'smartctl': has_smartctl, 'nvme': has_nvme} + +def _parse_smart_attributes(output_lines): + """Parse SMART attributes from smartctl output.""" + attributes = [] + in_attrs = False + for line in output_lines: + if 'ID#' in line and 'ATTRIBUTE_NAME' in line: + in_attrs = True + continue + if in_attrs: + if not line.strip(): + break + parts = line.split() + if len(parts) >= 10 and parts[0].isdigit(): + attr_id = int(parts[0]) + attr_name = parts[1] + value = int(parts[3]) if parts[3].isdigit() else 0 + worst = int(parts[4]) if parts[4].isdigit() else 0 + threshold = int(parts[5]) if parts[5].isdigit() else 0 + raw_value = parts[9] if len(parts) > 9 else '' + + # Determine status + status = 'ok' + if threshold > 0 and value <= threshold: + status = 'critical' + elif threshold > 0 and value <= threshold + 10: + status = 'warning' + + attributes.append({ + 'id': attr_id, + 'name': attr_name, + 'value': value, + 'worst': worst, + 'threshold': threshold, + 'raw_value': raw_value, + 'status': status + }) + return attributes + +@app.route('/api/storage/smart/', methods=['GET']) +@require_auth +def api_smart_status(disk_name): + """Get SMART status and data for a specific disk.""" + try: + # Validate disk name (security) + if not re.match(r'^[a-zA-Z0-9]+$', disk_name): + return jsonify({'error': 'Invalid disk name'}), 400 + + device = f'/dev/{disk_name}' + if not os.path.exists(device): + return jsonify({'error': 'Device not found'}), 404 + + tools = _ensure_smart_tools() + result = { + 'status': 'idle', + 'tools_installed': tools + } + + # Check if tools are available + is_nvme = _is_nvme(disk_name) + if is_nvme and not tools['nvme']: + result['error'] = 'nvme-cli not installed' + return jsonify(result) + if not is_nvme and not tools['smartctl']: + result['error'] = 'smartmontools not installed' + return jsonify(result) + + # Check for existing JSON file (from previous test) + json_path = _get_smart_json_path(disk_name) + if os.path.exists(json_path): + try: + with open(json_path, 'r') as f: + saved_data = json.load(f) + result['saved_data'] = saved_data + result['saved_timestamp'] = os.path.getmtime(json_path) + except (json.JSONDecodeError, IOError): + pass + + # Get current SMART status + if is_nvme: + # NVMe: Check for running test + proc = subprocess.run( + ['nvme', 'self-test-log', device], + capture_output=True, text=True, timeout=10 + ) + if 'in progress' in proc.stdout.lower(): + result['status'] = 'running' + result['test_type'] = 'nvme' + + # Get smart-log data + proc = subprocess.run( + ['nvme', 'smart-log', device], + capture_output=True, text=True, timeout=10 + ) + if proc.returncode == 0: + lines = proc.stdout.strip().split('\n') + smart_data = {} + for line in lines: + if ':' in line: + key, value = line.split(':', 1) + smart_data[key.strip().lower().replace(' ', '_')] = value.strip() + result['smart_data'] = smart_data + + # Check health + crit_warn = smart_data.get('critical_warning', '0') + result['smart_status'] = 'passed' if crit_warn == '0' else 'warning' + else: + # SATA/SAS: Check for running test + proc = subprocess.run( + ['smartctl', '-c', device], + capture_output=True, text=True, timeout=10 + ) + if 'Self-test routine in progress' in proc.stdout or '% of test remaining' in proc.stdout: + result['status'] = 'running' + # Extract progress percentage + match = re.search(r'(\d+)% of test remaining', proc.stdout) + if match: + result['progress'] = 100 - int(match.group(1)) + + # Get SMART health + proc = subprocess.run( + ['smartctl', '-H', device], + capture_output=True, text=True, timeout=10 + ) + if 'PASSED' in proc.stdout: + result['smart_status'] = 'passed' + elif 'FAILED' in proc.stdout: + result['smart_status'] = 'failed' + else: + result['smart_status'] = 'unknown' + + # Get SMART attributes + proc = subprocess.run( + ['smartctl', '-A', device], + capture_output=True, text=True, timeout=10 + ) + if proc.returncode == 0: + attrs = _parse_smart_attributes(proc.stdout.split('\n')) + result['smart_data'] = {'attributes': attrs} + + # Get self-test log for last test result + proc = subprocess.run( + ['smartctl', '-l', 'selftest', device], + capture_output=True, text=True, timeout=10 + ) + if proc.returncode == 0: + lines = proc.stdout.split('\n') + for line in lines: + if line.startswith('# ') or line.startswith('# '): + parts = line.split() + if len(parts) >= 5: + test_type = 'short' if 'Short' in line else 'long' if 'Extended' in line or 'Long' in line else 'unknown' + test_status = 'passed' if 'Completed without error' in line else 'failed' + result['last_test'] = { + 'type': test_type, + 'status': test_status, + 'timestamp': ' '.join(parts[-5:-2]) if len(parts) > 5 else 'unknown' + } + break + + return jsonify(result) + except subprocess.TimeoutExpired: + return jsonify({'error': 'Command timeout'}), 504 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/storage/smart//test', methods=['POST']) +@require_auth +def api_smart_run_test(disk_name): + """Start a SMART self-test on a disk.""" + try: + # Validate disk name (security) + if not re.match(r'^[a-zA-Z0-9]+$', disk_name): + return jsonify({'error': 'Invalid disk name'}), 400 + + device = f'/dev/{disk_name}' + if not os.path.exists(device): + return jsonify({'error': 'Device not found'}), 404 + + data = request.get_json() or {} + test_type = data.get('test_type', 'short') + + if test_type not in ('short', 'long'): + return jsonify({'error': 'Invalid test type. Use "short" or "long"'}), 400 + + tools = _ensure_smart_tools() + is_nvme = _is_nvme(disk_name) + + # Ensure SMART directory exists + os.makedirs(SMART_DIR, exist_ok=True) + json_path = _get_smart_json_path(disk_name) + + if is_nvme: + if not tools['nvme']: + return jsonify({'error': 'nvme-cli not installed'}), 400 + + # NVMe: self-test-code 1=short, 2=long + code = 1 if test_type == 'short' else 2 + proc = subprocess.run( + ['nvme', 'device-self-test', device, f'--self-test-code={code}'], + capture_output=True, text=True, timeout=30 + ) + + if proc.returncode != 0: + return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500 + + # For long test, start background monitor + if test_type == 'long': + subprocess.Popen( + f''' + while nvme device-self-test {device} --self-test-code=0 2>/dev/null | grep -qi 'in progress'; do + sleep 60 + done + nvme smart-log -o json {device} > {json_path} 2>/dev/null + ''', + shell=True, start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + else: + if not tools['smartctl']: + return jsonify({'error': 'smartmontools not installed'}), 400 + + test_flag = '-t short' if test_type == 'short' else '-t long' + proc = subprocess.run( + ['smartctl'] + test_flag.split() + [device], + capture_output=True, text=True, timeout=30 + ) + + if proc.returncode not in (0, 4): # 4 = test started successfully + return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500 + + # For long test, start background monitor + if test_type == 'long': + subprocess.Popen( + f''' + while smartctl -c {device} 2>/dev/null | grep -qiE 'Self-test routine in progress|[1-9][0-9]?% of test remaining'; do + sleep 60 + done + smartctl --json=c {device} > {json_path} 2>/dev/null + ''', + shell=True, start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + + return jsonify({ + 'success': True, + 'test_type': test_type, + 'device': device, + 'message': f'{test_type.capitalize()} test started on {device}' + }) + except subprocess.TimeoutExpired: + return jsonify({'error': 'Command timeout'}), 504 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/storage/smart/tools', methods=['GET']) +@require_auth +def api_smart_tools_status(): + """Check if SMART tools are installed.""" + tools = _ensure_smart_tools() + return jsonify(tools) + + +@app.route('/api/storage/smart/tools/install', methods=['POST']) +@require_auth +def api_smart_tools_install(): + """Install SMART tools (smartmontools and nvme-cli).""" + try: + data = request.get_json() or {} + packages = data.get('packages', ['smartmontools', 'nvme-cli']) + + results = {} + for pkg in packages: + if pkg not in ('smartmontools', 'nvme-cli'): + results[pkg] = {'success': False, 'error': 'Invalid package name'} + continue + + # Update apt cache and install + proc = subprocess.run( + ['apt-get', 'install', '-y', pkg], + capture_output=True, text=True, timeout=120 + ) + results[pkg] = { + 'success': proc.returncode == 0, + 'output': proc.stdout if proc.returncode == 0 else proc.stderr + } + + return jsonify(results) + except subprocess.TimeoutExpired: + return jsonify({'error': 'Installation timeout'}), 504 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ─── END SMART API ──────────────────────────────────────────────────────────── + + @app.route('/api/network', methods=['GET']) @require_auth def api_network(): diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 92e9badd..66e4a2a6 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -678,6 +678,59 @@ TEMPLATES = { 'group': 'storage', 'default_enabled': True, }, + 'smart_test_complete': { + 'title': '{hostname}: SMART test completed — {device}', + 'body': 'SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}', + 'label': 'SMART test completed', + 'group': 'storage', + 'default_enabled': True, + }, + 'smart_test_failed': { + 'title': '{hostname}: SMART test FAILED — {device}', + 'body': 'SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}', + 'label': 'SMART test FAILED', + 'group': 'storage', + 'default_enabled': True, + }, + + # ── GPU / PCIe passthrough events ── + 'gpu_mode_switch': { + 'title': '{hostname}: GPU mode changed to {new_mode}', + 'body': ( + 'GPU passthrough mode has been switched.\n' + 'GPU: {gpu_name} ({gpu_pci})\n' + 'Previous mode: {old_mode}\n' + 'New mode: {new_mode}\n' + '{details}' + ), + 'label': 'GPU mode switched', + 'group': 'hardware', + 'default_enabled': True, + }, + 'gpu_passthrough_blocked': { + 'title': '{hostname}: {guest_type} {guest_id} blocked at startup', + 'body': ( + 'PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\n' + 'Reason: {reason}\n' + '{details}' + ), + 'label': 'GPU passthrough blocked', + 'group': 'hardware', + 'default_enabled': True, + }, + 'pci_passthrough_conflict': { + 'title': '{hostname}: PCIe device conflict detected', + 'body': ( + 'A PCIe device is assigned to multiple guests.\n' + 'Device: {device_pci}\n' + 'Conflicting guests: {guest_list}\n' + 'Action required: Stop one of the guests or reassign the device.' + ), + 'label': 'PCIe device conflict', + 'group': 'hardware', + 'default_enabled': True, + }, + 'load_high': { 'title': '{hostname}: High system load — {value}', 'body': 'System load average is {value} on {cores} cores.\n{details}', @@ -1203,6 +1256,7 @@ CATEGORY_EMOJI = { 'services': '\u2699\uFE0F', # gear 'health': '\U0001FA7A', # stethoscope 'updates': '\U0001F504', # counterclockwise arrows (update) + 'hardware': '\U0001F3AE', # video game controller (GPU/PCIe hardware) 'other': '\U0001F4E8', # incoming envelope } @@ -1275,6 +1329,10 @@ EVENT_EMOJI = { 'proxmenux_update': '\U0001F195', # NEW # AI 'ai_model_migrated': '\U0001F504', # arrows counterclockwise (refresh/update) + # GPU / PCIe + 'gpu_mode_switch': '\U0001F3AE', # video game controller (represents GPU) + 'gpu_passthrough_blocked': '\U0001F6AB', # prohibited sign (blocked) + 'pci_passthrough_conflict': '\u26A0\uFE0F', # warning triangle (conflict) } # Decorative field-level icons for body text enrichment diff --git a/scripts/global/disk_ops_helpers.sh b/scripts/global/disk_ops_helpers.sh new file mode 100644 index 00000000..10b30c51 --- /dev/null +++ b/scripts/global/disk_ops_helpers.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash + +# ========================================================== +# ProxMenux - Disk Operations Helpers +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : MIT +# Version : 1.0 +# Last Updated: 11/04/2026 +# ========================================================== +# Shared low-level disk operations: wipe, partition, format. +# Consumed by format-disk.sh, disk_host.sh and future scripts. +# +# Output variables (set by helpers, read by callers): +# DOH_CREATED_PARTITION — partition path set by doh_create_partition() +# DOH_PARTITION_ERROR_DETAIL — error detail set by doh_create_partition() +# ========================================================== + +if [[ -n "${__PROXMENUX_DISK_OPS_HELPERS__}" ]]; then + return 0 +fi +__PROXMENUX_DISK_OPS_HELPERS__=1 + +# shellcheck disable=SC2034 # these are output variables read by callers (format-disk.sh, disk_host.sh) +DOH_CREATED_PARTITION="" +DOH_PARTITION_ERROR_DETAIL="" +DOH_FORMAT_ERROR_DETAIL="" +DOH_WIPE_ERROR_DETAIL="" + +# Internal: print progress lines only when explicitly enabled by caller. +# Enabled with: export DOH_SHOW_PROGRESS=1 +_doh_progress() { + [[ "${DOH_SHOW_PROGRESS:-0}" == "1" ]] || return 0 + echo -e "${TAB}${YW}${HOLD}$*${CL}" +} + +# Internal: collect command stdout with timeout protection (best-effort). +# Usage: _doh_collect_cmd [args...] +_doh_collect_cmd() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout --kill-after=2 "${seconds}s" "$@" 2>/dev/null || true + else + "$@" 2>/dev/null || true + fi +} + +# Internal: run a command with a timeout, suppressing all output including +# the bash "Killed" job notification that leaks when --kill-after re-raises +# SIGKILL. Plain SIGTERM is not enough for processes stuck in kernel D-state +# (uninterruptible I/O wait on a busy ZFS/LVM disk), so --kill-after=2 is +# needed. The notification is suppressed by temporarily redirecting the +# current shell's stderr with exec before the call and restoring it after. +# Usage: _doh_run_quick_cmd [args...] +_doh_run_quick_cmd() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + local _saved_stderr + exec {_saved_stderr}>&2 2>/dev/null + timeout --kill-after=2 "${seconds}s" "$@" >/dev/null 2>&1 + local rc=$? + exec 2>&"${_saved_stderr}" {_saved_stderr}>&- + return $rc + fi + "$@" >/dev/null 2>&1 +} + +# Internal: unmount all ZFS datasets then export (or destroy) any ZFS pools +# whose vdevs live on . Called at the very start of doh_wipe_disk so +# ZFS fully releases the device before wipefs/sgdisk/partprobe touch it. +# If the pool is still held after export, processes on it will be in D-state +# and --kill-after in _doh_run_quick_cmd handles the force-kill. +_doh_release_zfs_pools() { + local disk="$1" + command -v zpool >/dev/null 2>&1 || return 0 + + local pool_name dev resolved base parent + while read -r pool_name; do + [[ -z "$pool_name" ]] && continue + local found=false + while read -r dev; do + [[ -z "$dev" ]] && continue + if [[ "$dev" == /dev/* ]]; then + resolved=$(readlink -f "$dev" 2>/dev/null) + elif [[ -e "/dev/disk/by-id/$dev" ]]; then + resolved=$(readlink -f "/dev/disk/by-id/$dev" 2>/dev/null) + elif [[ -e "/dev/$dev" ]]; then + resolved=$(readlink -f "/dev/$dev" 2>/dev/null) + else + continue + fi + [[ -z "$resolved" ]] && continue + base=$(lsblk -no PKNAME "$resolved" 2>/dev/null) + parent="${base:+/dev/$base}" + [[ -z "$parent" ]] && parent="$resolved" + if [[ "$parent" == "$disk" || "$resolved" == "$disk" ]]; then + found=true; break + fi + done < <(_doh_collect_cmd 12 zpool list -v -H "$pool_name" | awk '{print $1}' | \ + grep -v '^-' | grep -v '^mirror' | grep -v '^raidz' | \ + grep -v "^${pool_name}$") + if $found; then + _doh_progress "- Releasing active ZFS pool: $pool_name" + # Unmount all datasets (reverse order: deepest first) + if command -v zfs >/dev/null 2>&1; then + while read -r ds; do + [[ -z "$ds" ]] && continue + timeout 10s zfs unmount -f "$ds" >/dev/null 2>&1 || true + done < <(_doh_collect_cmd 10 zfs list -H -o name -r "$pool_name" | sort -r) + fi + # Export the pool so the kernel releases the block device + timeout 30s zpool export -f "$pool_name" >/dev/null 2>&1 || true + # Wait for udev to finish processing the device release + udevadm settle --timeout=5 >/dev/null 2>&1 || true + sleep 1 + fi + done < <(_doh_collect_cmd 8 zpool list -H -o name) +} + +# Internal: run a partitioning command with timeout, appending combined output to a file. +# Usage: _doh_part_cmd [args...] +_doh_part_cmd() { + local secs="$1" outfile="$2" + shift 2 + if command -v timeout >/dev/null 2>&1; then + timeout --kill-after=3 "${secs}s" "$@" >>"$outfile" 2>&1 + else + "$@" >>"$outfile" 2>&1 + fi +} + +# doh_wipe_disk +# Unmounts all partitions, deactivates swap, wipes all filesystem metadata +# and partition tables (wipefs + sgdisk + dd first/last 16 MiB). +# Never fails — all sub-commands run with "|| true". +doh_wipe_disk() { + local disk="$1" + local node mountpoint total_sectors seek_sectors discard_max base + + DOH_WIPE_ERROR_DETAIL="" + _doh_progress "[1/8] Preparing disk $disk" + + # Optional heavy release flow (disabled by default to avoid hangs in busy hosts). + if [[ "${DOH_ENABLE_STACK_RELEASE:-0}" == "1" ]]; then + # Release any ZFS pools using this disk so the kernel lets go of it + _doh_release_zfs_pools "$disk" + + # Deactivate any LVM VGs backed by this disk + if command -v vgchange >/dev/null 2>&1; then + local pv rp vg + while read -r pv; do + rp=$(readlink -f "$pv" 2>/dev/null) + base=$(lsblk -no PKNAME "${rp:-$pv}" 2>/dev/null) + if [[ "/dev/${base}" == "$disk" || "$rp" == "$disk" ]]; then + vg=$(_doh_collect_cmd 8 pvs --noheadings -o vg_name "${rp:-$pv}" | xargs) + [[ -n "$vg" ]] && _doh_run_quick_cmd 8 vgchange -an "$vg" || true + fi + done < <(_doh_collect_cmd 8 pvs --noheadings -o pv_name | xargs -r -n1) + fi + fi + + # Unmount all partitions + _doh_progress "[2/8] Unmounting partitions" + while read -r node mountpoint; do + [[ -z "$node" || -z "$mountpoint" ]] && continue + _doh_run_quick_cmd 8 umount -f "$node" || true + done < <(lsblk -lnpo NAME,MOUNTPOINT "$disk" 2>/dev/null | awk 'NR>1 && $2!="" {print $1" "$2}') + + # Deactivate swap + _doh_progress "[3/8] Disabling swap signatures" + while read -r node; do + [[ -z "$node" ]] && continue + _doh_run_quick_cmd 8 swapoff "$node" || true + done < <(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR>1 {print $1}') + + # Wipe filesystem signatures and RAID superblocks on every node + _doh_progress "[4/8] Removing filesystem/RAID signatures" + while read -r node; do + [[ -z "$node" ]] && continue + _doh_run_quick_cmd 10 wipefs -a -f "$node" || true + if command -v mdadm >/dev/null 2>&1; then + _doh_run_quick_cmd 8 mdadm --zero-superblock --force "$node" || true + fi + done < <(lsblk -lnpo NAME "$disk" 2>/dev/null) + + # Zap partition table + _doh_progress "[5/8] Resetting partition table" + _doh_run_quick_cmd 12 sgdisk --zap-all "$disk" || true + + # TRIM/discard if device supports it + _doh_progress "[6/8] Attempting discard/TRIM when supported" + discard_max=$(lsblk -dn -o DISC-MAX "$disk" 2>/dev/null | xargs) + if [[ -n "$discard_max" && "$discard_max" != "0B" && "$discard_max" != "0" ]]; then + _doh_run_quick_cmd 15 blkdiscard -f "$disk" || true + fi + + # Zero first 16 MiB (destroys partition table / filesystem headers) + _doh_progress "[7/8] Zeroing first metadata region" + _doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=1M count=16 conv=fsync status=none || true + + # Zero last 16 MiB (destroys backup GPT header) + _doh_progress "[8/8] Zeroing backup GPT region" + total_sectors=$(blockdev --getsz "$disk" 2>/dev/null || echo 0) + if [[ "$total_sectors" =~ ^[0-9]+$ ]] && (( total_sectors > 32768 )); then + seek_sectors=$(( total_sectors - 32768 )) + _doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=512 seek="$seek_sectors" count=32768 conv=fsync status=none || true + fi + + udevadm settle --timeout=10 >/dev/null 2>&1 || true + _doh_run_quick_cmd 8 partprobe "$disk" || true + sleep 1 +} + +# doh_create_partition +# Creates a single GPT partition spanning the whole disk. +# Tries parted → sgdisk → sfdisk in order; stops at first success. +# +# On success: sets DOH_CREATED_PARTITION to the new partition path, returns 0. +# On failure: sets DOH_PARTITION_ERROR_DETAIL with tool diagnostics, returns 1. +doh_create_partition() { + local disk="$1" + local created=false tmp_out err_snippet + + DOH_CREATED_PARTITION="" + DOH_PARTITION_ERROR_DETAIL="" + + _doh_run_quick_cmd 5 blockdev --setrw "$disk" || true + + # --- attempt 1: parted --- + if command -v parted >/dev/null 2>&1; then + tmp_out=$(mktemp) + if _doh_part_cmd 15 "$tmp_out" parted -s -f "$disk" mklabel gpt; then + if _doh_part_cmd 20 "$tmp_out" parted -s -f "$disk" mkpart primary 1MiB 100%; then + created=true + else + err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//') + DOH_PARTITION_ERROR_DETAIL+="parted mkpart: ${err_snippet:-no details}"$'\n' + fi + else + err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//') + DOH_PARTITION_ERROR_DETAIL+="parted mklabel: ${err_snippet:-no details}"$'\n' + fi + rm -f "$tmp_out" + else + DOH_PARTITION_ERROR_DETAIL+="parted command not found"$'\n' + fi + + # --- attempt 2: sgdisk --- + if [[ "$created" != "true" ]] && command -v sgdisk >/dev/null 2>&1; then + tmp_out=$(mktemp) + _doh_run_quick_cmd 10 sgdisk --zap-all "$disk" || true + # sgdisk does not accept "1MiB" notation — use sector 2048 (= 1 MiB at 512 B/sector) + if _doh_part_cmd 20 "$tmp_out" sgdisk -o -n 1:2048:0 -t 1:8300 "$disk"; then + created=true + else + err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//') + DOH_PARTITION_ERROR_DETAIL+="sgdisk create: ${err_snippet:-no details}"$'\n' + fi + rm -f "$tmp_out" + elif [[ "$created" != "true" ]]; then + DOH_PARTITION_ERROR_DETAIL+="sgdisk command not found"$'\n' + fi + + # --- attempt 3: sfdisk --- + if [[ "$created" != "true" ]] && command -v sfdisk >/dev/null 2>&1; then + tmp_out=$(mktemp) + local sfdisk_ok=1 + if command -v timeout >/dev/null 2>&1; then + printf 'label: gpt\n,;\n' | timeout --kill-after=3 20s sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1 + sfdisk_ok=$? + else + printf 'label: gpt\n,;\n' | sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1 + sfdisk_ok=$? + fi + if [[ $sfdisk_ok -eq 0 ]]; then + created=true + else + err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//') + DOH_PARTITION_ERROR_DETAIL+="sfdisk create: ${err_snippet:-no details}"$'\n' + fi + rm -f "$tmp_out" + elif [[ "$created" != "true" ]]; then + DOH_PARTITION_ERROR_DETAIL+="sfdisk command not found"$'\n' + fi + + [[ "$created" == "true" ]] || return 1 + + # Wait for the kernel to expose the new partition node + udevadm settle --timeout=10 >/dev/null 2>&1 || true + _doh_run_quick_cmd 8 partprobe "$disk" || true + + local part + for _ in {1..15}; do + sleep 0.3 + part=$(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR==2{print; exit}') + if [[ -n "$part" && -b "$part" ]]; then + DOH_CREATED_PARTITION="$part" + return 0 + fi + done + + # Fallback: derive partition name from disk path (handles NVMe p-suffix) + local fallback + if [[ "$disk" =~ [0-9]$ ]]; then + fallback="${disk}p1" + else + fallback="${disk}1" + fi + if [[ -b "$fallback" ]]; then + DOH_CREATED_PARTITION="$fallback" + return 0 + fi + + DOH_PARTITION_ERROR_DETAIL+="partition node not detected after table refresh"$'\n' + return 1 +} + +# doh_format_partition [label] [zfs_pool_name] [zfs_mountpoint] +# +# Formats with . +# label : optional FS label for ext4/xfs/btrfs (ignored for ZFS) +# zfs_pool_name : required when filesystem=zfs; defaults to label if empty +# zfs_mountpoint : ZFS pool mountpoint (default: "none" — no automatic mount) +# +# On failure: sets DOH_FORMAT_ERROR_DETAIL with tool diagnostics. +# Returns 0 on success, 1 on failure. +doh_format_partition() { + local partition="$1" + local filesystem="$2" + local label="${3:-}" + local zfs_pool="${4:-}" + local zfs_mountpoint="${5:-none}" + local tmp_out rc=1 + + DOH_FORMAT_ERROR_DETAIL="" + tmp_out=$(mktemp) + + case "$filesystem" in + ext4) + if [[ -n "$label" ]]; then + mkfs.ext4 -F -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$? + else + mkfs.ext4 -F "$partition" >"$tmp_out" 2>&1; rc=$? + fi + ;; + xfs) + if [[ -n "$label" ]]; then + mkfs.xfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$? + else + mkfs.xfs -f "$partition" >"$tmp_out" 2>&1; rc=$? + fi + ;; + exfat) + mkfs.exfat "$partition" >"$tmp_out" 2>&1; rc=$? + ;; + btrfs) + if [[ -n "$label" ]]; then + mkfs.btrfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$? + else + mkfs.btrfs -f "$partition" >"$tmp_out" 2>&1; rc=$? + fi + ;; + zfs) + [[ -z "$zfs_pool" ]] && zfs_pool="${label:-pool}" + zpool labelclear -f "$partition" >/dev/null 2>&1 || true + zpool create -f -o ashift=12 \ + -O compression=lz4 -O atime=off -O xattr=sa -O acltype=posixacl \ + -m "$zfs_mountpoint" "$zfs_pool" "$partition" >"$tmp_out" 2>&1 + rc=$? + ;; + *) + echo "Unknown filesystem: $filesystem" >"$tmp_out" + rc=1 + ;; + esac + + if [[ $rc -ne 0 ]]; then + DOH_FORMAT_ERROR_DETAIL=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//') + fi + rm -f "$tmp_out" + return $rc +} diff --git a/scripts/global/gpu_hook_guard_helpers.sh b/scripts/global/gpu_hook_guard_helpers.sh index dd0e2690..22dc36ab 100644 --- a/scripts/global/gpu_hook_guard_helpers.sh +++ b/scripts/global/gpu_hook_guard_helpers.sh @@ -222,9 +222,9 @@ attach_proxmenux_gpu_guard_to_vm() { fi if qm set "$vmid" --hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then - _gpu_guard_msg_ok "GPU guard hook attached to VM ${vmid}" + _gpu_guard_msg_ok "PCIe passthrough guard attached to VM ${vmid}" else - _gpu_guard_msg_warn "Could not attach GPU guard hook to VM ${vmid}. Ensure 'local' storage supports snippets." + _gpu_guard_msg_warn "Could not attach PCIe passthrough guard to VM ${vmid}. Ensure 'local' storage supports snippets." fi } @@ -239,9 +239,9 @@ attach_proxmenux_gpu_guard_to_lxc() { fi if pct set "$ctid" -hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then - _gpu_guard_msg_ok "GPU guard hook attached to LXC ${ctid}" + _gpu_guard_msg_ok "PCIe passthrough guard attached to LXC ${ctid}" else - _gpu_guard_msg_warn "Could not attach GPU guard hook to LXC ${ctid}. Ensure 'local' storage supports snippets." + _gpu_guard_msg_warn "Could not attach PCIe passthrough guard to LXC ${ctid}. Ensure 'local' storage supports snippets." fi } diff --git a/scripts/global/vm_storage_helpers.sh b/scripts/global/vm_storage_helpers.sh index be65c72f..18066229 100644 --- a/scripts/global/vm_storage_helpers.sh +++ b/scripts/global/vm_storage_helpers.sh @@ -15,6 +15,66 @@ function _array_contains() { return 1 } +function _vm_boot_order_add_unique() { + local arr_name="$1" + shift + local -n arr_ref="$arr_name" + local entry + for entry in "$@"; do + [[ -z "$entry" ]] && continue + _array_contains "$entry" "${arr_ref[@]}" || arr_ref+=("$entry") + done +} + +function _vm_boot_order_join() { + local -a unique_entries=() + local entry + for entry in "$@"; do + [[ -z "$entry" ]] && continue + _array_contains "$entry" "${unique_entries[@]}" || unique_entries+=("$entry") + done + [[ ${#unique_entries[@]} -gt 0 ]] || return 0 + local joined + joined=$(IFS=';'; echo "${unique_entries[*]}") + echo "$joined" +} + +function _vm_boot_order_hostpci_entries_for_pcis() { + local vmid="$1" + shift + + local cfg + cfg=$(qm config "$vmid" 2>/dev/null || true) + [[ -n "$cfg" ]] || return 0 + + local -a hostpci_entries=() + local pci bdf bdf_re slot_base slot_re line entry + + for pci in "$@"; do + [[ -n "$pci" ]] || continue + bdf="${pci#0000:}" + bdf_re="${bdf//./\\.}" + + line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${bdf_re}([,[:space:]]|$)" <<< "$cfg" | head -n1) + if [[ -z "$line" ]]; then + slot_base="${bdf%.*}" + slot_re="${slot_base//./\\.}" + line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${slot_re}(\\.[0-7])?([,[:space:]]|$)" <<< "$cfg" | head -n1) + fi + + [[ -n "$line" ]] || continue + entry="${line%%:*}" + _array_contains "$entry" "${hostpci_entries[@]}" || hostpci_entries+=("$entry") + done + + printf '%s\n' "${hostpci_entries[@]}" +} + +function _vmids_scope_key() { + [[ "$#" -eq 0 ]] && { echo ""; return 0; } + printf '%s\n' "$@" | awk 'NF' | sort -u | paste -sd',' - +} + function _refresh_host_storage_cache() { MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') SWAP_DISKS=$(swapon --noheadings --raw --show=NAME 2>/dev/null) @@ -23,17 +83,24 @@ function _refresh_host_storage_cache() { ZFS_DISKS="" local zfs_raw entry path base_disk - zfs_raw=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') + zfs_raw=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror' | grep -v '^raidz') for entry in $zfs_raw; do path="" - if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then - [[ -e "/dev/disk/by-id/$entry" ]] && path=$(readlink -f "/dev/disk/by-id/$entry") - elif [[ "$entry" == /dev/* ]]; then - path="$entry" + if [[ "$entry" == /dev/* ]]; then + path=$(readlink -f "$entry" 2>/dev/null) + elif [[ -e "/dev/disk/by-id/$entry" ]]; then + path=$(readlink -f "/dev/disk/by-id/$entry" 2>/dev/null) + elif [[ -e "/dev/$entry" ]]; then + path=$(readlink -f "/dev/$entry" 2>/dev/null) fi if [[ -n "$path" ]]; then base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) - [[ -n "$base_disk" ]] && ZFS_DISKS+="/dev/$base_disk"$'\n' + if [[ -n "$base_disk" ]]; then + ZFS_DISKS+="/dev/$base_disk"$'\n' + else + # Whole-disk vdev — path is already the resolved disk itself + ZFS_DISKS+="$path"$'\n' + fi fi done ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) @@ -77,7 +144,7 @@ function _disk_is_host_system_used() { DISK_USAGE_REASON="$(translate "Disk is part of host LVM")" return 0 fi - if [[ -n "$ZFS_DISKS" && "$ZFS_DISKS" == *"$disk"* ]]; then + if [[ -n "$ZFS_DISKS" ]] && grep -qFx "$disk" <<< "$ZFS_DISKS"; then DISK_USAGE_REASON="$(translate "Disk is part of a host ZFS pool")" return 0 fi @@ -86,23 +153,181 @@ function _disk_is_host_system_used() { function _disk_used_in_guest_configs() { local disk="$1" - local real_path + local real_path escaped real_path=$(readlink -f "$disk" 2>/dev/null) - if [[ -n "$real_path" ]] && grep -Fq "$real_path" <<< "$CONFIG_DATA"; then - return 0 + # Use boundary matching: path must be followed by comma, whitespace, or EOL + # This prevents /dev/sdb from falsely matching /dev/sdb1 or /dev/sdb2 + if [[ -n "$real_path" ]]; then + escaped="${real_path//./\\.}" + if grep -qE "${escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then + return 0 + fi fi - local symlink + local symlink symlink_escaped for symlink in /dev/disk/by-id/*; do [[ -e "$symlink" ]] || continue - if [[ "$(readlink -f "$symlink")" == "$real_path" ]] && grep -Fq "$symlink" <<< "$CONFIG_DATA"; then + [[ "$(readlink -f "$symlink")" == "$real_path" ]] || continue + symlink_escaped="${symlink//./\\.}" + if grep -qE "${symlink_escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then return 0 fi done return 1 } +# Returns 0 if the disk is referenced in a RUNNING VM or CT config. +# Mirrors _disk_used_in_guest_configs but checks guest status per-file. +function _disk_used_in_running_guest() { + local disk="$1" + local real_path + real_path=$(readlink -f "$disk" 2>/dev/null) + + local -a aliases=() + [[ -n "$disk" ]] && aliases+=("$disk") + [[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path") + local symlink + for symlink in /dev/disk/by-id/*; do + [[ -e "$symlink" ]] || continue + [[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink") + done + + local conf vmid alias escaped + for conf in /etc/pve/qemu-server/*.conf; do + [[ -f "$conf" ]] || continue + vmid=$(basename "$conf" .conf) + for alias in "${aliases[@]}"; do + escaped="${alias//./\\.}" + if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then + if qm status "$vmid" 2>/dev/null | grep -q "status: running"; then + return 0 + fi + fi + done + done + + local ctid + for conf in /etc/pve/lxc/*.conf; do + [[ -f "$conf" ]] || continue + ctid=$(basename "$conf" .conf) + for alias in "${aliases[@]}"; do + escaped="${alias//./\\.}" + if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then + if pct status "$ctid" 2>/dev/null | grep -q "status: running"; then + return 0 + fi + fi + done + done + + return 1 +} + +# Prints "VM:VMID" or "CT:CTID" for each stopped guest that references the disk. +function _disk_guest_ids() { + local disk="$1" + local real_path + real_path=$(readlink -f "$disk" 2>/dev/null) + + local -a aliases=() + [[ -n "$disk" ]] && aliases+=("$disk") + [[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path") + local symlink + for symlink in /dev/disk/by-id/*; do + [[ -e "$symlink" ]] || continue + [[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink") + done + + local conf vmid alias escaped + for conf in /etc/pve/qemu-server/*.conf; do + [[ -f "$conf" ]] || continue + vmid=$(basename "$conf" .conf) + for alias in "${aliases[@]}"; do + escaped="${alias//./\\.}" + if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then + echo "VM:$vmid" + break + fi + done + done + + local ctid + for conf in /etc/pve/lxc/*.conf; do + [[ -f "$conf" ]] || continue + ctid=$(basename "$conf" .conf) + for alias in "${aliases[@]}"; do + escaped="${alias//./\\.}" + if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then + echo "CT:$ctid" + break + fi + done + done +} + +# Print the slot names (e.g. sata0, scsi1) in a VM config that reference the disk. +function _find_disk_slots_in_vm() { + local vmid="$1" + local disk="$2" + local real_path conf + real_path=$(readlink -f "$disk" 2>/dev/null) + conf="/etc/pve/qemu-server/${vmid}.conf" + [[ -f "$conf" ]] || return + + local -a aliases=("$disk") + [[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path") + local symlink + for symlink in /dev/disk/by-id/*; do + [[ -e "$symlink" ]] || continue + [[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink") + done + + local key rest alias escaped + while IFS=: read -r key rest; do + key=$(echo "$key" | xargs) + [[ "$key" =~ ^(scsi|sata|ide|virtio)[0-9]+$ ]] || continue + for alias in "${aliases[@]}"; do + escaped="${alias//./\\.}" + if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then + echo "$key" + break + fi + done + done < "$conf" +} + +# Print the mp names (e.g. mp0, mp1) in a CT config that reference the disk. +function _find_disk_slots_in_ct() { + local ctid="$1" + local disk="$2" + local real_path conf + real_path=$(readlink -f "$disk" 2>/dev/null) + conf="/etc/pve/lxc/${ctid}.conf" + [[ -f "$conf" ]] || return + + local -a aliases=("$disk") + [[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path") + local symlink + for symlink in /dev/disk/by-id/*; do + [[ -e "$symlink" ]] || continue + [[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink") + done + + local key rest alias escaped + while IFS=: read -r key rest; do + key=$(echo "$key" | xargs) + [[ "$key" =~ ^mp[0-9]+$ ]] || continue + for alias in "${aliases[@]}"; do + escaped="${alias//./\\.}" + if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then + echo "$key" + break + fi + done + done < "$conf" +} + function _controller_block_devices() { local pci_full="$1" local pci_root="/sys/bus/pci/devices/$pci_full" @@ -137,6 +362,14 @@ function _vm_is_q35() { [[ "$machine_line" == *q35* ]] } +function _vm_storage_register_vfio_iommu_tool() { + local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json" + command -v jq >/dev/null 2>&1 || return 0 + [[ -f "$tools_json" ]] || echo "{}" > "$tools_json" + jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \ + && mv "$tools_json.tmp" "$tools_json" || true +} + function _vm_storage_enable_iommu_cmdline() { local cpu_vendor iommu_param cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') @@ -175,18 +408,28 @@ function _vm_storage_ensure_iommu_or_offer() { local reboot_policy="${VM_STORAGE_IOMMU_REBOOT_POLICY:-ask_now}" if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then + _vm_storage_register_vfio_iommu_tool return 0 fi if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \ [[ -d /sys/kernel/iommu_groups ]] && \ [[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then + _vm_storage_register_vfio_iommu_tool return 0 fi - # Wizard flow: if IOMMU was already configured in this run and reboot is pending, - # allow the user to continue planning storage selections without re-prompting. - if [[ "$reboot_policy" == "defer" && "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then + # Dedup: if IOMMU was already configured/announced in this wizard run, skip prompt + if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then + return 0 + fi + + # Detect if another script already wrote IOMMU params (e.g. GPU script ran first) + if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \ + grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then + _vm_storage_register_vfio_iommu_tool + VM_STORAGE_IOMMU_PENDING_REBOOT=1 + export VM_STORAGE_IOMMU_PENDING_REBOOT return 0 fi @@ -206,6 +449,8 @@ function _vm_storage_ensure_iommu_or_offer() { return 1 fi + _vm_storage_register_vfio_iommu_tool + if [[ "$reboot_policy" == "defer" ]]; then VM_STORAGE_IOMMU_PENDING_REBOOT=1 export VM_STORAGE_IOMMU_PENDING_REBOOT @@ -230,47 +475,71 @@ function _vm_storage_confirm_controller_passthrough_risk() { local vmid="${1:-}" local vm_name="${2:-}" local title="${3:-Controller + NVMe}" + local ui_mode="${4:-auto}" # wizard | standalone | auto local vm_label="" if [[ -n "$vmid" ]]; then vm_label="$vmid" [[ -n "$vm_name" ]] && vm_label="${vm_label} (${vm_name})" fi - local msg - msg="$(translate "Important compatibility notice")\n\n" - msg+="$(translate "Not all motherboards support physical Controller/NVMe passthrough to VMs reliably, especially systems with old platforms or limited BIOS/UEFI firmware.")\n\n" - msg+="$(translate "On some systems, the VM may fail to start or the host may freeze when the VM boots.")\n\n" - local reinforce_limited_firmware="no" - local bios_date bios_year current_year cpu_model + local bios_date bios_year current_year bios_age cpu_model risk_detail="" bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null) bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1) current_year=$(date +%Y 2>/dev/null) if [[ -n "$bios_year" && -n "$current_year" ]]; then - if (( current_year - bios_year >= 7 )); then + bios_age=$(( current_year - bios_year )) + if (( bios_age >= 7 )); then reinforce_limited_firmware="yes" + risk_detail="$(translate "BIOS from") ${bios_year} (${bios_age} $(translate "years old")) — $(translate "older firmware may increase passthrough instability")" fi fi cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs) if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then reinforce_limited_firmware="yes" + [[ -z "$risk_detail" ]] && risk_detail="$(translate "Low-power CPU platform"): ${cpu_model}" fi - if [[ "$reinforce_limited_firmware" == "yes" ]]; then - msg+="$(translate "Detected risk factor: this host may use an older or limited firmware platform, which increases passthrough instability risk.")\n\n" + if [[ "$ui_mode" == "auto" ]]; then + if [[ "${PROXMENUX_UI_MODE:-}" == "wizard" || "${WIZARD_CALL:-false}" == "true" ]]; then + ui_mode="wizard" + else + ui_mode="standalone" + fi fi - if [[ -n "$vm_label" ]]; then - msg+="$(translate "Target VM"): ${vm_label}\n\n" - fi - msg+="$(translate "If this happens after assignment"):\n" - msg+=" - $(translate "Power cycle the host if it is frozen.")\n" - msg+=" - $(translate "Remove the hostpci controller/NVMe entries from the VM config file.")\n" - msg+=" /etc/pve/qemu-server/${vmid:-}.conf\n" - msg+=" - $(translate "Start the VM again without that passthrough device.")\n\n" - msg+="$(translate "Do you want to continue with this assignment?")" + local height=20 + [[ "$reinforce_limited_firmware" == "yes" ]] && height=23 - whiptail --title "$title" --yesno "$msg" 21 96 + if [[ "$ui_mode" == "wizard" ]]; then + # whiptail: plain text (no color codes) + local msg + [[ -n "$vm_label" ]] && msg+="$(translate "Target VM"): ${vm_label}\n\n" + msg+="⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\n\n" + msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n" + msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n" + if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then + msg+="\n$(translate "Detected risk factor"): ${risk_detail}\n" + fi + msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-}.conf\n" + msg+="\n$(translate "Do you want to continue?")" + whiptail --title "$title" --yesno "$msg" $height 96 + else + # dialog: colored format matching add_controller_nvme_vm.sh + local msg + [[ -n "$vm_label" ]] && msg+="\n\Zb$(translate "Target VM"): ${vm_label}\Zn\n" + msg+="\n\Zb\Z4⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\Zn\n\n" + msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n" + msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n" + if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then + msg+="\n\Z1$(translate "Detected risk factor"): ${risk_detail}\Zn\n" + fi + msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-}.conf\n" + msg+="\n\Zb$(translate "Do you want to continue?")\Zn" + dialog --backtitle "ProxMenux" --colors \ + --title "$title" \ + --yesno "$msg" $height 96 + fi } function _shorten_text() { @@ -284,6 +553,30 @@ function _shorten_text() { fi } +function _pci_storage_display_name() { + local pci_full="$1" + local raw_line name_part + + raw_line=$(lspci -nn -s "${pci_full#0000:}" 2>/dev/null | sed 's/^[^ ]* //') + if [[ -z "$raw_line" ]]; then + translate "Unknown storage controller" + return 0 + fi + + # Prefer the right side after class prefix (e.g. "...: Vendor Model ..."). + name_part="${raw_line#*: }" + [[ "$name_part" == "$raw_line" ]] && name_part="$raw_line" + + # Remove noisy suffixes while keeping the meaningful model name. + name_part="${name_part%% (rev *}" + name_part=$(echo "$name_part" | sed -E 's/\[[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\]//g') + name_part=$(echo "$name_part" | sed -E 's/ Technology Inc\.?//g; s/ Corporation//g; s/ Co\., Ltd\.?//g') + name_part=$(echo "$name_part" | sed -E 's/[[:space:]]+/ /g; s/^ +| +$//g') + + [[ -z "$name_part" ]] && name_part="$raw_line" + echo "$name_part" +} + function _pci_slot_base() { local pci_full="$1" local slot diff --git a/scripts/gpu_tpu/add_gpu_vm.sh b/scripts/gpu_tpu/add_gpu_vm.sh index 8c895b97..f57360e8 100644 --- a/scripts/gpu_tpu/add_gpu_vm.sh +++ b/scripts/gpu_tpu/add_gpu_vm.sh @@ -28,6 +28,7 @@ LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" +TOOLS_JSON="$BASE_DIR/installed_tools.json" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" @@ -190,12 +191,80 @@ _vm_switch_action_label() { esac } +_gpu_register_vfio_iommu_tool() { + command -v jq >/dev/null 2>&1 || return 0 + [[ -f "$TOOLS_JSON" ]] || echo "{}" > "$TOOLS_JSON" + jq '.vfio_iommu=true' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" \ + && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON" || true +} + _set_wizard_result() { local result="$1" [[ -z "${GPU_WIZARD_RESULT_FILE:-}" ]] && return 0 printf '%s\n' "$result" >"$GPU_WIZARD_RESULT_FILE" 2>/dev/null || true } +# ========================================================== +# UI wrapper helpers — dialog in standalone, whiptail in wizard +# ========================================================== +# Strips dialog color sequences (\Zb, \Z1, \Zn, etc.) from a string +_strip_colors() { + printf '%s' "$1" | sed 's/\\Z[0-9a-zA-Z]//g' +} + +# Msgbox: dialog in standalone mode, whiptail in wizard mode +_pmx_msgbox() { + local title="$1" msg="$2" h="${3:-10}" w="${4:-72}" + if [[ "$WIZARD_CALL" == "true" ]]; then + whiptail --backtitle "ProxMenux" --title "$title" \ + --msgbox "$(_strip_colors "$msg")" "$h" "$w" + else + dialog --backtitle "ProxMenux" --colors \ + --title "$title" --msgbox "$msg" "$h" "$w" + fi +} + +# Yesno: dialog in standalone mode, whiptail in wizard mode +# Returns 0 for yes, 1 for no (same as dialog/whiptail) +_pmx_yesno() { + local title="$1" msg="$2" h="${3:-10}" w="${4:-72}" + if [[ "$WIZARD_CALL" == "true" ]]; then + whiptail --backtitle "ProxMenux" --title "$title" \ + --yesno "$(_strip_colors "$msg")" "$h" "$w" + else + dialog --backtitle "ProxMenux" --colors \ + --title "$title" --yesno "$msg" "$h" "$w" + fi + return $? +} + +# Menu: dialog in standalone mode, whiptail in wizard mode +# Accepts optional --default-item VALUE before title +# Usage: _pmx_menu [--default-item VAL] title msg h w list_h item desc ... +_pmx_menu() { + local -a extra_opts=() + while [[ "${1:-}" == --* ]]; do + case "$1" in + --default-item) extra_opts+=("--default-item" "$2"); shift 2 ;; + *) shift ;; + esac + done + local title="$1" msg="$2" h="$3" w="$4" lh="$5" + shift 5 + if [[ "$WIZARD_CALL" == "true" ]]; then + whiptail --backtitle "ProxMenux" "${extra_opts[@]}" \ + --title "$title" \ + --menu "$(_strip_colors "$msg")" "$h" "$w" "$lh" \ + "$@" 3>&1 1>&2 2>&3 + else + dialog --backtitle "ProxMenux" --colors "${extra_opts[@]}" \ + --title "$title" \ + --menu "$msg" "$h" "$w" "$lh" \ + "$@" 2>&1 >/dev/tty + fi + return $? +} + _file_has_exact_line() { local line="$1" local file="$2" @@ -398,18 +467,16 @@ ensure_selected_gpu_not_already_in_target_vm() { TARGET_VM_ALREADY_HAS_GPU=true local popup_title popup_title=$(_get_vm_run_title) - dialog --backtitle "ProxMenux" \ - --title "${popup_title}" \ - --msgbox "\n$(translate 'The selected GPU is already assigned to this VM, but the host is not currently using vfio-pci for this device.')\n\n$(translate 'Current driver'): ${current_driver}\n\n$(translate 'The script will continue to restore VM passthrough mode on the host and reuse existing hostpci entries.')" \ + _pmx_msgbox "${popup_title}" \ + "\n$(translate 'The selected GPU is already assigned to this VM, but the host is not currently using vfio-pci for this device.')\n\n$(translate 'Current driver'): ${current_driver}\n\n$(translate 'The script will continue to restore VM passthrough mode on the host and reuse existing hostpci entries.')" \ 13 78 return 0 fi # Single GPU system: nothing else to choose if [[ $GPU_COUNT -le 1 ]]; then - dialog --backtitle "ProxMenux" \ - --title "$(translate 'GPU Already Added')" \ - --msgbox "\n$(translate 'The selected GPU is already assigned to this VM.')\n\n$(translate 'No changes are required.')" \ + _pmx_msgbox "$(translate 'GPU Already Added')" \ + "\n$(translate 'The selected GPU is already assigned to this VM.')\n\n$(translate 'No changes are required.')" \ 9 66 exit 0 fi @@ -428,22 +495,18 @@ ensure_selected_gpu_not_already_in_target_vm() { done if [[ $available -eq 0 ]]; then - dialog --backtitle "ProxMenux" \ - --title "$(translate 'All GPUs Already Assigned')" \ - --msgbox "\n$(translate 'All detected GPUs are already assigned to this VM.')\n\n$(translate 'No additional GPU can be added.')" \ + _pmx_msgbox "$(translate 'All GPUs Already Assigned')" \ + "\n$(translate 'All detected GPUs are already assigned to this VM.')\n\n$(translate 'No additional GPU can be added.')" \ 10 70 exit 0 fi local choice - local -a clear_opt=() - [[ "$WIZARD_CALL" != "true" ]] && clear_opt+=(--clear) - choice=$(dialog "${clear_opt[@]}" --backtitle "ProxMenux" --colors \ - --title "$(translate 'GPU Already Assigned to This VM')" \ - --menu "\n$(translate 'The selected GPU is already present in this VM. Select another GPU to continue:')" \ + choice=$(_pmx_menu \ + "$(translate 'GPU Already Assigned to This VM')" \ + "\n$(translate 'The selected GPU is already present in this VM. Select another GPU to continue:')" \ 18 82 10 \ - "${menu_items[@]}" \ - 2>&1 >/dev/tty) || exit 0 + "${menu_items[@]}") || exit 0 SELECTED_GPU="${ALL_GPU_TYPES[$choice]}" SELECTED_GPU_PCI="${ALL_GPU_PCIS[$choice]}" @@ -492,9 +555,8 @@ detect_host_gpus() { if [[ $GPU_COUNT -eq 0 ]]; then _set_wizard_result "no_gpu" - dialog --backtitle "ProxMenux" \ - --title "$(translate 'No GPU Detected')" \ - --msgbox "\n$(translate 'No compatible GPU was detected on this host.')" 8 60 + _pmx_msgbox "$(translate 'No GPU Detected')" \ + "\n$(translate 'No compatible GPU was detected on this host.')" 8 60 exit 0 fi @@ -506,7 +568,16 @@ detect_host_gpus() { # Phase 1 — Step 2: Check IOMMU, offer to enable it # ========================================================== check_iommu_enabled() { + # Dedup: if IOMMU was already configured by another script in this wizard run, skip prompt + if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then + IOMMU_PENDING_REBOOT=true + HOST_CONFIG_CHANGED=true + _gpu_register_vfio_iommu_tool + return 0 + fi + if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then + _gpu_register_vfio_iommu_tool return 0 fi @@ -519,9 +590,11 @@ check_iommu_enabled() { if [[ "$configured_next_boot" == "true" ]]; then IOMMU_PENDING_REBOOT=true HOST_CONFIG_CHANGED=true - dialog --backtitle "ProxMenux" \ - --title "$(translate 'IOMMU Pending Reboot')" \ - --msgbox "\n$(translate 'IOMMU is already configured for next boot, but it is not active yet.')\n\n$(translate 'GPU passthrough configuration will continue now and will become effective after host reboot.')" \ + _gpu_register_vfio_iommu_tool + VM_STORAGE_IOMMU_PENDING_REBOOT=1 + export VM_STORAGE_IOMMU_PENDING_REBOOT + _pmx_msgbox "$(translate 'IOMMU Pending Reboot')" \ + "\n$(translate 'IOMMU is already configured for next boot, but it is not active yet.')\n\n$(translate 'GPU passthrough configuration will continue now and will become effective after host reboot.')" \ 11 78 return 0 fi @@ -533,9 +606,7 @@ check_iommu_enabled() { msg+="$(translate 'Note: A system reboot will be required after enabling IOMMU.')\n" msg+="$(translate 'Configuration will continue now and be effective after reboot.')" - dialog --backtitle "ProxMenux" \ - --title "$(translate 'IOMMU Required')" \ - --yesno "$msg" 15 72 + _pmx_yesno "$(translate 'IOMMU Required')" "$msg" 15 72 local response=$? [[ "$WIZARD_CALL" != "true" ]] && clear @@ -553,6 +624,9 @@ check_iommu_enabled() { fi IOMMU_PENDING_REBOOT=true HOST_CONFIG_CHANGED=true + _gpu_register_vfio_iommu_tool + VM_STORAGE_IOMMU_PENDING_REBOOT=1 + export VM_STORAGE_IOMMU_PENDING_REBOOT echo msg_success "$(translate 'IOMMU configured. GPU passthrough setup will continue now and will be effective after reboot.')" echo @@ -632,14 +706,11 @@ select_gpu() { done local choice - local -a clear_opt=() - [[ "$WIZARD_CALL" != "true" ]] && clear_opt+=(--clear) - choice=$(dialog "${clear_opt[@]}" --backtitle "ProxMenux" --colors \ - --title "$(translate 'Select GPU for VM Passthrough')" \ - --menu "\n$(translate 'Select the GPU to pass through to the VM:')" \ + choice=$(_pmx_menu \ + "$(translate 'Select GPU for VM Passthrough')" \ + "\n$(translate 'Select the GPU to pass through to the VM:')" \ 18 82 10 \ - "${menu_items[@]}" \ - 2>&1 >/dev/tty) || exit 0 + "${menu_items[@]}") || exit 0 SELECTED_GPU="${ALL_GPU_TYPES[$choice]}" SELECTED_GPU_PCI="${ALL_GPU_PCIS[$choice]}" @@ -665,9 +736,7 @@ warn_single_gpu() { msg+="$(translate 'Make sure you have SSH or Web UI access before rebooting.')\n\n" msg+="$(translate 'Do you want to continue?')" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Single GPU Warning')" \ - --yesno "$msg" 22 76 + _pmx_yesno "$(translate 'Single GPU Warning')" "$msg" 22 76 [[ $? -ne 0 ]] && exit 0 } @@ -765,9 +834,7 @@ check_intel_vm_compatibility() { msg+="$(translate 'This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.')\n\n" msg+="$(translate 'Recommended: use GPU with LXC workloads instead of VM passthrough on this hardware.')" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Blocked GPU ID')" \ - --msgbox "$msg" 20 84 + _pmx_msgbox "$(translate 'Blocked GPU ID')" "$msg" 20 84 exit 0 fi @@ -782,9 +849,7 @@ check_intel_vm_compatibility() { msg+="$(translate 'This state has a high probability of VM startup/reset failures.')\n\n" msg+="\Zb$(translate 'Configuration has been stopped to prevent an unusable VM state.')\Zn" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'High-Risk GPU Power State')" \ - --msgbox "$msg" 20 80 + _pmx_msgbox "$(translate 'High-Risk GPU Power State')" "$msg" 20 80 exit 0 fi @@ -800,9 +865,7 @@ check_intel_vm_compatibility() { msg+="$(translate 'startup/restart errors are likely.')\n\n" msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Reset Capability Blocked')" \ - --msgbox "$msg" 20 80 + _pmx_msgbox "$(translate 'Reset Capability Blocked')" "$msg" 20 80 exit 0 fi @@ -818,9 +881,7 @@ check_intel_vm_compatibility() { msg+="$(translate 'start/restart failures and reset instability.')\n\n" msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Reset Capability Blocked')" \ - --msgbox "$msg" 20 80 + _pmx_msgbox "$(translate 'Reset Capability Blocked')" "$msg" 20 80 exit 0 fi @@ -834,9 +895,7 @@ check_intel_vm_compatibility() { msg+="$(translate 'Passthrough may work, but startup/restart reliability is not guaranteed.')\n\n" msg+="$(translate 'Do you want to continue anyway?')" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Reset Capability Warning')" \ - --yesno "$msg" 18 78 + _pmx_yesno "$(translate 'Reset Capability Warning')" "$msg" 18 78 [[ $? -ne 0 ]] && exit 0 fi } @@ -872,9 +931,7 @@ check_gpu_vm_compatibility() { msg+=" • $(translate 'Potential QEMU startup/assertion failures')\n\n" msg+="\Zb$(translate 'Configuration has been stopped to prevent an unusable VM state.')\Zn" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'High-Risk GPU Power State')" \ - --msgbox "$msg" 22 80 + _pmx_msgbox "$(translate 'High-Risk GPU Power State')" "$msg" 22 80 exit 0 fi @@ -903,9 +960,7 @@ check_gpu_vm_compatibility() { msg+=" — QEMU IRQ assertion failure → VM does not start\n\n" msg+="\Zb$(translate 'Configuration has been stopped to prevent leaving the VM in an unusable state.')\Zn" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Incompatible GPU for VM Passthrough')" \ - --msgbox "$msg" 26 80 + _pmx_msgbox "$(translate 'Incompatible GPU for VM Passthrough')" "$msg" 26 80 exit 0 fi @@ -922,9 +977,7 @@ check_gpu_vm_compatibility() { msg+="$(translate 'for this policy and may fail after first use or on subsequent VM starts.')\n\n" msg+="\Zb$(translate 'Configuration has been stopped due to high reset risk.')\Zn" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Reset Capability Blocked')" \ - --msgbox "$msg" 20 80 + _pmx_msgbox "$(translate 'Reset Capability Blocked')" "$msg" 20 80 exit 0 fi @@ -939,9 +992,7 @@ check_gpu_vm_compatibility() { msg+="$(translate 'Passthrough may fail depending on hardware/firmware implementation.')\n\n" msg+="$(translate 'Do you want to continue anyway?')" - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'Reset Capability Warning')" \ - --yesno "$msg" 18 78 + _pmx_yesno "$(translate 'Reset Capability Warning')" "$msg" 18 78 [[ $? -ne 0 ]] && exit 0 fi } @@ -965,16 +1016,14 @@ analyze_iommu_group() { did=$(cat "/sys/bus/pci/devices/${pci_full}/device" 2>/dev/null | sed 's/0x//') [[ -n "$vid" && -n "$did" ]] && IOMMU_VFIO_IDS+=("${vid}:${did}") - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'IOMMU Group Pending')" \ - --msgbox "\n$(translate 'IOMMU groups are not available yet because reboot is pending.')\n\n$(translate 'The script will preconfigure the selected GPU now and finalize hardware binding after reboot.')\n\n$(translate 'Selected GPU function'):\n • ${pci_full}" \ + _pmx_msgbox "$(translate 'IOMMU Group Pending')" \ + "\n$(translate 'IOMMU groups are not available yet because reboot is pending.')\n\n$(translate 'The script will preconfigure the selected GPU now and finalize hardware binding after reboot.')\n\n$(translate 'Selected GPU function'):\n • ${pci_full}" \ 14 82 return 0 fi - dialog --backtitle "ProxMenux" \ - --title "$(translate 'IOMMU Group Error')" \ - --msgbox "\n$(translate 'Could not determine the IOMMU group for the selected GPU.')\n\n$(translate 'Make sure IOMMU is properly enabled and the system has been rebooted after activation.')" \ + _pmx_msgbox "$(translate 'IOMMU Group Error')" \ + "\n$(translate 'Could not determine the IOMMU group for the selected GPU.')\n\n$(translate 'Make sure IOMMU is properly enabled and the system has been rebooted after activation.')" \ 10 72 exit 1 fi @@ -1016,19 +1065,6 @@ analyze_iommu_group() { [[ "$dev" != "$pci_full" ]] && extra_devices=$((extra_devices + 1)) done - local msg - msg="$(translate 'IOMMU Group'): ${IOMMU_GROUP}\n\n" - msg+="$(translate 'The following devices will all be passed to the VM') " - msg+="($(translate 'IOMMU isolation rule')):\n\n" - msg+="${display_lines}" - - if [[ $extra_devices -gt 0 ]]; then - msg+="\n\Z1$(translate 'All devices in the same IOMMU group must be passed together.')\Zn" - fi - - dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'IOMMU Group') ${IOMMU_GROUP}" \ - --msgbox "\n${msg}" 22 82 } detect_optional_gpu_audio() { @@ -1078,9 +1114,8 @@ select_vm() { VM_NAME=$(qm config "$SELECTED_VMID" 2>/dev/null | grep "^name:" | awk '{print $2}') return 0 fi - dialog --backtitle "ProxMenux" \ - --title "$(translate 'Invalid VMID')" \ - --msgbox "\n$(translate 'The preselected VMID does not exist on this host:') ${PRESELECT_VMID}" 9 72 + _pmx_msgbox "$(translate 'Invalid VMID')" \ + "\n$(translate 'The preselected VMID does not exist on this host:') ${PRESELECT_VMID}" 9 72 exit 1 fi @@ -1097,19 +1132,17 @@ select_vm() { done < <(qm list 2>/dev/null) if [[ ${#menu_items[@]} -eq 0 ]]; then - dialog --backtitle "ProxMenux" \ - --title "$(translate 'No VMs Found')" \ - --msgbox "\n$(translate 'No Virtual Machines found on this system.')\n\n$(translate 'Create a VM first (machine type q35 + UEFI BIOS), then run this option again.')" \ + _pmx_msgbox "$(translate 'No VMs Found')" \ + "\n$(translate 'No Virtual Machines found on this system.')\n\n$(translate 'Create a VM first (machine type q35 + UEFI BIOS), then run this option again.')" \ 10 68 exit 0 fi - SELECTED_VMID=$(dialog --backtitle "ProxMenux" \ - --title "$(translate 'Select Virtual Machine')" \ - --menu "\n$(translate 'Select the VM to add the GPU to:')" \ + SELECTED_VMID=$(_pmx_menu \ + "$(translate 'Select Virtual Machine')" \ + "\n$(translate 'Select the VM to add the GPU to:')" \ 20 72 12 \ - "${menu_items[@]}" \ - 2>&1 >/dev/tty) || exit 0 + "${menu_items[@]}") || exit 0 VM_NAME=$(qm config "$SELECTED_VMID" 2>/dev/null | grep "^name:" | awk '{print $2}') } @@ -1138,9 +1171,7 @@ check_vm_machine_type() { msg+=" • $(translate 'BIOS: OVMF (UEFI)')\n" msg+=" • $(translate 'Storage controller: VirtIO SCSI')" - dialog --backtitle "ProxMenux" \ - --title "$(translate 'Incompatible Machine Type')" \ - --msgbox "$msg" 20 78 + _pmx_msgbox "$(translate 'Incompatible Machine Type')" "$msg" 20 78 exit 0 } @@ -1210,13 +1241,11 @@ check_switch_mode() { msg+="\Z1\Zb$(translate 'Start on boot enabled (onboot=1)'): ${onboot_count}\Zn\n" msg+="\n\Z1$(translate 'After this LXC → VM switch, reboot the host so the new binding state is applied cleanly.')\Zn" - action_choice=$(dialog --backtitle "ProxMenux" --colors \ - --title "$(translate 'GPU Used in LXC Containers')" \ - --default-item "2" \ - --menu "$msg" 25 96 8 \ + action_choice=$(_pmx_menu --default-item "2" \ + "$(translate 'GPU Used in LXC Containers')" \ + "$msg" 25 96 8 \ "1" "$(translate 'Keep GPU in LXC config (disable Start on boot)')" \ - "2" "$(translate 'Remove GPU from LXC config (keep Start on boot)')" \ - 2>&1 >/dev/tty) || exit 0 + "2" "$(translate 'Remove GPU from LXC config (keep Start on boot)')") || exit 0 case "$action_choice" in 1) LXC_SWITCH_ACTION="keep_gpu_disable_onboot" ;; @@ -1254,9 +1283,7 @@ check_switch_mode() { msg+=" Hardware Graphics → Add GPU to VM\n" msg+="$(translate 'to move the GPU safely.')" - dialog --backtitle "ProxMenux" \ - --title "$(translate 'GPU Busy in Running VM')" \ - --msgbox "$msg" 16 78 + _pmx_msgbox "$(translate 'GPU Busy in Running VM')" "$msg" 16 78 exit 0 fi @@ -1292,13 +1319,11 @@ check_switch_mode() { msg+="$(translate 'Choose conflict policy for the source VM:')" local vm_action_choice - vm_action_choice=$(dialog --clear --backtitle "ProxMenux" --colors \ - --title "$(translate 'GPU Already Assigned to Another VM')" \ - --default-item "1" \ - --menu "$msg" 24 98 8 \ + vm_action_choice=$(_pmx_menu --default-item "1" \ + "$(translate 'GPU Already Assigned to Another VM')" \ + "$msg" 24 84 8 \ "1" "$(translate 'Keep GPU in source VM config (disable Start on boot if enabled)')" \ - "2" "$(translate 'Remove GPU from source VM config (keep Start on boot)')" \ - 2>&1 >/dev/tty) || exit 0 + "2" "$(translate 'Remove GPU from source VM config (keep Start on boot)')") || exit 0 case "$vm_action_choice" in 1) SWITCH_VM_ACTION="keep_gpu_disable_onboot" ;; @@ -1376,9 +1401,7 @@ confirm_summary() { local run_title run_title=$(_get_vm_run_title) - dialog --clear --backtitle "ProxMenux" --colors \ - --title "${run_title}" \ - --yesno "$msg" 28 78 + _pmx_yesno "${run_title}" "$msg" 28 78 [[ $? -ne 0 ]] && exit 0 } @@ -1724,7 +1747,7 @@ cleanup_vm_config() { local pci_slot="${SELECTED_GPU_PCI#0000:}" pci_slot="${pci_slot%.*}" # 01:00 - if [[ "$VM_SWITCH_ACTION" == "keep_gpu_disable_onboot" ]]; then + if [[ "$SWITCH_VM_ACTION" == "keep_gpu_disable_onboot" ]]; then msg_info "$(translate 'Keeping GPU in source VM config') ${SWITCH_VM_SRC}..." if _vm_onboot_enabled "$SWITCH_VM_SRC"; then if qm set "$SWITCH_VM_SRC" -onboot 0 >>"$LOG_FILE" 2>&1; then @@ -1916,7 +1939,6 @@ main() { if [[ "$WIZARD_CALL" == "true" ]]; then echo else - clear show_proxmenux_logo msg_title "${run_title}" fi diff --git a/scripts/gpu_tpu/gpu-tpu-manual-guide.sh b/scripts/gpu_tpu/gpu-tpu-manual-guide.sh new file mode 100644 index 00000000..bfb467b6 --- /dev/null +++ b/scripts/gpu_tpu/gpu-tpu-manual-guide.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# ========================================================== +# ProxMenux - GPU/TPU Manual CLI Guide +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : GPL-3.0 +# Version : 1.0 +# Last Updated: 07/04/2026 +# ========================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi + +if [[ -f "$UTILS_FILE" ]]; then + source "$UTILS_FILE" +fi +load_language +initialize_cache + +GREEN=$'\033[0;32m' +NC=$'\033[0m' + +_cl() { + # _cl + # Prints a numbered command line with fixed-column alignment (separator at col 52). + local num="$1" disp="$2" desc="$3" + local pad=$((47 - ${#disp})) + [[ $pad -lt 1 ]] && pad=1 + local spaces + spaces=$(printf '%*s' "$pad" '') + printf " %2d) %s%s%s%s - %s\n" "$num" "$GREEN" "$disp" "$NC" "$spaces" "$desc" +} + +while true; do + clear + show_proxmenux_logo + msg_title "$(translate "GPU/TPU - Manual CLI Guide")" + echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}" + echo + + _cl 1 "lspci -nn | grep -iE 'VGA|3D|Display'" "$(translate 'Detect GPUs in host')" + _cl 2 "lspci -nnk | grep -A3 -Ei 'VGA|3D'" "$(translate 'Show GPU kernel driver in use')" + _cl 3 "cat /proc/cmdline" "$(translate 'Check kernel params (IOMMU flags)')" + _cl 4 "dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'" "$(translate 'Inspect passthrough/kernel events')" + _cl 5 "find /sys/kernel/iommu_groups -type l" "$(translate 'List IOMMU group mapping')" + _cl 6 "lsmod | grep -E 'vfio|nvidia|amdgpu|apex'" "$(translate 'Check loaded GPU/TPU modules')" + _cl 7 "grep -R \"vfio-pci|blacklist\" /etc/modprobe.d" "$(translate 'Review passthrough config files')" + _cl 8 "nvidia-smi" "$(translate 'Check NVIDIA driver and devices')" + _cl 9 "qm config | grep 'hostpci|bios'" "$(translate 'Check VM passthrough settings')" + _cl 10 "pct config | grep 'dev|lxc.cgroup2'" "$(translate 'Check LXC GPU/TPU mapping')" + _cl 11 "ls -l /dev/dri /dev/kfd /dev/nvidia*" "$(translate 'Inspect host device nodes')" + _cl 12 "qm set --hostpci ,pcie=1" "[T] $(translate 'Assign GPU PCI function to VM')" + _cl 13 "qm set -delete hostpci" "[T] $(translate 'Remove passthrough device from VM')" + _cl 14 "qm set -onboot 0" "[T] $(translate 'Disable autostart on conflicting VM')" + _cl 15 "sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'" "[T] $(translate 'Enable IOMMU in GRUB or ZFS boot')" + _cl 16 "update-initramfs -u && proxmox-boot-tool" "[T] $(translate 'Apply boot/initramfs changes')" + _cl 17 "lsusb | grep Coral ; lspci | grep Unichip" "$(translate 'Check Coral USB/M.2 detection')" + echo -e " ${DEF} 0) $(translate 'Back to previous menu or Esc + Enter')${CL}" + echo + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}" + read -r user_input + + if [[ "$user_input" == $'\x1b' ]]; then + break + fi + + mode="exec" + case "$user_input" in + 1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;; + 2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;; + 3) cmd="cat /proc/cmdline" ;; + 4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;; + 5) cmd="find /sys/kernel/iommu_groups -type l" ;; + 6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;; + 7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;; + 8) cmd="nvidia-smi" ;; + 9) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}" + read -r vmid + cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'" + ;; + 10) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}" + read -r ctid + cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'" + ;; + 11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;; + 12) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf + cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1" + mode="template" + ;; + 13) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot + cmd="qm set $vmid -delete hostpci${slot}" + mode="template" + ;; + 14) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + cmd="qm set $vmid -onboot 0" + mode="template" + ;; + 15) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor + case "$cpu_vendor" in + amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;; + *) iommu_param="intel_iommu=on iommu=pt" ;; + esac + case "$boot_type" in + zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;; + *) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;; + esac + mode="template" + ;; + 16) + cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)" + mode="template" + ;; + 17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;; + 0) break ;; + *) + if [[ -n "$user_input" ]]; then + cmd="$user_input" + else + continue + fi + ;; + esac + + if [[ "$mode" == "template" ]]; then + echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n" + echo "$cmd" + echo + msg_success "$(translate 'Press ENTER to continue...')" + read -r tmp + continue + fi + + echo -e "\n${GREEN}> $cmd${NC}\n" + bash -c "$cmd" + echo + msg_success "$(translate 'Press ENTER to continue...')" + read -r tmp +done + diff --git a/scripts/gpu_tpu/nvidia_installer.sh b/scripts/gpu_tpu/nvidia_installer.sh index c733b8bd..70548fa6 100644 --- a/scripts/gpu_tpu/nvidia_installer.sh +++ b/scripts/gpu_tpu/nvidia_installer.sh @@ -57,6 +57,33 @@ detect_nvidia_gpus() { fi } +check_gpu_not_in_vm_passthrough() { + local dev vendor driver vfio_list="" + for dev in /sys/bus/pci/devices/*; do + vendor=$(cat "$dev/vendor" 2>/dev/null) + [[ "$vendor" != "0x10de" ]] && continue + if [[ -L "$dev/driver" ]]; then + driver=$(basename "$(readlink "$dev/driver")") + if [[ "$driver" == "vfio-pci" ]]; then + vfio_list+=" • $(basename "$dev")\n" + fi + fi + done + + [[ -z "$vfio_list" ]] && return 0 + + local msg + msg="\n$(translate "One or more NVIDIA GPUs are currently configured for VM passthrough (vfio-pci):")\n\n" + msg+="${vfio_list}\n" + msg+="$(translate "Installing host drivers while the GPU is assigned to a VM could break passthrough and destabilize the system.")\n\n" + msg+="$(translate "To install host drivers, first remove the GPU from VM passthrough configuration and reboot.")" + + dialog --backtitle "ProxMenux" \ + --title "$(translate "GPU in VM Passthrough Mode")" \ + --msgbox "$msg" 16 78 + exit 0 +} + detect_driver_status() { CURRENT_DRIVER_INSTALLED=false CURRENT_DRIVER_VERSION="" @@ -842,6 +869,7 @@ main() { detect_nvidia_gpus detect_driver_status + check_gpu_not_in_vm_passthrough if ! $NVIDIA_GPU_PRESENT; then dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \ diff --git a/scripts/gpu_tpu/nvidia_update.sh b/scripts/gpu_tpu/nvidia_update.sh index 7b2d6ff5..23cdd642 100644 --- a/scripts/gpu_tpu/nvidia_update.sh +++ b/scripts/gpu_tpu/nvidia_update.sh @@ -23,6 +23,37 @@ load_language initialize_cache +# ============================================================ +# GPU passthrough guard — block update when GPU is in VM passthrough mode +# ============================================================ +check_gpu_not_in_vm_passthrough() { + local dev vendor driver vfio_list="" + for dev in /sys/bus/pci/devices/*; do + vendor=$(cat "$dev/vendor" 2>/dev/null) + [[ "$vendor" != "0x10de" ]] && continue + if [[ -L "$dev/driver" ]]; then + driver=$(basename "$(readlink "$dev/driver")") + if [[ "$driver" == "vfio-pci" ]]; then + vfio_list+=" • $(basename "$dev")\n" + fi + fi + done + + [[ -z "$vfio_list" ]] && return 0 + + local msg + msg="\n$(translate "One or more NVIDIA GPUs are currently configured for VM passthrough (vfio-pci):")\n\n" + msg+="${vfio_list}\n" + msg+="$(translate "Updating host drivers while the GPU is assigned to a VM could break passthrough and destabilize the system.")\n\n" + msg+="$(translate "To update host drivers, first remove the GPU from VM passthrough configuration and reboot.")" + + dialog --backtitle "ProxMenux" \ + --title "$(translate "GPU in VM Passthrough Mode")" \ + --msgbox "$msg" 16 78 + exit 0 +} + + # ============================================================ # Host NVIDIA state detection # ============================================================ @@ -436,13 +467,25 @@ show_current_state_dialog() { # Restart prompt # ============================================================ restart_prompt() { - if whiptail --title "$(translate 'NVIDIA Update')" --yesno \ - "$(translate 'The host driver update requires a reboot to take effect. Reboot now?')" 10 70; then - msg_warn "$(translate 'Restarting the server...')" + echo + msg_success "$(translate 'NVIDIA driver update completed.')" + echo + msg_info "$(translate 'Removing no longer required packages and purging old cached updates...')" + apt-get -y autoremove >/dev/null 2>&1 + apt-get -y autoclean >/dev/null 2>&1 + msg_ok "$(translate 'Cleanup finished.')" + echo -e "${TAB}${BL}Log: ${LOG_FILE}${CL}" + echo + + if whiptail --title "$(translate 'Reboot Required')" \ + --yesno "$(translate 'The host driver update requires a reboot to take effect. Do you want to restart now?')" 10 70; then + msg_success "$(translate 'Press Enter to continue...')" + read -r + msg_warn "$(translate 'Rebooting the system...')" reboot else - msg_success "$(translate 'Update complete. Please reboot the server manually.')" - msg_success "$(translate 'Completed. Press Enter to return to menu...')" + msg_info2 "$(translate 'You can reboot later manually.')" + msg_success "$(translate 'Press Enter to continue...')" read -r fi } @@ -455,6 +498,7 @@ main() { : >"$LOG_FILE" # ---- Phase 1: dialogs ---- + check_gpu_not_in_vm_passthrough detect_host_nvidia show_current_state_dialog select_target_version diff --git a/scripts/gpu_tpu/switch_gpu_mode.sh b/scripts/gpu_tpu/switch_gpu_mode.sh index 1c869c7c..4ac95e23 100644 --- a/scripts/gpu_tpu/switch_gpu_mode.sh +++ b/scripts/gpu_tpu/switch_gpu_mode.sh @@ -888,6 +888,48 @@ apply_vm_action_for_lxc_mode() { done } +_register_iommu_tool() { + local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json" + command -v jq >/dev/null 2>&1 || return 0 + [[ -f "$tools_json" ]] || echo "{}" > "$tools_json" + jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \ + && mv "$tools_json.tmp" "$tools_json" || true +} + +_enable_iommu_cmdline() { + local cpu_vendor + cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') + + local iommu_param + if [[ "$cpu_vendor" == "GenuineIntel" ]]; then + iommu_param="intel_iommu=on" + elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then + iommu_param="amd_iommu=on" + else + return 1 + fi + + local cmdline_file="/etc/kernel/cmdline" + local grub_file="/etc/default/grub" + + if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then + if ! grep -q "$iommu_param" "$cmdline_file"; then + cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" + sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file" + proxmox-boot-tool refresh >>"$LOG_FILE" 2>&1 || true + fi + elif [[ -f "$grub_file" ]]; then + if ! grep -q "$iommu_param" "$grub_file"; then + cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" + sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file" + update-grub >>"$LOG_FILE" 2>&1 || true + fi + else + return 1 + fi + return 0 +} + switch_to_vm_mode() { detect_affected_lxc_for_selected prompt_lxc_action_for_vm_mode @@ -897,6 +939,25 @@ switch_to_vm_mode() { apply_lxc_action_for_vm_mode msg_info "$(translate 'Configuring host for GPU -> VM mode...')" + + if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then + _register_iommu_tool + msg_ok "$(translate 'IOMMU is already active on this system')" | tee -a "$screen_capture" + elif grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \ + grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then + _register_iommu_tool + HOST_CONFIG_CHANGED=true + msg_ok "$(translate 'IOMMU already configured in kernel parameters')" | tee -a "$screen_capture" + else + if _enable_iommu_cmdline; then + _register_iommu_tool + HOST_CONFIG_CHANGED=true + msg_ok "$(translate 'IOMMU kernel parameters configured')" | tee -a "$screen_capture" + else + msg_warn "$(translate 'Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.')" | tee -a "$screen_capture" + fi + fi + _add_vfio_modules msg_ok "$(translate 'VFIO modules configured in /etc/modules')" | tee -a "$screen_capture" _configure_iommu_options @@ -1011,6 +1072,45 @@ switch_to_lxc_mode() { fi } +# ========================================================== +# Send notification when GPU mode switch completes +# ========================================================== +_send_gpu_mode_notification() { + local new_mode="$1" + local old_mode="$2" + local notify_script="/usr/bin/notification_manager.py" + + [[ ! -f "$notify_script" ]] && return 0 + + local hostname_short + hostname_short=$(hostname -s) + + # Build GPU list for notification + local gpu_list="" + local idx + for idx in "${SELECTED_GPU_IDX[@]}"; do + gpu_list+="${ALL_GPU_NAMES[$idx]} (${ALL_GPU_PCIS[$idx]}), " + done + gpu_list="${gpu_list%, }" + + local mode_label details + if [[ "$new_mode" == "vm" ]]; then + mode_label="GPU -> VM (VFIO passthrough)" + details="GPU(s) ready for VM passthrough. A host reboot may be required." + else + mode_label="GPU -> LXC (native driver)" + details="GPU(s) available for LXC containers with native drivers." + fi + + python3 "$notify_script" --action send-raw --severity INFO \ + --title "${hostname_short}: GPU mode changed to ${mode_label}" \ + --message "GPU passthrough mode switched. +GPU(s): ${gpu_list} +Previous: ${old_mode} +New: ${mode_label} +${details}" 2>/dev/null || true +} + confirm_plan() { local msg mode_line if [[ "$TARGET_MODE" == "vm" ]]; then @@ -1079,12 +1179,22 @@ main() { _set_title echo + # Determine old mode before switch for notification + local old_mode_label + if [[ "$CURRENT_MODE" == "vm" ]]; then + old_mode_label="GPU -> VM (VFIO)" + else + old_mode_label="GPU -> LXC (native)" + fi + if [[ "$TARGET_MODE" == "vm" ]]; then switch_to_vm_mode msg_success "$(translate 'GPU switch complete: VM mode prepared.')" + _send_gpu_mode_notification "vm" "$old_mode_label" else switch_to_lxc_mode msg_success "$(translate 'GPU switch complete: LXC mode prepared.')" + _send_gpu_mode_notification "lxc" "$old_mode_label" fi final_summary diff --git a/scripts/gpu_tpu/switch_gpu_mode_direct.sh b/scripts/gpu_tpu/switch_gpu_mode_direct.sh index 36196bc6..dfe5536e 100644 --- a/scripts/gpu_tpu/switch_gpu_mode_direct.sh +++ b/scripts/gpu_tpu/switch_gpu_mode_direct.sh @@ -816,6 +816,48 @@ apply_vm_action_for_lxc_mode() { # ========================================================== # Switch Mode Functions # ========================================================== +_register_iommu_tool() { + local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json" + command -v jq >/dev/null 2>&1 || return 0 + [[ -f "$tools_json" ]] || echo "{}" > "$tools_json" + jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \ + && mv "$tools_json.tmp" "$tools_json" || true +} + +_enable_iommu_cmdline() { + local cpu_vendor + cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') + + local iommu_param + if [[ "$cpu_vendor" == "GenuineIntel" ]]; then + iommu_param="intel_iommu=on" + elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then + iommu_param="amd_iommu=on" + else + return 1 + fi + + local cmdline_file="/etc/kernel/cmdline" + local grub_file="/etc/default/grub" + + if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then + if ! grep -q "$iommu_param" "$cmdline_file"; then + cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" + sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file" + proxmox-boot-tool refresh >>"$LOG_FILE" 2>&1 || true + fi + elif [[ -f "$grub_file" ]]; then + if ! grep -q "$iommu_param" "$grub_file"; then + cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" + sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file" + update-grub >>"$LOG_FILE" 2>&1 || true + fi + else + return 1 + fi + return 0 +} + switch_to_vm_mode() { detect_affected_lxc_for_selected prompt_lxc_action_for_vm_mode @@ -825,6 +867,25 @@ switch_to_vm_mode() { apply_lxc_action_for_vm_mode msg_info "$(translate 'Configuring host for GPU -> VM mode...')" + + if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then + _register_iommu_tool + msg_ok "$(translate 'IOMMU is already active on this system')" | tee -a "$screen_capture" + elif grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \ + grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then + _register_iommu_tool + HOST_CONFIG_CHANGED=true + msg_ok "$(translate 'IOMMU already configured in kernel parameters')" | tee -a "$screen_capture" + else + if _enable_iommu_cmdline; then + _register_iommu_tool + HOST_CONFIG_CHANGED=true + msg_ok "$(translate 'IOMMU kernel parameters configured')" | tee -a "$screen_capture" + else + msg_warn "$(translate 'Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.')" | tee -a "$screen_capture" + fi + fi + _add_vfio_modules msg_ok "$(translate 'VFIO modules configured in /etc/modules')" | tee -a "$screen_capture" _configure_iommu_options @@ -986,6 +1047,39 @@ final_summary() { # ========================================================== # Parse Arguments (supports both CLI args and env vars) +# ========================================================== +# Send notification when GPU mode switch completes +# ========================================================== +_send_gpu_mode_notification() { + local new_mode="$1" + local gpu_name="$2" + local gpu_pci="$3" + local old_mode="$4" + local notify_script="/usr/bin/notification_manager.py" + + [[ ! -f "$notify_script" ]] && return 0 + + local hostname_short + hostname_short=$(hostname -s) + + local mode_label details + if [[ "$new_mode" == "vm" ]]; then + mode_label="GPU -> VM (VFIO passthrough)" + details="GPU is now ready for VM passthrough. A host reboot may be required." + else + mode_label="GPU -> LXC (native driver)" + details="GPU is now available for LXC containers with native drivers." + fi + + python3 "$notify_script" --action send-raw --severity INFO \ + --title "${hostname_short}: GPU mode changed to ${mode_label}" \ + --message "GPU passthrough mode switched. +GPU: ${gpu_name} (${gpu_pci}) +Previous: ${old_mode} +New: ${mode_label} +${details}" 2>/dev/null || true +} + # ========================================================== parse_arguments() { # First, check combined parameter (format: "SLOT|MODE") @@ -1066,13 +1160,28 @@ main() { _set_title echo + # Determine old mode before switch for notification + local old_mode_label + if [[ "$CURRENT_MODE" == "vm" ]]; then + old_mode_label="GPU -> VM (VFIO)" + else + old_mode_label="GPU -> LXC (native)" + fi + + # Get GPU info for notification + local gpu_idx="${SELECTED_GPU_IDX[0]}" + local gpu_name="${ALL_GPU_NAMES[$gpu_idx]}" + local gpu_pci="${ALL_GPU_PCIS[$gpu_idx]}" + # Execute the switch if [[ "$TARGET_MODE" == "vm" ]]; then switch_to_vm_mode msg_success "$(translate 'GPU switch complete: VM mode prepared.')" + _send_gpu_mode_notification "vm" "$gpu_name" "$gpu_pci" "$old_mode_label" else switch_to_lxc_mode msg_success "$(translate 'GPU switch complete: LXC mode prepared.')" + _send_gpu_mode_notification "lxc" "$gpu_name" "$gpu_pci" "$old_mode_label" fi final_summary diff --git a/scripts/help_info_menu.sh b/scripts/help_info_menu.sh index c76af05d..ad7530fc 100644 --- a/scripts/help_info_menu.sh +++ b/scripts/help_info_menu.sh @@ -303,7 +303,7 @@ show_storage_commands() { 15) cmd="lvs" ;; 16) cmd="cat /etc/pve/storage.cfg" ;; 17) cmd="pvesm status" ;; - 19) + 18) echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter storage ID: ')${CL}" read -r store cmd="pvesm list $store" @@ -591,42 +591,116 @@ show_update_commands() { # =============================================================== -# 06 GPU Passthrough Commands +# 06 GPU/TPU Passthrough Commands # =============================================================== show_gpu_commands() { while true; do clear - echo -e "${YELLOW}$(translate 'GPU Passthrough Commands')${NC}" - echo "------------------------------------------------" - echo -e " 1) ${GREEN}lspci -nn | grep -i nvidia${NC} - $(translate 'List NVIDIA PCI devices')" - echo -e " 2) ${GREEN}lspci -nn | grep -i vga${NC} - $(translate 'List all VGA compatible devices')" - echo -e " 3) ${GREEN}dmesg | grep -i vfio${NC} - $(translate 'Check VFIO module messages')" - echo -e " 4) ${GREEN}cat /etc/modprobe.d/vfio.conf${NC} - $(translate 'Review VFIO passthrough configuration')" - echo -e " 5) ${GREEN}update-initramfs -u${NC} - $(translate 'Apply initramfs changes (VFIO)')" - echo -e " 6) ${GREEN}cat /etc/default/grub${NC} - $(translate 'Review GRUB options for IOMMU')" - echo -e " 7) ${GREEN}update-grub${NC} - $(translate 'Apply GRUB changes')" + echo -e "${YELLOW}$(translate 'GPU/TPU Passthrough Commands')${NC}" + echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}" + echo "------------------------------------------------------------" + echo -e " 1) ${GREEN}lspci -nn | grep -iE 'VGA|3D|Display'${NC} - $(translate 'Detect GPUs in host')" + echo -e " 2) ${GREEN}lspci -nnk | grep -A3 -Ei 'VGA|3D'${NC} - $(translate 'Show GPU kernel driver in use')" + echo -e " 3) ${GREEN}cat /proc/cmdline${NC} - $(translate 'Check kernel params (IOMMU flags)')" + echo -e " 4) ${GREEN}dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'${NC} - $(translate 'Inspect passthrough/kernel events')" + echo -e " 5) ${GREEN}find /sys/kernel/iommu_groups -type l${NC} - $(translate 'List IOMMU group mapping')" + echo -e " 6) ${GREEN}lsmod | grep -E 'vfio|nvidia|amdgpu|apex'${NC} - $(translate 'Check loaded GPU/TPU modules')" + echo -e " 7) ${GREEN}grep -R \"vfio-pci|blacklist\" /etc/modprobe.d${NC} - $(translate 'Review passthrough config files')" + echo -e " 8) ${GREEN}nvidia-smi${NC} - $(translate 'Check NVIDIA driver and devices')" + echo -e " 9) ${GREEN}qm config | grep 'hostpci|bios'${NC} - [T] $(translate 'Check VM passthrough settings')" + echo -e "10) ${GREEN}pct config | grep 'dev|lxc.cgroup2'${NC} - [T] $(translate 'Check LXC GPU/TPU mapping')" + echo -e "11) ${GREEN}ls -l /dev/dri /dev/kfd /dev/nvidia*${NC} - $(translate 'Inspect host device nodes')" + echo -e "12) ${GREEN}qm set --hostpci ,pcie=1${NC} - [T] $(translate 'Assign GPU PCI function to VM')" + echo -e "13) ${GREEN}qm set -delete hostpci${NC} - [T] $(translate 'Remove passthrough device from VM')" + echo -e "14) ${GREEN}qm set -onboot 0${NC} - [T] $(translate 'Disable autostart on conflicting VM')" + echo -e "15) ${GREEN}sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'${NC} - [T] $(translate 'Enable IOMMU in GRUB or ZFS boot')" + echo -e "16) ${GREEN}update-initramfs -u && proxmox-boot-tool${NC} - [T] $(translate 'Apply boot/initramfs changes')" + echo -e "17) ${GREEN}lsusb | grep Coral ; lspci | grep Unichip${NC} - $(translate 'Check Coral USB/M.2 detection')" echo -e " ${DEF}0) $(translate ' Back to previous menu or Esc + Enter')" echo echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}" read -r user_input - # Check for Esc key press if [[ "$user_input" == $'\x1b' ]]; then break fi + mode="exec" case "$user_input" in - 1) cmd="lspci -nn | grep -i nvidia" ;; - 2) cmd="lspci -nn | grep -i vga" ;; - 3) cmd="dmesg | grep -i vfio" ;; - 4) cmd="cat /etc/modprobe.d/vfio.conf" ;; - 5) cmd="update-initramfs -u" ;; - 6) cmd="cat /etc/default/grub" ;; - 7) cmd="update-grub" ;; + 1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;; + 2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;; + 3) cmd="cat /proc/cmdline" ;; + 4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;; + 5) cmd="find /sys/kernel/iommu_groups -type l" ;; + 6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;; + 7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;; + 8) cmd="nvidia-smi" ;; + 9) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}" + read -r vmid + cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'" + ;; + 10) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}" + read -r ctid + cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'" + ;; + 11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;; + 12) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf + cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1" + mode="template" + ;; + 13) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot + cmd="qm set $vmid -delete hostpci${slot}" + mode="template" + ;; + 14) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + cmd="qm set $vmid -onboot 0" + mode="template" + ;; + 15) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor + case "$cpu_vendor" in + amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;; + *) iommu_param="intel_iommu=on iommu=pt" ;; + esac + case "$boot_type" in + zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;; + *) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;; + esac + mode="template" + ;; + 16) + cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)" + mode="template" + ;; + 17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;; 0) break ;; - *) cmd="$user_input" ;; + *) + if [[ -n "$user_input" ]]; then + cmd="$user_input" + else + continue + fi + ;; esac + if [[ "$mode" == "template" ]]; then + echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n" + echo "$cmd" + echo + msg_success "$(translate 'Press ENTER to continue...')" + read -r tmp + continue + fi + echo -e "\n${GREEN}> $cmd${NC}\n" bash -c "$cmd" echo @@ -913,7 +987,7 @@ show_tools_commands() { while true; do OPTION=$(dialog --stdout \ --title "$(translate 'Help and Info')" \ - --menu "\n$(translate 'Select a category of useful commands:')" 20 70 9 \ + --menu "$(translate 'Select a category of useful commands:')" 20 70 9 \ 1 "$(translate 'Useful System Commands')" \ 2 "$(translate 'VM and CT Management Commands')" \ 3 "$(translate 'Storage and Disks Commands')" \ diff --git a/scripts/menus/create_vm_menu.sh b/scripts/menus/create_vm_menu.sh index da45bcff..2b5394e3 100644 --- a/scripts/menus/create_vm_menu.sh +++ b/scripts/menus/create_vm_menu.sh @@ -134,14 +134,21 @@ function start_vm_configuration() { while true; do VM_STORAGE_IOMMU_PENDING_REBOOT=0 - OS_TYPE=$(dialog --backtitle "ProxMenux" \ + WIZARD_CONFLICT_POLICY="" + WIZARD_CONFLICT_SCOPE="" + export WIZARD_CONFLICT_POLICY WIZARD_CONFLICT_SCOPE + OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \ --title "$(translate "Select System Type")" \ --menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \ 1 "$(translate "Create") VM System NAS" \ 2 "$(translate "Create") VM System Windows" \ 3 "$(translate "Create") VM System Linux" \ + "" "" \ + "" "\Z4──────────────────────────────────────────────────\Zn" \ + "" "" \ 4 "$(translate "Create") VM System macOS (OSX-PROXMOX)" \ 5 "$(translate "Create") VM System Others (based Linux)" \ + "" "" \ 6 "$(translate "Return to Main Menu")" \ 3>&1 1>&2 2>&3) diff --git a/scripts/menus/hw_grafics_menu.sh b/scripts/menus/hw_grafics_menu.sh index b0b9976c..abb49633 100644 --- a/scripts/menus/hw_grafics_menu.sh +++ b/scripts/menus/hw_grafics_menu.sh @@ -27,20 +27,24 @@ initialize_cache while true; do OPTION=$(dialog --colors --backtitle "ProxMenux" \ --title "$(translate "GPUs and Coral-TPU Menu")" \ - --menu "\n$(translate "Select an option:")" 25 80 15 \ + --menu "\n$(translate "Select an option:")" 26 78 18 \ "" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \ "1" "$(translate "Install NVIDIA Drivers on Host")" \ "2" "$(translate "Update NVIDIA Drivers (Host + LXC)")" \ "3" "$(translate "Install/Update Coral TPU on Host")" \ + "" "" \ "" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \ "4" "$(translate "Add GPU to LXC (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \ "5" "$(translate "Add Coral TPU to LXC")" \ + "" "" \ "" "\Z4──────────────────────── VM ───────────────────────────\Zn" \ "6" "$(translate "Add GPU to VM (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \ "" "" \ "" "\Z4──────────────────── SWICHT MODE ───────────────────────\Zn" \ "7" "$(translate "Switch GPU Mode (VM <-> LXC)")" \ - "" "" \ + "" "" \ + "" "\Z4────────────────────── Utilities ───────────────────────\Zn" \ + "8" "$(translate "Manual CLI Guide (GPU/TPU)")" \ "0" "$(translate "Return to Main Menu")" \ 2>&1 >/dev/tty ) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; } @@ -67,6 +71,9 @@ while true; do 7) bash "$LOCAL_SCRIPTS/gpu_tpu/switch_gpu_mode.sh" ;; + 8) + bash "$LOCAL_SCRIPTS/gpu_tpu/gpu-tpu-manual-guide.sh" + ;; 0) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; diff --git a/scripts/menus/main_menu.sh b/scripts/menus/main_menu.sh index ee6f96d9..f26fdb2f 100644 --- a/scripts/menus/main_menu.sh +++ b/scripts/menus/main_menu.sh @@ -94,12 +94,12 @@ show_menu() { dialog --clear \ --backtitle "ProxMenux" \ --title "$(translate "$menu_title")" \ - --menu "$(translate "Select an option:")" 20 70 11 \ + --menu "\n$(translate "Select an option:")" 20 70 11 \ 1 "$(translate "Settings post-install Proxmox")" \ 2 "$(translate "Hardware: GPUs and Coral-TPU")" \ 3 "$(translate "Create VM from template or script")" \ - 4 "$(translate "Disk and Storage Manager")" \ - 5 "$(translate "Mount and Share Manager")" \ + 4 "$(translate "Disk Manager")" \ + 5 "$(translate "Storage & Share Manager")" \ 6 "$(translate "Proxmox VE Helper Scripts")" \ 7 "$(translate "Network Management")" \ 8 "$(translate "Security")" \ diff --git a/scripts/menus/menu_Helper_Scripts.sh b/scripts/menus/menu_Helper_Scripts.sh index e1df7113..3bf267d3 100644 --- a/scripts/menus/menu_Helper_Scripts.sh +++ b/scripts/menus/menu_Helper_Scripts.sh @@ -398,7 +398,7 @@ while true; do SELECTED_IDX=$(dialog --backtitle "ProxMenux" \ --title "Proxmox VE Helper-Scripts" \ --menu "$(translate "Select a category or search for scripts:"):" \ - 20 70 14 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || { + 22 75 15 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || { dialog --clear --title "ProxMenux" \ --msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70 exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" @@ -440,7 +440,7 @@ while true; do SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \ --title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \ --menu "$(translate "Choose a script to execute:"):" \ - 20 70 14 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break + 22 75 15 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}" run_script_by_slug "$SCRIPT_SELECTED" diff --git a/scripts/menus/security_menu.sh b/scripts/menus/security_menu.sh index d126da98..3d6ba935 100644 --- a/scripts/menus/security_menu.sh +++ b/scripts/menus/security_menu.sh @@ -26,13 +26,12 @@ initialize_cache security_menu() { while true; do local menu_text - menu_text="\n$(translate 'Security tools for hardening and auditing your Proxmox VE system.')\n\n" - menu_text+="$(translate 'Select an option:')" + menu_text+="\n$(translate 'Select an option:')" local OPTION OPTION=$(dialog --backtitle "ProxMenux" \ --title "$(translate "$SCRIPT_TITLE")" \ - --menu "$menu_text" 18 70 4 \ + --menu "$menu_text" 20 70 10 \ "1" "$(translate 'Fail2Ban - Intrusion Prevention')" \ "2" "$(translate 'Lynis - Security Audit')" \ 3>&1 1>&2 2>&3) || OPTION="0" diff --git a/scripts/menus/share_menu.sh b/scripts/menus/share_menu.sh index 0e27cfbd..3349658f 100644 --- a/scripts/menus/share_menu.sh +++ b/scripts/menus/share_menu.sh @@ -26,21 +26,22 @@ initialize_cache while true; do OPTION=$(dialog --colors --backtitle "ProxMenux" \ - --title "$(translate "Mount and Share Manager")" \ - --menu "\n$(translate "Select an option:")" 25 80 15 \ + --title "$(translate "Storage & Share Manager")" \ + --menu "\n$(translate "Select an option:")" 26 78 17 \ "" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \ "1" "$(translate "Configure NFS shared on Host")" \ "2" "$(translate "Configure Samba shared on Host")" \ "3" "$(translate "Configure Local Shared on Host")" \ - "9" "$(translate "Add Local Disk as Proxmox Storage")" \ - "10" "$(translate "Add iSCSI Target as Proxmox Storage")" \ - "" "\Z4──────────────────────── LXC ─────────────────────────\Zn" \ - "4" "$(translate "Configure LXC Mount Points (Host ↔ Container)")" \ + "4" "$(translate "Add Local Disk as Proxmox Storage")" \ + "5" "$(translate "Add iSCSI Target as Proxmox Storage")" \ "" "" \ - "5" "$(translate "Configure NFS Client in LXC (only privileged)")" \ - "6" "$(translate "Configure Samba Client in LXC (only privileged)")" \ - "7" "$(translate "Configure NFS Server in LXC (only privileged)")" \ - "8" "$(translate "configure Samba Server in LXC (only privileged)")" \ + "" "\Z4──────────────────────── LXC ─────────────────────────\Zn" \ + "6" "$(translate "Configure LXC Mount Points (Host ↔ Container)")" \ + "" "" \ + "7" "$(translate "Configure NFS Client in LXC (only privileged)")" \ + "8" "$(translate "Configure Samba Client in LXC (only privileged)")" \ + "9" "$(translate "Configure NFS Server in LXC (only privileged)")" \ + "10" "$(translate "configure Samba Server in LXC (only privileged)")" \ "" "" \ "h" "$(translate "Help & Info (commands)")" \ "0" "$(translate "Return to Main Menu")" \ @@ -62,25 +63,25 @@ while true; do 3) bash "$LOCAL_SCRIPTS/share/local-shared-manager.sh" ;; - 9) + 4) bash "$LOCAL_SCRIPTS/share/disk_host.sh" ;; - 10) + 5) bash "$LOCAL_SCRIPTS/share/iscsi_host.sh" ;; - 4) + 6) bash "$LOCAL_SCRIPTS/share/lxc-mount-manager_minimal.sh" ;; - 5) + 7) bash "$LOCAL_SCRIPTS/share/nfs_client.sh" ;; - 6) + 8) bash "$LOCAL_SCRIPTS/share/samba_client.sh" ;; - 7) + 9) bash "$LOCAL_SCRIPTS/share/nfs_lxc_server.sh" ;; - 8) + 10) bash "$LOCAL_SCRIPTS/share/samba_lxc_server.sh" ;; h) diff --git a/scripts/menus/storage_menu.sh b/scripts/menus/storage_menu.sh index 39339a27..d9250395 100644 --- a/scripts/menus/storage_menu.sh +++ b/scripts/menus/storage_menu.sh @@ -6,7 +6,7 @@ # Copyright : (c) 2024 MacRimi # License : GPL-3.0 # Version : 2.0 -# Last Updated: 06/04/2026 +# Last Updated: 07/04/2026 # ========================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -30,15 +30,21 @@ initialize_cache while true; do OPTION=$(dialog --colors --backtitle "ProxMenux" \ - --title "$(translate "Disk and Storage Manager Menu")" \ - --menu "\n$(translate "Select an option:")" 24 84 14 \ + --title "$(translate "Disk Manager")" \ + --menu "\n$(translate "Select an option:")" 24 78 16 \ "" "\Z4──────────────────────── VM ───────────────────────────\Zn" \ "1" "$(translate "Import Disk to VM")" \ "2" "$(translate "Import Disk Image to VM")" \ "3" "$(translate "Add Controller or NVMe PCIe to VM")" \ + "" "" \ "" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \ "4" "$(translate "Import Disk to LXC")" \ "" "" \ + "" "\Z4────────────────────── Utilities ───────────────────────\Zn" \ + "5" "$(translate "Format / Wipe Physical Disk (Safe)")" \ + "6" "$(translate "SMART Disk Health & Test")" \ + "7" "$(translate "Manual CLI Guide (Disk and Storage Manager)")" \ + "" "" \ "0" "$(translate "Return to Main Menu")" \ 2>&1 >/dev/tty ) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; } @@ -56,6 +62,15 @@ while true; do 4) bash "$LOCAL_SCRIPTS/storage/disk-passthrough_ct.sh" ;; + 5) + bash "$LOCAL_SCRIPTS/storage/format-disk.sh" + ;; + 6) + bash "$LOCAL_SCRIPTS/storage/smart-disk-test.sh" + ;; + 7) + bash "$LOCAL_SCRIPTS/storage/disk-storage-manual-guide.sh" + ;; 0) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; diff --git a/scripts/menus/utilities_menu.sh b/scripts/menus/utilities_menu.sh index 349b5900..5328bfec 100644 --- a/scripts/menus/utilities_menu.sh +++ b/scripts/menus/utilities_menu.sh @@ -26,12 +26,14 @@ initialize_cache while true; do OPTION=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Utilities Menu")" \ - --menu "$(translate "Select an option:")" 20 70 8 \ + --menu "\n$(translate "Select an option:")" 20 70 11 \ "1" "$(translate "UUp Dump ISO creator Custom")" \ "2" "$(translate "System Utilities Installer")" \ "3" "$(translate "Proxmox System Update")" \ "4" "$(translate "Upgrade PVE 8 to PVE 9")" \ - "5" "$(translate "Return to Main Menu")" \ + "5" "$(translate "Export VM to OVA or OVF")" \ + "6" "$(translate "Import VM from OVA or OVF")" \ + "7" "$(translate "Return to Main Menu")" \ 2>&1 >/dev/tty) case $OPTION in @@ -76,8 +78,20 @@ initialize_cache return fi ;; - 5) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; + 5) + bash "$LOCAL_SCRIPTS/utilities/export_vm_ova_ovf.sh" + if [ $? -ne 0 ]; then + return + fi + ;; + 6) + bash "$LOCAL_SCRIPTS/utilities/import_vm_ova_ovf.sh" + if [ $? -ne 0 ]; then + return + fi + ;; + 7) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; *) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;; esac done - \ No newline at end of file + diff --git a/scripts/share/commands_share.sh b/scripts/share/commands_share.sh index db0300ec..088501b2 100644 --- a/scripts/share/commands_share.sh +++ b/scripts/share/commands_share.sh @@ -6,8 +6,8 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.5 -# Last Updated: 04/08/2025 +# Version : 1.6 +# Last Updated: 07/04/2026 # ========================================================== # Configuration ============================================ @@ -29,11 +29,14 @@ show_command() { local command="$3" local note="$4" local command_extra="$5" - - echo -e "${BGN}${step}.${CL} ${BL}${description}${CL}" + + echo -e " ${DARK_GRAY}────────────────────────────────────────────────${CL}" + echo -e " ${BGN}${step}.${CL} ${description}" + echo "" + while IFS= read -r line; do + echo -e "${TAB}${line}" + done <<< "$(echo -e "$command")" echo "" - echo -e "${TAB}${command}" - echo -e [[ -n "$note" ]] && echo -e "${TAB}${DARK_GRAY}${note}${CL}" [[ -n "$command_extra" ]] && echo -e "${TAB}${YW}${command_extra}${CL}" echo "" @@ -43,10 +46,10 @@ show_how_to_enter_lxc() { clear show_proxmenux_logo msg_title "$(translate "How to Access an LXC Terminal from Proxmox Host")" - + msg_info2 "$(translate "Use these commands on your Proxmox host to access an LXC container's terminal:")" - echo -e - + echo -e + show_command "1" \ "$(translate "Get a list of all your containers:")" \ "pct list" \ @@ -54,93 +57,203 @@ show_how_to_enter_lxc() { "" show_command "2" \ - "$(translate "Enter the container's terminal")" \ + "$(translate "Enter the container terminal:")" \ "pct enter ${CUS}${CL}" \ - "$(translate "Replace with the actual ID.")"\ + "$(translate "Replace with the actual ID.")" \ "$(translate "For example: pct enter 101")" show_command "3" \ - "$(translate "To exit the container's terminal, press:")" \ - "CTRL + D" \ - "" \ + "$(translate "Exit the container terminal:")" \ + "exit" \ + "$(translate "Or press CTRL + D")" \ "" - + echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r } -show_host_mount_resources_help() { +show_host_storage_help() { clear show_proxmenux_logo - msg_title "$(translate "Mount Remote Resources on Proxmox Host")" - - msg_info2 "$(translate "How to mount NFS and Samba shares directly on the Proxmox host. Proxmox already has the necessary tools installed.")" - echo -e + msg_title "$(translate "Host Storage (NFS / Samba via Proxmox)")" - echo -e "${BOLD}${BL}=== MOUNT NFS SHARE ===${CL}" + msg_info2 "$(translate "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.")" + msg_info2 "$(translate "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).")" echo -e - + + echo -e "${BOLD}${BL}=== NFS AS PROXMOX STORAGE ===${CL}" + echo -e + show_command "1" \ - "$(translate "Create mount point:")" \ - "mkdir -p ${CUS}/mnt/nfs_share${CL}" \ - "$(translate "Replace with your preferred path.")" \ - "" + "$(translate "Add NFS storage:")" \ + "pvesm add nfs ${CUS}${CL} --server ${CUS}${CL} --export ${CUS}${CL} --content ${CUS}import,backup,iso,vztmpl,images,snippets${CL}" \ + "$(translate "Use content types according to your use case.")" \ + "$(translate "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup")" show_command "2" \ - "$(translate "Mount NFS share:")" \ - "mount -t nfs ${CUS}192.168.1.100${CL}:${CUS}/path/to/share${CL} ${CUS}/mnt/nfs_share${CL}" \ - "$(translate "Replace IP and paths with your values.")" \ + "$(translate "List configured storages:")" \ + "pvesm status" \ + "$(translate "Shows status and type (nfs/cifs/dir/iscsi...).")" \ "" show_command "3" \ - "$(translate "Make permanent (optional):")" \ - "echo '${CUS}192.168.1.100${CL}:${CUS}/path/to/share${CL} ${CUS}/mnt/nfs_share${CL} nfs4 rw,hard,intr,_netdev,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0' >> /etc/fstab" \ - "$(translate "_netdev waits for network before mounting.")" \ + "$(translate "Remove NFS storage:")" \ + "pvesm remove ${CUS}${CL}" \ + "$(translate "Only removes storage definition, not remote data.")" \ "" - echo -e "${BOLD}${BL}=== MOUNT SAMBA SHARE ===${CL}" + echo -e "${BOLD}${BL}=== SAMBA/CIFS AS PROXMOX STORAGE ===${CL}" echo -e show_command "4" \ - "$(translate "Create mount point:")" \ - "mkdir -p ${CUS}/mnt/samba_share${CL}" \ - "$(translate "Replace with your preferred path.")" \ + "$(translate "Add CIFS storage:")" \ + "pvesm add cifs ${CUS}${CL} --server ${CUS}${CL} --share ${CUS}${CL} --username ${CUS}${CL} --password ${CUS}${CL} --content ${CUS}import,backup,iso,vztmpl,images,snippets${CL}" \ + "$(translate "For guest shares add: --options guest")" \ "" show_command "5" \ - "$(translate "Mount Samba share:")" \ - "mount -t cifs //${CUS}192.168.1.100${CL}/${CUS}sharename${CL} ${CUS}/mnt/samba_share${CL} -o username=${CUS}user${CL}" \ - "$(translate "You will be prompted for password. Replace IP, share and user.")" \ + "$(translate "Inspect storage config block:")" \ + "sed -n '/^${CUS}${CL}:/,/^[^ ]/p' /etc/pve/storage.cfg" \ + "$(translate "Useful to verify options/content after script execution.")" \ "" show_command "6" \ - "$(translate "Make permanent (optional):")" \ - "echo '//${CUS}192.168.1.100${CL}/${CUS}sharename${CL} ${CUS}/mnt/samba_share${CL} cifs username=${CUS}user${CL},password=${CUS}pass${CL},_netdev 0 0' >> /etc/fstab" \ - "$(translate "Replace with your credentials.")" \ + "$(translate "Remove CIFS storage:")" \ + "pvesm remove ${CUS}${CL}" \ + "" \ "" - echo -e "${BOLD}${BL}=== CREATE LOCAL DIRECTORY ===${CL}" + echo -e "" + msg_success "$(translate "Press Enter to return to menu...")" + read -r +} + +show_local_share_help() { + clear + show_proxmenux_logo + msg_title "$(translate "Local Shared Directory on Host")" + + msg_info2 "$(translate "Equivalent manual flow used by Local Shared Manager.")" + msg_info2 "$(translate "No group creation required — uses world-writable sticky bit permissions.")" echo -e - show_command "7" \ - "$(translate "Create directory:")" \ - "mkdir -p ${CUS}/mnt/local_share${CL}" \ - "$(translate "Creates a local directory on Proxmox host.")" \ + show_command "1" \ + "$(translate "Create shared directory:")" \ + "mkdir -p ${CUS}/mnt/shared${CL}" \ + "$(translate "Choose any host path you want to share with CTs.")" \ "" - show_command "8" \ - "$(translate "Set permissions:")" \ - "chmod 755 ${CUS}/mnt/local_share${CL}" \ - "$(translate "Sets basic read/write permissions.")" \ + show_command "2" \ + "$(translate "Set ownership and permissions:")" \ + "chown root:root ${CUS}/mnt/shared${CL}\nchmod 1777 ${CUS}/mnt/shared${CL}" \ + "$(translate "1777 = sticky bit + rwx for all. No shared group needed.")" \ "" - show_command "9" \ - "$(translate "Verify mounts:")" \ - "df -h" \ - "$(translate "Shows all mounted filesystems.")" \ + show_command "3" \ + "$(translate "Optional: apply default ACL so new files inherit permissions:")" \ + "setfacl -R -m d:u::rwx,d:g::rwx,d:o::rwx,m::rwx ${CUS}/mnt/shared${CL}" \ + "$(translate "Requires acl package. Skip if setfacl is not available.")" \ "" - + + show_command "4" \ + "$(translate "Optional: register this path as Proxmox dir storage:")" \ + "pvesm add dir ${CUS}${CL} --path ${CUS}/mnt/shared${CL} --content ${CUS}backup,iso,vztmpl,snippets${CL}" \ + "$(translate "Use images only if the directory is on suitable storage.")" \ + "" + + echo -e "" + msg_success "$(translate "Press Enter to return to menu...")" + read -r +} + +show_disk_host_help() { + clear + show_proxmenux_logo + msg_title "$(translate "Add Local Disk as Proxmox Storage")" + + msg_info2 "$(translate "Equivalent manual flow of disk_host.sh: partition, format, mount, persist, register in Proxmox.")" + echo -e + + show_command "1" \ + "$(translate "Identify candidate disk (never use system disk):")" \ + "lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL" \ + "$(translate "Example target: /dev/sdb")" \ + "" + + show_command "2" \ + "$(translate "Wipe old signatures and partition table (DESTRUCTIVE):")" \ + "wipefs -a ${CUS}/dev/sdb${CL}\nsgdisk --zap-all ${CUS}/dev/sdb${CL}" \ + "$(translate "This erases existing metadata.")" \ + "" + + show_command "3" \ + "$(translate "Create GPT and one partition:")" \ + "parted -s ${CUS}/dev/sdb${CL} mklabel gpt\nparted -s ${CUS}/dev/sdb${CL} mkpart primary 0% 100%" \ + "" \ + "" + + show_command "4" \ + "$(translate "Format partition:")" \ + "mkfs.ext4 -F ${CUS}/dev/sdb1${CL}\n# or\nmkfs.xfs -f ${CUS}/dev/sdb1${CL}" \ + "" \ + "" + + show_command "5" \ + "$(translate "Mount and persist with UUID:")" \ + "mkdir -p ${CUS}/mnt/disk-sdb${CL}\nmount ${CUS}/dev/sdb1${CL} ${CUS}/mnt/disk-sdb${CL}\nblkid ${CUS}/dev/sdb1${CL}\n# Add UUID line to /etc/fstab" \ + "$(translate "Using UUID is recommended over /dev/sdX.")" \ + "" + + show_command "6" \ + "$(translate "Register mount path in Proxmox:")" \ + "pvesm add dir ${CUS}${CL} --path ${CUS}/mnt/disk-sdb${CL} --content ${CUS}images,backup${CL}" \ + "" \ + "" + + echo -e "" + msg_success "$(translate "Press Enter to return to menu...")" + read -r +} + +show_iscsi_host_help() { + clear + show_proxmenux_logo + msg_title "$(translate "Add iSCSI Target as Proxmox Storage")" + + msg_info2 "$(translate "Equivalent manual flow of iscsi_host.sh.")" + echo -e + + show_command "1" \ + "$(translate "Install and start iSCSI initiator:")" \ + "apt-get update && apt-get install -y open-iscsi\nsystemctl enable --now iscsid" \ + "" \ + "" + + show_command "2" \ + "$(translate "Discover targets on portal:")" \ + "iscsiadm -m discovery -t sendtargets -p ${CUS}:3260${CL}" \ + "$(translate "This returns available IQNs.")" \ + "" + + show_command "3" \ + "$(translate "Add iSCSI storage in Proxmox:")" \ + "pvesm add iscsi ${CUS}${CL} --portal ${CUS}:3260${CL} --target ${CUS}${CL} --content images" \ + "$(translate "Content is usually images for VM block devices.")" \ + "" + + show_command "4" \ + "$(translate "Verify iSCSI sessions and storage status:")" \ + "iscsiadm -m session\npvesm status" \ + "" \ + "" + + show_command "5" \ + "$(translate "Remove iSCSI storage definition:")" \ + "pvesm remove ${CUS}${CL}" \ + "" \ + "" + echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r @@ -149,42 +262,42 @@ show_host_mount_resources_help() { show_host_to_lxc_mount_help() { clear show_proxmenux_logo - msg_title "$(translate "Mount Host Directory to LXC Container")" - - msg_info2 "$(translate "How to mount a Proxmox host directory into an LXC container. Execute these commands on the Proxmox host.")" - echo -e - + msg_title "$(translate "Host Directory to LXC Mount Point")" + + msg_info2 "$(translate "Current script uses native bind mounts with pct set -mpX.")" + msg_info2 "$(translate "Safe design: no automatic ACL/ownership mutation on host or CT.")" + echo -e + show_command "1" \ - "$(translate "Add mount point to container:")" \ - "pct set ${CUS}${CL} -mp0 ${CUS}/host/directory${CL},mp=${CUS}/container/path${CL},backup=0,shared=1" \ - "$(translate "Replace container-id, host directory and container path.")" \ - "$(translate "Example: pct set 101 -mp0 /mnt/shared,mp=/mnt/shared,,backup=0,shared=1")" + "$(translate "List containers:")" \ + "pct list" \ + "" \ + "" show_command "2" \ - "$(translate "Restart container:")" \ - "pct reboot ${CUS}${CL}" \ - "$(translate "Required to activate the mount point.")" \ + "$(translate "Add bind mount to container:")" \ + "pct set ${CUS}${CL} -mp0 ${CUS}/host/path${CL},mp=${CUS}/container/path${CL},backup=0,shared=1" \ + "$(translate "Use mp1/mp2/... for extra mount points.")" \ "" show_command "3" \ - "$(translate "Verify mount inside container:")" \ - "pct enter ${CUS}${CL} - df -h | grep ${CUS}/container/path${CL}" \ - "$(translate "Check if the directory is mounted.")" \ + "$(translate "Check resulting config:")" \ + "pct config ${CUS}${CL} | grep '^mp'" \ + "" \ "" show_command "4" \ - "$(translate "Remove mount point (if needed):")" \ - "pct set ${CUS}${CL} --delete mp0" \ - "$(translate "Removes the mount point. Use mp1, mp2, etc. for other mounts.")" \ + "$(translate "Remove mount point:")" \ + "pct set ${CUS}${CL} --delete mp0" \ + "" \ "" - - echo -e "${BOR}" - echo -e "${BOLD}$(translate "Notes:")${CL}" - echo -e "${TAB}${BGN}$(translate "Mount indices:")${CL} ${BL}Use mp0, mp1, mp2, etc. for multiple mounts${CL}" - echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}May need adjustment depending on directory type${CL}" - echo -e "${TAB}${BGN}$(translate "Container types:")${CL} ${BL}Works with both privileged and unprivileged containers${CL}" - + + show_command "5" \ + "$(translate "Verify inside container:")" \ + "pct enter ${CUS}${CL}\ndf -h" \ + "$(translate "Confirm the mount path is visible.")" \ + "" + echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r @@ -193,67 +306,41 @@ show_host_to_lxc_mount_help() { show_nfs_server_help() { clear show_proxmenux_logo - msg_title "$(translate "NFS Server Installation")" - - msg_info2 "$(translate "How to install and configure an NFS server in an LXC container.")" - echo -e - + msg_title "$(translate "NFS Server in LXC (Privileged)")" + + msg_warn "$(translate "Use a privileged LXC for NFS server/client workflows.")" + echo -e + show_command "1" \ - "$(translate "Update and install packages:")" \ - "apt-get update && apt-get install -y nfs-kernel-server" \ + "$(translate "Install server packages inside CT:")" \ + "apt-get update && apt-get install -y nfs-kernel-server nfs-common rpcbind" \ "" \ "" show_command "2" \ "$(translate "Create export directory:")" \ - "mkdir -p ${CUS}/mnt/nfs_export${CL}" \ - "$(translate "Replace with your preferred path.")" \ + "mkdir -p ${CUS}/mnt/nfs_export${CL}\nchmod 755 ${CUS}/mnt/nfs_export${CL}" \ + "" \ "" - + show_command "3" \ - "$(translate "Set directory permissions:")" \ - "chmod 755 ${CUS}/mnt/nfs_export${CL}" \ - "" \ - "" - - show_command "4.1" \ - "$(translate "Configure exports (safe root_squash):")" \ + "$(translate "Add export rule:")" \ "echo '${CUS}/mnt/nfs_export${CL} ${CUS}192.168.1.0/24${CL}(rw,sync,no_subtree_check,root_squash)' >> /etc/exports" \ - "$(translate "Replace directory path and network range.")" \ + "$(translate "Adjust network/CIDR to your environment.")" \ "" - show_command "4.2" \ - "$(translate "Or Configure exports (map all users):")" \ - "echo '${CUS}/mnt/nfs_export${CL} ${CUS}192.168.1.0/24${CL}(rw,sync,no_subtree_check,all_squash,anonuid=0,anongid=0)' >> /etc/exports" \ - "$(translate "Replace directory path and network range.")" \ + show_command "4" \ + "$(translate "Apply and restart services:")" \ + "exportfs -ra\nsystemctl restart rpcbind nfs-kernel-server\nsystemctl enable rpcbind nfs-kernel-server" \ + "" \ "" - show_command "5" \ - "$(translate "Apply configuration:")" \ - "exportfs -ra" \ - "" \ - "" - - show_command "6" \ - "$(translate "Start and enable service:")" \ - "systemctl restart nfs-kernel-server - systemctl enable nfs-kernel-server" \ - "" \ - "" - - show_command "7" \ - "$(translate "Verify exports:")" \ + "$(translate "Verify active exports:")" \ "showmount -e localhost" \ - "$(translate "Shows available NFS exports.")" \ + "" \ "" - - echo -e "${BOR}" - echo -e "${BOLD}$(translate "Export Options:")${CL}" - echo -e "${TAB}${BGN}$(translate "rw:")${CL} ${BL}Read-write access${CL}" - echo -e "${TAB}${BGN}$(translate "sync:")${CL} ${BL}Synchronous writes${CL}" - echo -e "${TAB}${BGN}$(translate "no_subtree_check:")${CL} ${BL}Improves performance${CL}" - + echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r @@ -262,67 +349,47 @@ show_nfs_server_help() { show_samba_server_help() { clear show_proxmenux_logo - msg_title "$(translate "Samba Server Installation")" - - msg_info2 "$(translate "How to install and configure a Samba server in an LXC container.")" + msg_title "$(translate "Samba Server in LXC (Privileged)")" + + msg_warn "$(translate "Use a privileged LXC for Samba client/server workflows.")" echo -e - + show_command "1" \ - "$(translate "Update and install packages:")" \ - "apt-get update && apt-get install -y samba" \ + "$(translate "Install Samba inside CT:")" \ + "apt-get update && apt-get install -y samba samba-common-bin acl" \ "" \ "" - + show_command "2" \ "$(translate "Create share directory:")" \ - "mkdir -p ${CUS}/mnt/samba_share${CL}" \ - "$(translate "Replace with your preferred path.")" \ + "mkdir -p ${CUS}/mnt/samba_share${CL}\nchmod 755 ${CUS}/mnt/samba_share${CL}" \ + "" \ "" - + show_command "3" \ - "$(translate "Set directory permissions:")" \ - "chmod 755 ${CUS}/mnt/samba_share${CL}" \ - "" \ - "" - - show_command "4" \ "$(translate "Create Samba user:")" \ - "adduser ${CUS}sambauser${CL} - smbpasswd -a ${CUS}sambauser${CL}" \ - "$(translate "Replace with your username. You'll be prompted for password.")" \ - "" - - show_command "5" \ - "$(translate "Configure share:")" \ - "cat >> /etc/samba/smb.conf << EOF -[shared] - comment = Shared folder - path = ${CUS}/mnt/samba_share${CL} - read only = no - browseable = yes - valid users = ${CUS}sambauser${CL} -EOF" \ - "$(translate "Replace path and username.")" \ - "" - - show_command "6" \ - "$(translate "Restart and enable service:")" \ - "systemctl restart smbd - systemctl enable smbd" \ + "adduser ${CUS}sambauser${CL}\nsmbpasswd -a ${CUS}sambauser${CL}" \ "" \ "" - - show_command "7" \ - "$(translate "Test configuration:")" \ - "smbclient -L localhost -U ${CUS}sambauser${CL}" \ - "$(translate "Lists available shares. You'll be prompted for password.")" \ + + show_command "4" \ + "$(translate "Add share block in /etc/samba/smb.conf:")" \ + "cat >> /etc/samba/smb.conf << 'EOF'\n[shared]\n path = /mnt/samba_share\n browseable = yes\n read only = no\n valid users = sambauser\nEOF" \ + "" \ "" - - echo -e "${BOR}" - echo -e "${BOLD}$(translate "Connection Examples:")${CL}" - echo -e "${TAB}${BGN}$(translate "Windows:")${CL} ${YW}\\\\\\shared${CL}" - echo -e "${TAB}${BGN}$(translate "Linux:")${CL} ${YW}smbclient //server-ip/shared -U sambauser${CL}" - + + show_command "5" \ + "$(translate "Restart and enable Samba:")" \ + "systemctl restart smbd\nsystemctl enable smbd" \ + "" \ + "" + + show_command "6" \ + "$(translate "Test share visibility:")" \ + "smbclient -L localhost -U ${CUS}sambauser${CL}" \ + "" \ + "" + echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r @@ -331,47 +398,41 @@ EOF" \ show_nfs_client_help() { clear show_proxmenux_logo - msg_title "$(translate "NFS Client Configuration")" - - msg_info2 "$(translate "How to configure an NFS client in an LXC container.")" + msg_title "$(translate "NFS Client in LXC (Privileged)")" + + msg_warn "$(translate "Current NFS client script supports privileged LXC only.")" echo -e - + show_command "1" \ - "$(translate "Update and install packages:")" \ + "$(translate "Install NFS client packages inside CT:")" \ "apt-get update && apt-get install -y nfs-common" \ "" \ "" show_command "2" \ "$(translate "Create mount point:")" \ - "mkdir -p ${CUS}/mnt/nfsmount${CL}" \ - "$(translate "Replace with your preferred path.")" \ + "mkdir -p ${CUS}/mnt/nfs_share${CL}" \ + "" \ "" show_command "3" \ "$(translate "Mount NFS share:")" \ - "mount -t nfs ${CUS}192.168.1.100${CL}:${CUS}/mnt/nfs_export${CL} ${CUS}/mnt/nfsmount${CL}" \ - "$(translate "Replace server IP and paths.")" \ + "mount -t nfs ${CUS}:/export/path${CL} ${CUS}/mnt/nfs_share${CL}" \ + "$(translate "Adjust options if needed (vers=4,hard,timeo,...).")" \ "" show_command "4" \ - "$(translate "Test access:")" \ - "ls -la ${CUS}/mnt/nfsmount${CL}" \ - "$(translate "Verify you can access the mounted share.")" \ + "$(translate "Persist mount in CT /etc/fstab (optional):")" \ + "echo '${CUS}:/export/path${CL} ${CUS}/mnt/nfs_share${CL} nfs defaults,_netdev,x-systemd.automount,noauto 0 0' >> /etc/fstab" \ + "" \ "" show_command "5" \ - "$(translate "Make permanent (optional):")" \ - "echo '${CUS}192.168.1.100${CL}:${CUS}/path/to/share${CL} ${CUS}/mnt/nfs_share${CL} nfs4 rw,hard,intr,_netdev,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0' >> /etc/fstab" \ - "$(translate "Replace with your server IP and paths.")" \ - "" - - show_command "6" \ "$(translate "Verify mount:")" \ - "df -h | grep nfs" \ - "$(translate "Shows NFS mounts.")" \ + "mount | grep nfs\ndf -h | grep nfs" \ + "" \ "" - + echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r @@ -380,63 +441,47 @@ show_nfs_client_help() { show_samba_client_help() { clear show_proxmenux_logo - msg_title "$(translate "Samba Client Configuration")" - - msg_info2 "$(translate "How to configure a Samba client in an LXC container.")" + msg_title "$(translate "Samba Client in LXC (Privileged)")" + + msg_warn "$(translate "Current Samba client script supports privileged LXC only.")" echo -e - + show_command "1" \ - "$(translate "Update and install packages:")" \ + "$(translate "Install CIFS client packages inside CT:")" \ "apt-get update && apt-get install -y cifs-utils" \ "" \ "" show_command "2" \ "$(translate "Create mount point:")" \ - "mkdir -p ${CUS}/mnt/sambamount${CL}" \ - "$(translate "Replace with your preferred path.")" \ + "mkdir -p ${CUS}/mnt/samba_share${CL}" \ + "" \ "" show_command "3" \ - "$(translate "Mount Samba share:")" \ - "mount -t cifs //${CUS}192.168.1.100${CL}/${CUS}shared${CL} ${CUS}/mnt/sambamount${CL} -o username=${CUS}sambauser${CL}" \ - "$(translate "Replace server IP, share name and username. You'll be prompted for password.")" \ + "$(translate "Create credentials file (recommended):")" \ + "cat > /etc/samba/credentials/proxmenux.cred << 'EOF'\nusername=${CUS}${CL}\npassword=${CUS}${CL}\nEOF\nchmod 600 /etc/samba/credentials/proxmenux.cred" \ + "" \ "" show_command "4" \ - "$(translate "Test access:")" \ - "ls -la ${CUS}/mnt/sambamount${CL}" \ - "$(translate "Verify you can access the mounted share.")" \ + "$(translate "Mount CIFS share:")" \ + "mount -t cifs //${CUS}/${CL} ${CUS}/mnt/samba_share${CL} -o credentials=/etc/samba/credentials/proxmenux.cred,iocharset=utf8,file_mode=0664,dir_mode=0775" \ + "" \ "" show_command "5" \ - "$(translate "Create credentials file (optional):")" \ - "cat > /etc/samba/credentials << EOF -username=${CUS}sambauser${CL} -password=${CUS}your_password${CL} -EOF -chmod 600 /etc/samba/credentials" \ - "$(translate "Secure way to store credentials.")" \ + "$(translate "Persist mount in CT /etc/fstab (optional):")" \ + "echo '//${CUS}/${CL} ${CUS}/mnt/samba_share${CL} cifs credentials=/etc/samba/credentials/proxmenux.cred,_netdev,x-systemd.automount,noauto 0 0' >> /etc/fstab" \ + "" \ "" show_command "6" \ - "$(translate "Mount with credentials file:")" \ - "mount -t cifs //${CUS}192.168.1.100${CL}/${CUS}shared${CL} ${CUS}/mnt/sambamount${CL} -o credentials=/etc/samba/credentials" \ - "$(translate "No password prompt needed.")" \ + "$(translate "Verify mount:")" \ + "mount -t cifs\ndf -h | grep cifs" \ + "" \ "" - show_command "7" \ - "$(translate "Make permanent (optional):")" \ - "echo '//${CUS}192.168.1.100${CL}/${CUS}shared${CL} ${CUS}/mnt/sambamount${CL} cifs credentials=/etc/samba/credentials,_netdev 0 0' >> /etc/fstab" \ - "$(translate "Replace with your values.")" \ - "" - - show_command "8" \ - "$(translate "Verify mount:")" \ - "df -h | grep cifs" \ - "$(translate "Shows CIFS/Samba mounts.")" \ - "" - echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r @@ -445,28 +490,35 @@ chmod 600 /etc/samba/credentials" \ show_help_menu() { while true; do CHOICE=$(dialog --title "$(translate "Help & Information")" \ - --menu "$(translate "Select help topic:")" 24 80 14 \ + --menu "$(translate "Select help topic:")" 24 90 14 \ "0" "$(translate "How to Access an LXC Terminal")" \ - "1" "$(translate "Mount Remote Resources on Proxmox Host")" \ - "2" "$(translate "Mount Host Directory to LXC Container")" \ - "3" "$(translate "NFS Server Installation")" \ - "4" "$(translate "Samba Server Installation")" \ - "5" "$(translate "NFS Client Configuration")" \ - "6" "$(translate "Samba Client Configuration")" \ - "7" "$(translate "Return to Main Menu")" \ + "1" "$(translate "Host NFS/Samba as Proxmox Storage (pvesm)")" \ + "2" "$(translate "Local Shared Directory on Host")" \ + "3" "$(translate "Add Local Disk as Proxmox Storage")" \ + "4" "$(translate "Add iSCSI Target as Proxmox Storage")" \ + "5" "$(translate "Mount Host Directory to LXC Container")" \ + "6" "$(translate "NFS Client in LXC (privileged)")" \ + "7" "$(translate "Samba Client in LXC (privileged)")" \ + "8" "$(translate "NFS Server in LXC (privileged)")" \ + "9" "$(translate "Samba Server in LXC (privileged)")" \ + "10" "$(translate "Return to Share Menu")" \ 3>&1 1>&2 2>&3) - - case $CHOICE in + + case "$CHOICE" in 0) show_how_to_enter_lxc ;; - 1) show_host_mount_resources_help ;; - 2) show_host_to_lxc_mount_help ;; - 3) show_nfs_server_help ;; - 4) show_samba_server_help ;; - 5) show_nfs_client_help ;; - 6) show_samba_client_help ;; - 7) return ;; + 1) show_host_storage_help ;; + 2) show_local_share_help ;; + 3) show_disk_host_help ;; + 4) show_iscsi_host_help ;; + 5) show_host_to_lxc_mount_help ;; + 6) show_nfs_client_help ;; + 7) show_samba_client_help ;; + 8) show_nfs_server_help ;; + 9) show_samba_server_help ;; + 10) return ;; *) return ;; esac done } + show_help_menu diff --git a/scripts/share/disk_host.sh b/scripts/share/disk_host.sh index eecbd548..38e3afc1 100644 --- a/scripts/share/disk_host.sh +++ b/scripts/share/disk_host.sh @@ -8,18 +8,40 @@ # ========================================================== # Description: # Adds local SCSI/SATA/NVMe disks as Proxmox directory storage -# (pvesm add dir). The disk is formatted (ext4 or xfs), mounted -# permanently, and registered in Proxmox. +# (pvesm add dir) or ZFS pool storage (pvesm add zfspool). +# The disk can be formatted (ext4/xfs/zfs) and registered in Proxmox. # ========================================================== -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" + +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" +fi + +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/disk_ops_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/disk_ops_helpers.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" +fi + load_language initialize_cache @@ -29,6 +51,51 @@ if ! command -v pveversion >/dev/null 2>&1; then exit 1 fi +# ========================================================== +# SYSTEM STORAGE DETECTION +# ========================================================== + +# Returns the name of the ZFS pool containing the root filesystem, if any. +_get_system_zfs_pool() { + local root_fs + root_fs=$(df / 2>/dev/null | awk 'NR==2 {print $1}') + if [[ "$root_fs" != /dev/* && "$root_fs" == */* ]]; then + echo "${root_fs%%/*}" + fi +} + +# Returns 0 if the given pvesm storage is a user-created disk storage +# that should appear in add/remove menus. Returns 1 for system storages. +_is_user_disk_storage() { + local storage_id="$1" + local storage_type="$2" + local sys_pool + + local cfg_path pool + cfg_path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}') + pool=$(get_storage_config "$storage_id" | awk '$1 == "pool" {print $2}') + + case "$storage_type" in + dir) + # User-created dir storages are always mounted under /mnt/ + [[ "$cfg_path" == /mnt/* ]] && return 0 + return 1 + ;; + zfspool) + # User-created ZFS pool storages are NOT on the root pool or its datasets + sys_pool=$(_get_system_zfs_pool) + if [[ -n "$sys_pool" ]]; then + # Skip if pool is the root pool or a dataset within it (e.g. rpool/data) + [[ "$pool" == "$sys_pool" || "$pool" == "$sys_pool/"* ]] && return 1 + fi + return 0 + ;; + *) + return 1 + ;; + esac +} + # ========================================================== # STORAGE CONFIG READER # ========================================================== @@ -45,53 +112,123 @@ get_storage_config() { # DISK DETECTION # ========================================================== +disk_referenced_in_guest_configs() { + local disk="$1" + if declare -F _disk_used_in_guest_configs >/dev/null 2>&1; then + _disk_used_in_guest_configs "$disk" + return $? + fi + + local real_path config_data link + real_path=$(readlink -f "$disk" 2>/dev/null) + config_data=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) + [[ -z "$config_data" ]] && return 1 + + if [[ -n "$real_path" ]] && grep -Fq "$real_path" <<< "$config_data"; then + return 0 + fi + for link in /dev/disk/by-id/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + if grep -Fq "$link" <<< "$config_data"; then + return 0 + fi + done + return 1 +} + +disk_used_by_host_storage() { + local disk="$1" + if declare -F _disk_is_host_system_used >/dev/null 2>&1; then + _disk_is_host_system_used "$disk" + return $? + fi + + local mounted_disks swap_disks lvm_devices zfs_disks real_path path base_disk + local part fstype part_path + + mounted_disks=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') + swap_disks=$(swapon --noheadings --raw --show=NAME 2>/dev/null) + lvm_devices=$(pvs --noheadings -o pv_name 2>/dev/null | xargs -r -n1 readlink -f | sort -u) + zfs_disks="" + + while read -r part fstype; do + [[ -z "$part" ]] && continue + part_path="/dev/$part" + if grep -qFx "$part_path" <<< "$mounted_disks"; then + return 0 + fi + if grep -qFx "$part_path" <<< "$swap_disks"; then + return 0 + fi + case "$fstype" in + zfs_member|linux_raid_member|LVM2_member) + return 0 + ;; + esac + done < <(lsblk -ln -o NAME,FSTYPE "$disk" 2>/dev/null) + + while read -r entry; do + [[ -z "$entry" ]] && continue + path="" + if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then + [[ -e "/dev/disk/by-id/$entry" ]] && path=$(readlink -f "/dev/disk/by-id/$entry") + elif [[ "$entry" == /dev/* ]]; then + path="$entry" + fi + if [[ -n "$path" ]]; then + base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) + [[ -n "$base_disk" ]] && zfs_disks+="/dev/$base_disk"$'\n' + fi + done < <(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') + + real_path=$(readlink -f "$disk" 2>/dev/null) + if [[ -n "$real_path" && -n "$lvm_devices" ]] && grep -qFx "$real_path" <<< "$lvm_devices"; then + return 0 + fi + if [[ -n "$zfs_disks" ]] && grep -qFx "$disk" <<< "$(echo "$zfs_disks" | sort -u)"; then + return 0 + fi + return 1 +} + +get_disk_info() { + local disk="$1" + local model size + model=$(lsblk -dn -o MODEL "$disk" 2>/dev/null | xargs) + size=$(lsblk -dn -o SIZE "$disk" 2>/dev/null | xargs) + [[ -z "$model" ]] && model="$(translate "Unknown model")" + [[ -z "$size" ]] && size="$(translate "Unknown size")" + printf '%s\t%s\n' "$model" "$size" +} + get_available_disks() { - # List block devices that are: - # - Whole disks (not partitions, not loop, not dm) - # - Not the system disk (where / is mounted) - local system_disk - system_disk=$(lsblk -ndo PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null | head -1) + if declare -F _refresh_host_storage_cache >/dev/null 2>&1; then + _refresh_host_storage_cache + fi - while IFS= read -r line; do - local name size type model ro - name=$(echo "$line" | awk '{print $1}') - size=$(echo "$line" | awk '{print $2}') - type=$(echo "$line" | awk '{print $3}') - model=$(echo "$line" | awk '{for(i=4;i<=NF;i++) printf "%s ", $i; print ""}' | sed 's/[[:space:]]*$//') - ro=$(echo "$line" | awk '{print $NF}') - - # Only whole disks + while read -r disk ro type; do + [[ -z "$disk" ]] && continue [[ "$type" != "disk" ]] && continue - # Skip read-only [[ "$ro" == "1" ]] && continue - # Skip system disk - [[ "$name" == "$system_disk" ]] && continue + [[ "$disk" =~ ^/dev/zd ]] && continue - # Check if fully mounted (any partition or the disk itself is mounted at /) - local is_mounted=false - if lsblk -no MOUNTPOINT "/dev/$name" 2>/dev/null | grep -qE "^/[[:space:]]*$|^/boot"; then - is_mounted=true + if disk_used_by_host_storage "$disk"; then + continue fi - [[ "$is_mounted" == true ]] && continue - - local info="${size}" - [[ -n "$model" && "$model" != " " ]] && info="${size} — ${model}" - - # Show mount status - local mount_info - mount_info=$(lsblk -no MOUNTPOINT "/dev/$name" 2>/dev/null | grep -v "^$" | tr '\n' ' ' | sed 's/[[:space:]]*$//') - if [[ -n "$mount_info" ]]; then - info="${info} [${mount_info}]" + if disk_referenced_in_guest_configs "$disk"; then + continue fi - echo "/dev/$name|$info" - done < <(lsblk -ndo NAME,SIZE,TYPE,MODEL,RO 2>/dev/null) + local model size + IFS=$'\t' read -r model size < <(get_disk_info "$disk") + [[ -z "$model" || "$model" == " " ]] && model="-" + + echo "$disk|$size — $model" + done < <(lsblk -dn -e 7,11 -o PATH,RO,TYPE 2>/dev/null) } select_disk() { - show_proxmenux_logo - msg_title "$(translate "Add Local Disk as Proxmox Storage")" - msg_info "$(translate "Scanning available disks...")" local disk_list disk_list=$(get_available_disks) @@ -115,7 +252,7 @@ select_disk() { SELECTED_DISK=$(dialog --backtitle "ProxMenux" --title "$(translate "Select Disk")" \ --menu "\n$(translate "Select the disk to add as Proxmox storage:")\n$(translate "WARNING: All data on selected disk will be ERASED if formatted.")" \ - 20 80 10 "${options[@]}" 3>&1 1>&2 2>&3) + 20 84 10 "${options[@]}" 3>&1 1>&2 2>&3) [[ -z "$SELECTED_DISK" ]] && return 1 return 0 @@ -128,15 +265,27 @@ inspect_disk() { local partition_info partition_info=$(lsblk -no NAME,SIZE,FSTYPE,MOUNTPOINT "$disk" 2>/dev/null | tail -n +2) - local existing_fs + local existing_fs existing_node existing_fs=$(blkid -s TYPE -o value "$disk" 2>/dev/null || true) + existing_node="$disk" + if [[ -z "$existing_fs" ]]; then + while read -r node fstype mountpoint; do + [[ -z "$node" || -z "$fstype" ]] && continue + [[ -n "$mountpoint" ]] && continue + existing_fs="$fstype" + existing_node="$node" + break + done < <(lsblk -lnpo NAME,FSTYPE,MOUNTPOINT "$disk" 2>/dev/null | awk 'NR>1 {print $1, $2, $3}') + fi DISK_HAS_DATA=false DISK_EXISTING_FS="" + DISK_EXISTING_NODE="" if [[ -n "$partition_info" || -n "$existing_fs" ]]; then DISK_HAS_DATA=true DISK_EXISTING_FS="$existing_fs" + DISK_EXISTING_NODE="$existing_node" fi return 0 @@ -149,38 +298,38 @@ select_partition_action() { local disk_size disk_size=$(lsblk -ndo SIZE "$disk" 2>/dev/null) - if [[ "$DISK_HAS_DATA" == "true" ]]; then - local msg="$(translate "Disk:"): $disk ($disk_size)\n" - [[ -n "$DISK_EXISTING_FS" ]] && msg+="$(translate "Existing filesystem:"): $DISK_EXISTING_FS\n" - msg+="\n$(translate "Options:")\n" - msg+="• $(translate "Format: ERASE all data and create new filesystem")\n" - [[ -n "$DISK_EXISTING_FS" ]] && msg+="• $(translate "Use existing: mount without formatting")\n" - msg+="\n$(translate "Continue?")" + local menu_items=() + menu_items+=("format" "$(translate "Format disk (ERASE all data)")") + [[ -n "$DISK_EXISTING_FS" ]] && menu_items+=("use_existing" "$(translate "Use existing filesystem")") + menu_items+=("cancel" "$(translate "Cancel")") - DISK_ACTION=$(whiptail --title "$(translate "Disk Setup")" \ - --menu "$msg" 20 80 3 \ - "format" "$(translate "Format disk (ERASE all data)")" \ - $(if [[ -n "$DISK_EXISTING_FS" ]]; then echo '"use_existing" "'"$(translate "Use existing filesystem")"'"'; fi) \ - "cancel" "$(translate "Cancel")" \ - 3>&1 1>&2 2>&3) + local menu_text + if [[ "$DISK_HAS_DATA" == "true" ]]; then + menu_text="$(translate "Disk:"): $disk ($disk_size)\n" + [[ -n "$DISK_EXISTING_FS" ]] && menu_text+="$(translate "Existing filesystem:"): $DISK_EXISTING_FS\n" + menu_text+="\n$(translate "Options:")\n" + menu_text+="• $(translate "Format: ERASE all data and create new filesystem")\n" + [[ -n "$DISK_EXISTING_FS" ]] && menu_text+="• $(translate "Use existing: mount without formatting")\n" + menu_text+="\n$(translate "Continue?")" else - DISK_ACTION=$(whiptail --title "$(translate "Disk Setup")" \ - --menu "$(translate "Disk:"): $disk ($disk_size)\n\n$(translate "Disk appears empty. It will be formatted.")" \ - 14 70 2 \ - "format" "$(translate "Format and add as Proxmox storage")" \ - "cancel" "$(translate "Cancel")" \ - 3>&1 1>&2 2>&3) + menu_text="$(translate "Disk:"): $disk ($disk_size)\n\n$(translate "Disk appears empty. It will be formatted.")" fi + DISK_ACTION=$(dialog --backtitle "ProxMenux" --title "$(translate "Disk Setup")" \ + --menu "$menu_text" 20 84 8 \ + "${menu_items[@]}" 3>&1 1>&2 2>&3) + [[ -z "$DISK_ACTION" || "$DISK_ACTION" == "cancel" ]] && return 1 return 0 } select_filesystem() { - FILESYSTEM=$(whiptail --title "$(translate "Select Filesystem")" \ - --menu "$(translate "Choose filesystem for the disk:")" 14 60 3 \ - "ext4" "$(translate "ext4 — recommended, most compatible")" \ - "xfs" "$(translate "xfs — better for large files and VMs")" \ + FILESYSTEM=$(dialog --backtitle "ProxMenux" --title "$(translate "Select Filesystem")" \ + --menu "\n$(translate "Choose filesystem for the disk:")" 16 72 5 \ + "ext4" "$(translate "ext4 — Proxmox dir storage (recommended)")" \ + "xfs" "$(translate "xfs — Proxmox dir storage (large files and VMs)")" \ + "btrfs" "$(translate "btrfs — Proxmox dir storage (snapshots, compression)")" \ + "zfs" "$(translate "zfs — Proxmox ZFS pool storage")" \ 3>&1 1>&2 2>&3) [[ -z "$FILESYSTEM" ]] && return 1 return 0 @@ -194,24 +343,30 @@ configure_disk_storage() { local disk_name disk_name=$(basename "$SELECTED_DISK") - STORAGE_ID=$(whiptail --inputbox "$(translate "Enter storage ID for Proxmox:")" \ - 10 60 "disk-${disk_name}" \ - --title "$(translate "Storage ID")" 3>&1 1>&2 2>&3) + STORAGE_ID=$(dialog --backtitle "ProxMenux" --title "$(translate "Storage ID")" \ + --inputbox "$(translate "Enter storage ID for Proxmox:")" \ + 10 60 "disk-${disk_name}" 3>&1 1>&2 2>&3) [[ $? -ne 0 ]] && return 1 [[ -z "$STORAGE_ID" ]] && STORAGE_ID="disk-${disk_name}" if [[ ! "$STORAGE_ID" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then - whiptail --msgbox "$(translate "Invalid storage ID. Use only letters, numbers, hyphens and underscores.")" 8 70 + dialog --backtitle "ProxMenux" --title "$(translate "Invalid ID")" \ + --msgbox "$(translate "Invalid storage ID. Use only letters, numbers, hyphens and underscores.")" 8 74 + return 1 + fi + if [[ "${FILESYSTEM:-}" == "zfs" && ! "$STORAGE_ID" =~ ^[a-zA-Z][a-zA-Z0-9_.:-]*$ ]]; then + dialog --backtitle "ProxMenux" --title "$(translate "Invalid ID")" \ + --msgbox "$(translate "For ZFS, storage ID must start with a letter and use only letters, numbers, dot, dash, underscore or colon.")" 9 86 return 1 fi MOUNT_PATH="/mnt/${STORAGE_ID}" - MOUNT_PATH=$(whiptail --inputbox "$(translate "Enter mount path on host:")" \ - 10 60 "$MOUNT_PATH" \ - --title "$(translate "Mount Path")" 3>&1 1>&2 2>&3) + MOUNT_PATH=$(dialog --backtitle "ProxMenux" --title "$(translate "Mount Path")" \ + --inputbox "$(translate "Enter mount path on host:")" \ + 10 60 "$MOUNT_PATH" 3>&1 1>&2 2>&3) [[ $? -ne 0 || -z "$MOUNT_PATH" ]] && return 1 - CONTENT_TYPE=$(whiptail --title "$(translate "Content Types")" \ + CONTENT_TYPE=$(dialog --backtitle "ProxMenux" --title "$(translate "Content Types")" \ --menu "$(translate "Select content types for this storage:")" 16 70 5 \ "1" "$(translate "VM Storage (images, backup)")" \ "2" "$(translate "Standard NAS (backup, iso, vztmpl)")" \ @@ -225,8 +380,9 @@ configure_disk_storage() { 2) MOUNT_CONTENT="backup,iso,vztmpl" ;; 3) MOUNT_CONTENT="images,backup,iso,vztmpl,snippets" ;; 4) - MOUNT_CONTENT=$(whiptail --inputbox "$(translate "Enter content types (comma-separated):")" \ - 10 70 "images,backup" --title "$(translate "Custom Content")" 3>&1 1>&2 2>&3) + MOUNT_CONTENT=$(dialog --backtitle "ProxMenux" --title "$(translate "Custom Content")" \ + --inputbox "$(translate "Enter content types (comma-separated):")" \ + 10 70 "images,backup" 3>&1 1>&2 2>&3) [[ $? -ne 0 || -z "$MOUNT_CONTENT" ]] && MOUNT_CONTENT="images,backup" ;; *) return 1 ;; @@ -247,50 +403,53 @@ format_and_mount_disk() { # Final confirmation before any destructive operation local disk_size disk_size=$(lsblk -ndo SIZE "$disk" 2>/dev/null) - if ! whiptail --yesno \ + if ! dialog --backtitle "ProxMenux" --title "$(translate "CONFIRM FORMAT")" --yesno \ "$(translate "FINAL CONFIRMATION — DATA WILL BE ERASED")\n\n$(translate "Disk:"): $disk ($disk_size)\n$(translate "Filesystem:"): $filesystem\n$(translate "Mount path:"): $mount_path\n\n$(translate "ALL DATA ON") $disk $(translate "WILL BE PERMANENTLY ERASED.")\n\n$(translate "Are you absolutely sure?")" \ - 14 80 --title "$(translate "CONFIRM FORMAT")"; then + 14 80; then return 1 fi - + show_proxmenux_logo + msg_title "$(translate "Add Local Disk as Proxmox Storage")" + local _disk_model _disk_size _disk_label + IFS=$'\t' read -r _disk_model _disk_size < <(get_disk_info "$disk") + _disk_label="$disk" + [[ -n "$_disk_size" && -n "$_disk_model" ]] && _disk_label="$disk — $_disk_size — $_disk_model" + msg_ok "$(translate "Disk:") ${BL}${_disk_label}${CL}" + msg_ok "$(translate "Action:") $DISK_ACTION" + [[ "$DISK_ACTION" == "format" ]] && msg_ok "$(translate "Filesystem:") $FILESYSTEM" + msg_ok "$(translate "Mount path:") $MOUNT_PATH" + msg_ok "$(translate "Storage ID:") $STORAGE_ID" + msg_ok "$(translate "Content:") $MOUNT_CONTENT" msg_info "$(translate "Wiping existing partition table...")" - wipefs -a "$disk" >/dev/null 2>&1 || true - sgdisk --zap-all "$disk" >/dev/null 2>&1 || true - + doh_wipe_disk "$disk" + msg_ok "$(translate "Partition table wiped")" msg_info "$(translate "Creating partition...")" - if ! parted -s "$disk" mklabel gpt mkpart primary 0% 100% >/dev/null 2>&1; then + if ! doh_create_partition "$disk"; then msg_error "$(translate "Failed to create partition table")" + [[ -n "$DOH_PARTITION_ERROR_DETAIL" ]] && \ + msg_error "$(translate "Details"): $(printf '%s' "$DOH_PARTITION_ERROR_DETAIL" | head -n1)" return 1 fi + msg_ok "$(translate "Partition created")" + local partition="$DOH_CREATED_PARTITION" - # Wait for kernel to recognize new partition - sleep 2 - partprobe "$disk" 2>/dev/null || true - sleep 1 - - # Determine partition device - local partition - if [[ "$disk" =~ [0-9]$ ]]; then - partition="${disk}p1" - else - partition="${disk}1" + # ZFS pre-flight checks (pool existence must be verified before format) + if [[ "$filesystem" == "zfs" ]]; then + if ! command -v zpool >/dev/null 2>&1; then + msg_error "$(translate "zpool command not found. Install zfsutils-linux and retry.")" + return 1 + fi + if zpool list "$STORAGE_ID" >/dev/null 2>&1; then + msg_error "$(translate "A ZFS pool with this name already exists:") $STORAGE_ID" + return 1 + fi fi msg_info "$(translate "Formatting as") $filesystem..." - case "$filesystem" in - ext4) - if ! mkfs.ext4 -F -L "$STORAGE_ID" "$partition" >/dev/null 2>&1; then - msg_error "$(translate "Failed to format disk as ext4")" - return 1 - fi - ;; - xfs) - if ! mkfs.xfs -f -L "$STORAGE_ID" "$partition" >/dev/null 2>&1; then - msg_error "$(translate "Failed to format disk as xfs")" - return 1 - fi - ;; - esac + if ! doh_format_partition "$partition" "$filesystem" "$STORAGE_ID" "$STORAGE_ID" "$mount_path"; then + msg_error "$(translate "Failed to format disk as") $filesystem" + return 1 + fi msg_ok "$(translate "Disk formatted as") $filesystem" @@ -303,11 +462,21 @@ mount_disk_permanently() { local mount_path="$2" local filesystem="$3" + if [[ "$filesystem" == "zfs" ]]; then + if ! zpool list "$STORAGE_ID" >/dev/null 2>&1; then + msg_error "$(translate "ZFS pool is not available after creation:") $STORAGE_ID" + return 1 + fi + msg_ok "$(translate "ZFS pool created and mounted at") $mount_path" + return 0 + fi + msg_info "$(translate "Creating mount point...")" if ! mkdir -p "$mount_path"; then msg_error "$(translate "Failed to create mount point:") $mount_path" return 1 fi + msg_ok "$(translate "Mount point created")" msg_info "$(translate "Mounting disk...")" if ! mount -t "$filesystem" "$partition" "$mount_path" 2>/dev/null; then @@ -323,11 +492,11 @@ mount_disk_permanently() { if [[ -n "$disk_uuid" ]]; then # Remove any existing fstab entry for this UUID or mount point sed -i "\|UUID=$disk_uuid|d" /etc/fstab - sed -i "\|[[:space:]]$mount_path[[:space:]]|d" /etc/fstab + sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab echo "UUID=$disk_uuid $mount_path $filesystem defaults,nofail 0 2" >> /etc/fstab msg_ok "$(translate "Added to /etc/fstab using UUID")" else - sed -i "\|[[:space:]]$mount_path[[:space:]]|d" /etc/fstab + sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab echo "$partition $mount_path $filesystem defaults,nofail 0 2" >> /etc/fstab msg_ok "$(translate "Added to /etc/fstab using device path")" fi @@ -350,6 +519,7 @@ mount_existing_disk() { msg_info "$(translate "Creating mount point...")" mkdir -p "$mount_path" + msg_ok "$(translate "Mount point created")" msg_info "$(translate "Mounting existing") $existing_fs $(translate "filesystem...")" if ! mount "$disk" "$mount_path" 2>/dev/null; then @@ -363,7 +533,7 @@ mount_existing_disk() { disk_uuid=$(blkid -s UUID -o value "$disk" 2>/dev/null) if [[ -n "$disk_uuid" ]]; then sed -i "\|UUID=$disk_uuid|d" /etc/fstab - sed -i "\|[[:space:]]$mount_path[[:space:]]|d" /etc/fstab + sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab echo "UUID=$disk_uuid $mount_path $existing_fs defaults,nofail 0 2" >> /etc/fstab msg_ok "$(translate "Added to /etc/fstab")" fi @@ -377,6 +547,12 @@ add_proxmox_dir_storage() { local storage_id="$1" local path="$2" local content="$3" + local storage_kind="dir" + local pool_name="$storage_id" + + if [[ "${FILESYSTEM:-}" == "zfs" ]]; then + storage_kind="zfspool" + fi if ! command -v pvesm >/dev/null 2>&1; then msg_error "$(translate "pvesm command not found. This should not happen on Proxmox.")" @@ -385,8 +561,9 @@ add_proxmox_dir_storage() { if pvesm status "$storage_id" >/dev/null 2>&1; then msg_warn "$(translate "Storage ID already exists:") $storage_id" - if ! whiptail --yesno "$(translate "Storage ID already exists. Do you want to remove and recreate it?")" \ - 8 60 --title "$(translate "Storage Exists")"; then + if ! dialog --backtitle "ProxMenux" --title "$(translate "Storage Exists")" --yesno \ + "$(translate "Storage ID already exists. Do you want to remove and recreate it?")" \ + 8 60; then return 0 fi pvesm remove "$storage_id" 2>/dev/null || true @@ -394,16 +571,38 @@ add_proxmox_dir_storage() { msg_info "$(translate "Registering disk as Proxmox storage...")" local pvesm_output - if pvesm_output=$(pvesm add dir "$storage_id" \ - --path "$path" \ - --content "$content" 2>&1); then + local add_ok=false + if [[ "$storage_kind" == "zfspool" ]]; then + if pvesm_output=$(pvesm add zfspool "$storage_id" \ + --pool "$pool_name" \ + --content "$content" 2>&1); then + add_ok=true + fi + else + if pvesm_output=$(pvesm add dir "$storage_id" \ + --path "$path" \ + --content "$content" 2>&1); then + add_ok=true + fi + fi - msg_ok "$(translate "Directory storage added successfully to Proxmox!")" - echo -e "" - echo -e "${TAB}${BOLD}$(translate "Storage Added:")${CL}" - echo -e "${TAB}${BGN}$(translate "Storage ID:")${CL} ${BL}$storage_id${CL}" - echo -e "${TAB}${BGN}$(translate "Path:")${CL} ${BL}$path${CL}" - echo -e "${TAB}${BGN}$(translate "Content Types:")${CL} ${BL}$content${CL}" + if [[ "$add_ok" == "true" ]]; then + if [[ "$storage_kind" == "zfspool" ]]; then + msg_ok "$(translate "ZFS storage added successfully to Proxmox!")" + echo -e "" + echo -e "${TAB}${BOLD}$(translate "Storage Added:")${CL}" + echo -e "${TAB}${BGN}$(translate "Storage ID:")${CL} ${BL}$storage_id${CL}" + echo -e "${TAB}${BGN}$(translate "Type:")${CL} ${BL}zfspool${CL}" + echo -e "${TAB}${BGN}$(translate "Pool:")${CL} ${BL}$pool_name${CL}" + echo -e "${TAB}${BGN}$(translate "Content Types:")${CL} ${BL}$content${CL}" + else + msg_ok "$(translate "Directory storage added successfully to Proxmox!")" + echo -e "" + echo -e "${TAB}${BOLD}$(translate "Storage Added:")${CL}" + echo -e "${TAB}${BGN}$(translate "Storage ID:")${CL} ${BL}$storage_id${CL}" + echo -e "${TAB}${BGN}$(translate "Path:")${CL} ${BL}$path${CL}" + echo -e "${TAB}${BGN}$(translate "Content Types:")${CL} ${BL}$content${CL}" + fi echo -e "" msg_ok "$(translate "Storage is now available in Proxmox web interface under Datacenter > Storage")" return 0 @@ -412,8 +611,13 @@ add_proxmox_dir_storage() { echo -e "${TAB}$(translate "Error details:"): $pvesm_output" echo -e "" msg_info2 "$(translate "You can add it manually through:")" - echo -e "${TAB}• $(translate "Proxmox web interface: Datacenter > Storage > Add > Directory")" - echo -e "${TAB}• pvesm add dir $storage_id --path $path --content $content" + if [[ "$storage_kind" == "zfspool" ]]; then + echo -e "${TAB}• $(translate "Proxmox web interface: Datacenter > Storage > Add > ZFS")" + echo -e "${TAB}• pvesm add zfspool $storage_id --pool $pool_name --content $content" + else + echo -e "${TAB}• $(translate "Proxmox web interface: Datacenter > Storage > Add > Directory")" + echo -e "${TAB}• pvesm add dir $storage_id --path $path --content $content" + fi return 1 fi } @@ -424,11 +628,14 @@ add_proxmox_dir_storage() { add_disk_to_proxmox() { # Check required tools - for tool in parted mkfs.ext4 blkid lsblk; do + for tool in parted mkfs.ext4 mkfs.xfs blkid lsblk sgdisk; do if ! command -v "$tool" >/dev/null 2>&1; then + show_proxmenux_logo + msg_title "$(translate "Add Local Disk as Proxmox Storage")" msg_info "$(translate "Installing required tools...")" apt-get update &>/dev/null - apt-get install -y parted e2fsprogs util-linux xfsprogs gdisk &>/dev/null + apt-get install -y parted e2fsprogs util-linux xfsprogs gdisk btrfs-progs &>/dev/null + stop_spinner break fi done @@ -442,20 +649,35 @@ add_disk_to_proxmox() { # Step 3: Filesystem selection (only if formatting) if [[ "$DISK_ACTION" == "format" ]]; then select_filesystem || return + if [[ "$FILESYSTEM" == "zfs" ]] && ! command -v zpool >/dev/null 2>&1; then + msg_error "$(translate "zpool not found. Install zfsutils-linux and retry.")" + echo + msg_success "$(translate "Press Enter to continue...")" + read -r + return 1 + fi fi # Step 4: Configure storage options configure_disk_storage || return - show_proxmenux_logo - msg_title "$(translate "Add Local Disk as Proxmox Storage")" - msg_ok "$(translate "Disk:") $SELECTED_DISK" - msg_ok "$(translate "Action:") $DISK_ACTION" - [[ "$DISK_ACTION" == "format" ]] && msg_ok "$(translate "Filesystem:") $FILESYSTEM" - msg_ok "$(translate "Mount path:") $MOUNT_PATH" - msg_ok "$(translate "Storage ID:") $STORAGE_ID" - msg_ok "$(translate "Content:") $MOUNT_CONTENT" - echo "" + if declare -F _refresh_host_storage_cache >/dev/null 2>&1; then + _refresh_host_storage_cache + fi + if disk_used_by_host_storage "$SELECTED_DISK"; then + msg_error "$(translate "Safety check failed: selected disk is now used by host/system.")" + echo + msg_success "$(translate "Press Enter to continue...")" + read -r + return 1 + fi + if disk_referenced_in_guest_configs "$SELECTED_DISK"; then + msg_error "$(translate "Safety check failed: selected disk is referenced by a VM/LXC config.")" + echo + msg_success "$(translate "Press Enter to continue...")" + read -r + return 1 + fi # Step 5: Format/mount case "$DISK_ACTION" in @@ -474,7 +696,9 @@ add_disk_to_proxmox() { } ;; use_existing) - mount_existing_disk "$SELECTED_DISK" "$MOUNT_PATH" || { + local existing_node + existing_node="${DISK_EXISTING_NODE:-$SELECTED_DISK}" + mount_existing_disk "$existing_node" "$MOUNT_PATH" || { echo "" msg_success "$(translate "Press Enter to continue...")" read -r @@ -506,20 +730,31 @@ view_disk_storages() { return fi - # Show all directory storages - DIR_STORAGES=$(pvesm status 2>/dev/null | awk '$2 == "dir" {print $1, $3}') - if [[ -z "$DIR_STORAGES" ]]; then - msg_warn "$(translate "No directory storage configured in Proxmox.")" + # Show local storages managed by this menu (Directory + ZFS Pool), excluding system ones + DIR_STORAGES=$(pvesm status 2>/dev/null | awk '$2 == "dir" || $2 == "zfspool" {print $1, $2, $3}') + local user_storage_found=false + if [[ -n "$DIR_STORAGES" ]]; then + while IFS=" " read -r s_id s_type _; do + [[ -z "$s_id" ]] && continue + _is_user_disk_storage "$s_id" "$s_type" || continue + user_storage_found=true + break + done <<< "$DIR_STORAGES" + fi + if [[ "$user_storage_found" == "false" ]]; then + msg_warn "$(translate "No local storage configured in Proxmox.")" echo "" msg_info2 "$(translate "Use option 1 to add a local disk as Proxmox storage.")" else - echo -e "${BOLD}$(translate "Directory Storages:")${CL}" + echo -e "${BOLD}$(translate "Local Storages:")${CL}" echo "" - while IFS=" " read -r storage_id storage_status; do + while IFS=" " read -r storage_id storage_type storage_status; do [[ -z "$storage_id" ]] && continue - local storage_info path content + _is_user_disk_storage "$storage_id" "$storage_type" || continue + local storage_info path content pool storage_info=$(get_storage_config "$storage_id") path=$(echo "$storage_info" | awk '$1 == "path" {print $2}') + pool=$(echo "$storage_info" | awk '$1 == "pool" {print $2}') content=$(echo "$storage_info" | awk '$1 == "content" {print $2}') local disk_device="" @@ -533,7 +768,9 @@ view_disk_storages() { fi echo -e "${TAB}${BOLD}$storage_id${CL}" + echo -e "${TAB} ${BGN}$(translate "Type:")${CL} ${BL}${storage_type:-unknown}${CL}" echo -e "${TAB} ${BGN}$(translate "Path:")${CL} ${BL}$path${CL}" + [[ -n "$pool" ]] && echo -e "${TAB} ${BGN}$(translate "Pool:")${CL} ${BL}$pool${CL}" [[ -n "$disk_device" ]] && echo -e "${TAB} ${BGN}$(translate "Device:")${CL} ${BL}$disk_device${CL}" [[ -n "$disk_size" ]] && echo -e "${TAB} ${BGN}$(translate "Size:")${CL} ${BL}$disk_size${CL}" echo -e "${TAB} ${BGN}$(translate "Content:")${CL} ${BL}$content${CL}" @@ -551,6 +788,161 @@ view_disk_storages() { read -r } +_remove_pvesm_storage() { + local storage_id="$1" + local path pool content stype + path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}') + pool=$(get_storage_config "$storage_id" | awk '$1 == "pool" {print $2}') + content=$(get_storage_config "$storage_id" | awk '$1 == "content" {print $2}') + stype=$(pvesm status 2>/dev/null | awk -v id="$storage_id" '$1==id {print $2}') + + local msg + msg="$(translate "WARNING: You are about to remove this Proxmox storage:")\n\n" + msg+=" $(translate "Storage ID:") $storage_id\n" + msg+=" $(translate "Type:") ${stype:-unknown}\n" + [[ -n "$path" ]] && msg+=" $(translate "Mount path:") $path\n" + [[ -n "$pool" ]] && msg+=" $(translate "ZFS pool:") $pool\n" + [[ -n "$content" ]] && msg+=" $(translate "Content:") $content\n" + msg+="\n$(translate "⚠ Disk data will NOT be erased.")\n" + [[ -n "$path" ]] && msg+="$(translate "⚠ Disk will be unmounted and removed from /etc/fstab.")\n" + [[ -n "$pool" ]] && msg+="$(translate "⚠ ZFS pool stays active — run 'zpool export $pool' to detach.")\n" + msg+="\n$(translate "Continue?")" + + if ! dialog --backtitle "ProxMenux" --title "$(translate "Confirm Remove")" --yesno "$msg" 22 84; then + return + fi + + show_proxmenux_logo + msg_title "$(translate "Remove Disk Storage")" + + # Step 1: Remove from Proxmox + msg_info "$(translate "Removing storage from Proxmox...")" + if ! pvesm remove "$storage_id" 2>/dev/null; then + msg_error "$(translate "Failed to remove storage from Proxmox.")" + echo "" + msg_success "$(translate "Press Enter to continue...")" + read -r + return + fi + msg_ok "$(translate "Storage") $storage_id $(translate "removed from Proxmox")" + + # Step 2: Unmount if mounted (dir-backed storages only) + if [[ -n "$path" ]] && mountpoint -q "$path" 2>/dev/null; then + msg_info "$(translate "Unmounting disk...")" + if umount "$path" 2>/dev/null; then + msg_ok "$(translate "Disk unmounted from") $path" + else + msg_warn "$(translate "Could not unmount") $path $(translate "— disk may be busy. Skipping fstab removal.")" + echo "" + msg_success "$(translate "Press Enter to continue...")" + read -r + return + fi + fi + + # Step 3: Remove /etc/fstab entry + if [[ -n "$path" ]] && grep -q "[[:space:]]${path}[[:space:]]" /etc/fstab 2>/dev/null; then + msg_info "$(translate "Removing from /etc/fstab...")" + local tmp + tmp=$(mktemp) + awk -v mp="$path" '$2 != mp' /etc/fstab > "$tmp" && mv "$tmp" /etc/fstab + systemctl daemon-reload 2>/dev/null || true + msg_ok "$(translate "Removed from /etc/fstab")" + fi + + # Step 3b: Export ZFS pool if applicable + if [[ -n "$pool" ]] && zpool list "$pool" >/dev/null 2>&1; then + msg_info "$(translate "Exporting ZFS pool...") $pool" + if zpool export "$pool" 2>/dev/null; then + msg_ok "$(translate "ZFS pool exported:") $pool" + else + msg_warn "$(translate "Could not export ZFS pool") $pool $(translate "— pool may be busy. Run manually: zpool export $pool")" + fi + fi + + # Step 4: Reboot prompt + echo "" + if whiptail --title "$(translate "Reboot Required")" --yesno \ + "\n$(translate "The storage has been removed and the disk unmounted.")\n\n$(translate "A server reboot is recommended for all changes to take full effect.")\n\n$(translate "Reboot now?")" \ + 14 72; then + msg_success "$(translate "Press Enter to continue...")" + read -r + echo "" + msg_warn "$(translate "Rebooting the system...")" + reboot + else + echo "" + msg_info2 "$(translate "Reboot pending — changes will take full effect after the next restart.")" + fi + + echo "" + msg_success "$(translate "Press Enter to continue...")" + read -r +} + +_remove_fstab_entry() { + local mount_point="$1" + + local fs fstype + while IFS= read -r line; do + [[ "$line" =~ ^# || -z "$line" ]] && continue + local f mp ft + read -r f mp ft _ <<< "$line" + if [[ "$mp" == "$mount_point" ]]; then + fs="$f"; fstype="$ft"; break + fi + done < /etc/fstab + + local device="$fs" + if [[ "$fs" == UUID=* ]]; then + device=$(blkid -U "${fs#UUID=}" 2>/dev/null || echo "$fs") + fi + local size="" + [[ -b "$device" ]] && size=$(lsblk -ndo SIZE "$device" 2>/dev/null) + + local mounted=false + findmnt -n "$mount_point" >/dev/null 2>&1 && mounted=true + + local msg + msg="$(translate "WARNING: You are about to remove this disk mount:")\n\n" + msg+=" $(translate "Mount point:") $mount_point\n" + [[ -n "$device" && "$device" != "$fs" ]] && msg+=" $(translate "Device:") $device\n" + msg+=" $(translate "Filesystem:") $fstype\n" + [[ -n "$size" ]] && msg+=" $(translate "Size:") $size\n" + local mounted_label; $mounted && mounted_label="$(translate "Yes")" || mounted_label="$(translate "No")" + msg+=" $(translate "Currently mounted:") $mounted_label\n" + msg+="\n$(translate "⚠ The disk will be unmounted.")\n" + msg+="$(translate "⚠ The /etc/fstab entry will be removed.")\n" + msg+="$(translate "⚠ Disk data will NOT be erased.")\n" + msg+="\n$(translate "Continue?")" + + if dialog --backtitle "ProxMenux" --title "$(translate "Confirm Remove")" --yesno "$msg" 20 80; then + show_proxmenux_logo + msg_title "$(translate "Remove Disk from fstab")" + + if $mounted; then + msg_info "$(translate "Unmounting") $mount_point..." + if umount "$mount_point" 2>/dev/null; then + msg_ok "$(translate "Unmounted successfully")" + else + msg_warn "$(translate "Could not unmount — disk may be busy. Removing fstab entry anyway.")" + fi + fi + + msg_info "$(translate "Removing from /etc/fstab...")" + local tmp + tmp=$(mktemp) + awk -v mp="$mount_point" '$2 != mp' /etc/fstab > "$tmp" + mv "$tmp" /etc/fstab + systemctl daemon-reload 2>/dev/null || true + msg_ok "$(translate "Removed from /etc/fstab")" + + echo "" + msg_success "$(translate "Press Enter to continue...")" + read -r + fi +} + remove_disk_storage() { if ! command -v pvesm >/dev/null 2>&1; then dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ @@ -558,49 +950,64 @@ remove_disk_storage() { return fi - DIR_STORAGES=$(pvesm status 2>/dev/null | awk '$2 == "dir" {print $1}') - if [[ -z "$DIR_STORAGES" ]]; then + local OPTIONS=() + local pvesm_paths=() + + # --- Source 1: pvesm user-created storages --- + local ALL_STORAGES + ALL_STORAGES=$(pvesm status 2>/dev/null | awk '$2 == "dir" || $2 == "zfspool" {print $1, $2}') + while IFS= read -r line; do + local storage_id storage_type + storage_id=$(echo "$line" | awk '{print $1}') + storage_type=$(echo "$line" | awk '{print $2}') + [[ -z "$storage_id" ]] && continue + _is_user_disk_storage "$storage_id" "$storage_type" || continue + local path pool + path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}') + pool=$(get_storage_config "$storage_id" | awk '$1 == "pool" {print $2}') + [[ -n "$path" ]] && pvesm_paths+=("$path") + local label="[pvesm] ${storage_type}" + [[ -n "$path" ]] && label+=" — $path" + [[ -z "$path" && -n "$pool" ]] && label+=" — pool: $pool" + OPTIONS+=("pvesm:$storage_id" "$label") + done <<< "$ALL_STORAGES" + + # --- Source 2: fstab /mnt/ entries not already covered by pvesm --- + while IFS= read -r line; do + [[ "$line" =~ ^# || -z "$line" ]] && continue + local fs mp fstype + read -r fs mp fstype _ <<< "$line" + [[ "$mp" == /mnt/* ]] || continue + [[ "$fstype" == "proc" || "$fstype" == "sysfs" || "$fstype" == "tmpfs" || "$fstype" == "devtmpfs" || "$fstype" == "none" ]] && continue + local covered=false + for p in "${pvesm_paths[@]}"; do [[ "$p" == "$mp" ]] && covered=true && break; done + $covered && continue + local device="$fs" + [[ "$fs" == UUID=* ]] && device=$(blkid -U "${fs#UUID=}" 2>/dev/null || echo "$fs") + local size="" + [[ -b "$device" ]] && size=$(lsblk -ndo SIZE "$device" 2>/dev/null) + local label="[fstab] $mp ($fstype)" + [[ -n "$size" ]] && label+=" [$size]" + findmnt -n "$mp" >/dev/null 2>&1 && label+=" ✓" || label+=" (not mounted)" + OPTIONS+=("fstab:$mp" "$label") + done < /etc/fstab + + if [[ ${#OPTIONS[@]} -eq 0 ]]; then dialog --backtitle "ProxMenux" --title "$(translate "No Disk Storage")" \ - --msgbox "\n$(translate "No directory storage found in Proxmox.")" 8 60 + --msgbox "\n$(translate "No user-created disk storage or fstab mount found.")" 8 68 return fi - OPTIONS=() - while IFS= read -r storage_id; do - [[ -z "$storage_id" ]] && continue - local path - path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}') - OPTIONS+=("$storage_id" "${path:-unknown}") - done <<< "$DIR_STORAGES" - + local SELECTED SELECTED=$(dialog --backtitle "ProxMenux" --title "$(translate "Remove Disk Storage")" \ - --menu "$(translate "Select storage to remove:")" 20 80 10 \ + --menu "$(translate "Select storage to remove:")" 20 88 12 \ "${OPTIONS[@]}" 3>&1 1>&2 2>&3) [[ -z "$SELECTED" ]] && return - local path content - path=$(get_storage_config "$SELECTED" | awk '$1 == "path" {print $2}') - content=$(get_storage_config "$SELECTED" | awk '$1 == "content" {print $2}') - - if whiptail --yesno "$(translate "Remove Proxmox storage:")\n\n$SELECTED\n\n$(translate "Path:"): $path\n$(translate "Content:"): $content\n\n$(translate "This removes the storage registration from Proxmox.")\n$(translate "The disk and its data will NOT be erased.")\n$(translate "The disk will remain mounted at:"): $path" \ - 18 80 --title "$(translate "Confirm Remove")"; then - - show_proxmenux_logo - msg_title "$(translate "Remove Disk Storage")" - - if pvesm remove "$SELECTED" 2>/dev/null; then - msg_ok "$(translate "Storage") $SELECTED $(translate "removed from Proxmox.")" - echo "" - msg_info2 "$(translate "The disk remains mounted at:"): $path" - msg_info2 "$(translate "The fstab entry is still present. Remove manually if needed.")" - else - msg_error "$(translate "Failed to remove storage.")" - fi - - echo "" - msg_success "$(translate "Press Enter to continue...")" - read -r - fi + case "${SELECTED%%:*}" in + pvesm) _remove_pvesm_storage "${SELECTED#pvesm:}" ;; + fstab) _remove_fstab_entry "${SELECTED#fstab:}" ;; + esac } list_available_disks() { @@ -615,9 +1022,9 @@ list_available_disks() { lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL 2>/dev/null echo "" - echo -e "${BOLD}$(translate "Proxmox directory storages:")${CL}" + echo -e "${BOLD}$(translate "Proxmox local storages:")${CL}" if command -v pvesm >/dev/null 2>&1; then - pvesm status 2>/dev/null | awk '$2 == "dir" {print " " $1, $2, $3}' || echo " $(translate "None")" + pvesm status 2>/dev/null | awk '$2 == "dir" || $2 == "zfspool" {print " " $1, $2, $3}' || echo " $(translate "None")" fi echo "" diff --git a/scripts/share/iscsi_host.sh b/scripts/share/iscsi_host.sh index dfac2b50..3c614383 100644 --- a/scripts/share/iscsi_host.sh +++ b/scripts/share/iscsi_host.sh @@ -55,7 +55,6 @@ ensure_iscsi_tools() { fi if ! systemctl is-active --quiet iscsid 2>/dev/null; then - msg_info "$(translate "Starting iSCSI daemon...")" systemctl start iscsid 2>/dev/null || true fi } @@ -65,10 +64,9 @@ ensure_iscsi_tools() { # ========================================================== select_iscsi_portal() { - ISCSI_PORTAL=$(whiptail --inputbox \ + ISCSI_PORTAL=$(dialog --backtitle "ProxMenux" --title "$(translate "iSCSI Portal")" --inputbox \ "$(translate "Enter iSCSI target portal IP or hostname:")\n\n$(translate "Examples:")\n 192.168.1.100\n 192.168.1.100:3260\n nas.local" \ - 14 65 \ - --title "$(translate "iSCSI Portal")" 3>&1 1>&2 2>&3) + 14 65 3>&1 1>&2 2>&3) [[ $? -ne 0 || -z "$ISCSI_PORTAL" ]] && return 1 # Normalise: if no port specified, add default 3260 diff --git a/scripts/share/local-shared-manager.sh b/scripts/share/local-shared-manager.sh index a25e86bd..62dac4ce 100644 --- a/scripts/share/local-shared-manager.sh +++ b/scripts/share/local-shared-manager.sh @@ -6,82 +6,236 @@ # Copyright : (c) 2024 MacRimi # License : MIT # Version : 1.0 -# Last Updated: $(date +%d/%m/%Y) +# Last Updated: 08/04/2026 # ========================================================== -# Configuration -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" - +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi - SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then - SHARE_COMMON_LOADED=false -else - SHARE_COMMON_LOADED=true + msg_error "$(translate "Could not load shared functions. Script cannot continue.")" + exit 1 fi load_language initialize_cache +if ! command -v pveversion >/dev/null 2>&1; then + dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ + --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 + exit 1 +fi + # ========================================================== -create_shared_directory() { - SHARED_DIR=$(pmx_select_host_mount_point "$(translate "Select Shared Directory Location")" "/mnt/shared") - [[ -z "$SHARED_DIR" ]] && return +lsm_apply_multi_unpriv_permissions() { + local dir="$1" + [[ -z "$dir" || ! -d "$dir" ]] && return 1 - if [[ -d "$SHARED_DIR" ]]; then - if ! whiptail --yesno "$(translate "Directory already exists. Continue with permission setup?")" 10 70 --title "$(translate "Directory Exists")"; then - return + # root:root ownership — no new group needed. + chown root:root "$dir" 2>/dev/null || true + + # 1777 = sticky bit (prevents cross-container file deletion) + world-rwx. + # Unprivileged LXC UIDs (100000+) appear as 'others' on the host, + # so 'o+rwx' is what grants them read+write access. + chmod 1777 "$dir" 2>/dev/null || true + + # Ensure existing content is readable/writable regardless of UID mapping. + chmod -R a+rwX "$dir" 2>/dev/null || true + find "$dir" -type d -exec chmod 1777 {} + 2>/dev/null || true + + if command -v setfacl >/dev/null 2>&1; then + # Remove restrictive ACLs and enforce permissive inheritance for new files. + setfacl -b -R "$dir" 2>/dev/null || true + setfacl -R -m u::rwx,g::rwx,o::rwx,m::rwx "$dir" 2>/dev/null || true + setfacl -R -m d:u::rwx,d:g::rwx,d:o::rwx,d:m::rwx "$dir" 2>/dev/null || true + fi + + return 0 +} + +# Returns a free name like /mnt/shared, /mnt/shared2, /mnt/shared3 … +lsm_next_free_name() { + local base="${1:-shared}" + local candidate="/mnt/$base" + [[ ! -d "$candidate" ]] && echo "$candidate" && return + local n=2 + while [[ -d "/mnt/${base}${n}" ]]; do + ((n++)) + done + echo "/mnt/${base}${n}" +} + +lsm_list_mnt_folders() { + show_proxmenux_logo + msg_title "$(translate "Folders in /mnt")" + + echo "==================================================" + + if [[ ! -d /mnt ]] || [[ -z "$(ls -A /mnt 2>/dev/null)" ]]; then + echo "" + echo -e "${TAB}$(translate "No folders found in /mnt.")" + else + local found=false + while IFS= read -r dir; do + [[ ! -d "$dir" ]] && continue + found=true + local perms owner + perms=$(stat -c "%a" "$dir" 2>/dev/null) + owner=$(stat -c "%U:%G" "$dir" 2>/dev/null) + echo "" + echo -e "${TAB}${BGN}$(translate "Directory:")${CL} ${BL}$dir${CL}" + echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}${perms} $(stat -c "(%A)" "$dir" 2>/dev/null)${CL}" + echo -e "${TAB}${BGN}$(translate "Owner:")${CL} ${BL}${owner}${CL}" + done < <(find /mnt -mindepth 1 -maxdepth 1 -type d | sort) + + if [[ "$found" = false ]]; then + echo "" + echo -e "${TAB}$(translate "No folders found in /mnt.")" fi fi + echo "" + echo "==================================================" + echo "" - SHARE_GROUP=$(pmx_choose_or_create_group "sharedfiles") || return 1 - SHARE_GID=$(pmx_ensure_host_group "$SHARE_GROUP" 101000) || return 1 - - - if command -v setfacl >/dev/null 2>&1; then - setfacl -k /mnt 2>/dev/null || true - setfacl -b /mnt 2>/dev/null || true - fi - chmod 755 /mnt 2>/dev/null || true - - - pmx_prepare_host_shared_dir "$SHARED_DIR" "$SHARE_GROUP" || return 1 - - - if command -v setfacl >/dev/null 2>&1; then - setfacl -b -R "$SHARED_DIR" 2>/dev/null || true + # Summary of /mnt available space + if mountpoint -q /mnt 2>/dev/null || [[ -d /mnt ]]; then + local mnt_avail mnt_total + mnt_avail=$(df -h /mnt 2>/dev/null | awk 'NR==2{print $4}') + mnt_total=$(df -h /mnt 2>/dev/null | awk 'NR==2{print $2}') + if [[ -n "$mnt_avail" ]]; then + echo -e "${TAB}${BGN}$(translate "Available space in /mnt:")${CL} ${BL}${mnt_avail} $(translate "of") ${mnt_total}${CL}" + echo "" + fi fi + msg_success "$(translate "Press Enter to return to menu...")" + read -r +} - chown root:"$SHARE_GROUP" "$SHARED_DIR" - chmod 2775 "$SHARED_DIR" +# Result is stored in LSM_SELECTED_MOUNT_POINT (not echoed) to avoid subshell issues +LSM_SELECTED_MOUNT_POINT="" - pmx_share_map_set "$SHARED_DIR" "$SHARE_GROUP" +lsm_select_host_mount_point_dialog() { + local title="${1:-$(translate "Select Shared Directory Location")}" + local base_name="${2:-shared}" + local choice folder_name result mount_point + LSM_SELECTED_MOUNT_POINT="" + + # Auto-suggest a free name in /mnt + local suggested + suggested=$(lsm_next_free_name "$base_name") + + while true; do + choice=$(dialog --backtitle "ProxMenux" \ + --title "$title" \ + --menu "\n$(translate "Where do you want the host folder?")" 16 72 4 \ + "1" "$(translate "Create new folder in /mnt")" \ + "2" "$(translate "Enter custom path")" \ + "3" "$(translate "View existing folders in /mnt")" \ + "4" "$(translate "Cancel")" \ + 3>&1 1>&2 2>&3) || return 1 + + case "$choice" in + 1) + folder_name=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Folder Name")" \ + --inputbox "\n$(translate "Enter folder name for /mnt:")" 10 70 "$(basename "$suggested")" \ + 3>&1 1>&2 2>&3) || continue + [[ -z "$folder_name" ]] && continue + mount_point="/mnt/$folder_name" + # Only warn if the user manually typed an existing name + if [[ -d "$mount_point" ]]; then + if ! dialog --backtitle "ProxMenux" --title "$(translate "Directory Exists")" \ + --yesno "\n$(translate "Directory already exists. Continue with permission setup?")" 8 70; then + continue + fi + fi + ;; + 2) + result=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Custom Path")" \ + --inputbox "\n$(translate "Enter full path:")" 10 80 "" \ + 3>&1 1>&2 2>&3) || continue + [[ -z "$result" ]] && continue + mount_point="$result" + if [[ -d "$mount_point" ]]; then + if ! dialog --backtitle "ProxMenux" --title "$(translate "Directory Exists")" \ + --yesno "\n$(translate "Directory already exists. Continue with permission setup?")" 8 70; then + continue + fi + fi + ;; + 3) + lsm_list_mnt_folders + # Refresh suggestion after viewing + suggested=$(lsm_next_free_name "$base_name") + continue + ;; + 4) return 1 ;; + *) continue ;; + esac + + if [[ ! "$mount_point" =~ ^/ ]]; then + dialog --backtitle "ProxMenux" --title "$(translate "Invalid Path")" \ + --msgbox "\n$(translate "Path must be absolute (start with /).")" 8 60 + continue + fi + + LSM_SELECTED_MOUNT_POINT="$mount_point" + return 0 + done +} + +create_shared_directory() { + lsm_select_host_mount_point_dialog "$(translate "Select Shared Directory Location")" "shared" + [[ -z "$LSM_SELECTED_MOUNT_POINT" ]] && return + SHARED_DIR="$LSM_SELECTED_MOUNT_POINT" show_proxmenux_logo msg_title "$(translate "Create Shared Directory")" + if ! mkdir -p "$SHARED_DIR" 2>/dev/null; then + msg_error "$(translate "Failed to create directory:") $SHARED_DIR" + echo "" + msg_success "$(translate "Press Enter to continue...")" + read -r + return 1 + fi + msg_ok "$(translate "Directory created:") $SHARED_DIR" + + lsm_apply_multi_unpriv_permissions "$SHARED_DIR" + + pmx_share_map_set "$SHARED_DIR" "open" + echo -e "" - echo -e "${TAB}${BOLD}$(translate "Shared Directory Created:")${CL}" + echo -e "${TAB}${BOLD}$(translate "Shared Directory Ready:")${CL}" echo -e "${TAB}${BGN}$(translate "Directory:")${CL} ${BL}$SHARED_DIR${CL}" - echo -e "${TAB}${BGN}$(translate "Group:")${CL} ${BL}$SHARE_GROUP (GID: $SHARE_GID)${CL}" - echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}2775 (rwxrwsr-x)${CL}" - echo -e "${TAB}${BGN}$(translate "Owner:")${CL} ${BL}root:$SHARE_GROUP${CL}" - echo -e "${TAB}${BGN}$(translate "ACL Status:")${CL} ${BL}$(translate "Cleaned and set for POSIX inheritance")${CL}" + echo -e "${TAB}${BGN}$(translate "Permissions:")${CL} ${BL}1777 (rwxrwxrwt)${CL}" + echo -e "${TAB}${BGN}$(translate "Owner:")${CL} ${BL}root:root${CL}" + echo -e "${TAB}${BGN}$(translate "Access profile:")${CL} ${BL}$(translate "Compatible with privileged and unprivileged LXC containers")${CL}" + echo -e "${TAB}${BGN}$(translate "ACL Status:")${CL} ${BL}$(translate "Open rwx + default inheritance for new files")${CL}" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r diff --git a/scripts/share/lxc-mount-manager_minimal.sh b/scripts/share/lxc-mount-manager_minimal.sh index 19c315e4..95c8fbb6 100644 --- a/scripts/share/lxc-mount-manager_minimal.sh +++ b/scripts/share/lxc-mount-manager_minimal.sh @@ -229,15 +229,13 @@ select_host_directory_unified() { return 1 fi - # Warn about CIFS Proxmox-GUI storage (read-only limitation) + # Store the storage type as a global so the main flow can act on it later. + # We don't block the user here — the active fix happens after we know the container type. + LMM_HOST_DIR_TYPE="local" if detect_problematic_storage "$result" "Proxmox-Storage" "CIFS/SMB"; then - dialog --clear --title "$(translate "CIFS Storage Notice")" --yesno "\ -$(translate "This directory is a CIFS storage managed by Proxmox.")\n\n\ -$(translate "CIFS storage configured through Proxmox GUI applies restrictive permissions.")\n\ -$(translate "LXC containers can usually READ but may NOT be able to WRITE.")\n\n\ -$(translate "For write access, use 'Add Samba Share as Proxmox Storage' option instead.")\n\n\ -$(translate "Do you want to continue anyway?")" 14 80 3>&1 1>&2 2>&3 - [[ $? -ne 0 ]] && return 1 + LMM_HOST_DIR_TYPE="cifs" + elif detect_problematic_storage "$result" "Proxmox-Storage" "NFS"; then + LMM_HOST_DIR_TYPE="nfs" fi echo "$result" @@ -314,7 +312,7 @@ select_container_mount_point() { fi # Check if path is already used as a mount point in this CT - if pct config "$ctid" 2>/dev/null | grep -q "mp=.*$mount_point"; then + if pct config "$ctid" 2>/dev/null | grep -qE "mp=${mount_point}(,|$)"; then whiptail --msgbox "$(translate "This path is already used as a mount point in this container.")" 8 70 continue fi @@ -364,7 +362,7 @@ add_bind_mount() { fi # Check if this host path is already mounted in this CT - if pct config "$ctid" 2>/dev/null | grep -q "^mp[0-9]*:.*${host_path},"; then + if pct config "$ctid" 2>/dev/null | grep -qF " ${host_path},"; then msg_warn "$(translate "Mount already exists for this path in container") $ctid" return 1 fi @@ -555,6 +553,199 @@ $(translate "Proceed with removal")?" read -r } +# ========================================================== +# ACTIVE FIXES FOR NETWORK STORAGE (CIFS / NFS) +# These functions act on problems instead of just warning about them. +# ========================================================== + +lmm_fix_cifs_access() { + local host_dir="$1" + local is_unprivileged="$2" + + # CIFS mounted by Proxmox GUI uses uid=0/gid=0 by default (root only). + # The fix: remount with uid/gid that the LXC can access. + # We detect the current mount options and propose a corrected remount. + + local mount_src mount_opts + mount_src=$(findmnt -n -o SOURCE --target "$host_dir" 2>/dev/null) + mount_opts=$(findmnt -n -o OPTIONS --target "$host_dir" 2>/dev/null) + + if [[ -z "$mount_src" ]]; then + dialog --backtitle "ProxMenux" \ + --title "$(translate "CIFS Mount Not Found")" \ + --msgbox "$(translate "Could not detect the CIFS mount for this directory. Try accessing it manually.")" 8 70 + return 0 + fi + + # Determine which uid/gid to use + local target_uid target_gid + if [[ "$is_unprivileged" == "1" ]]; then + # Unprivileged LXC: container root (UID 0) maps to host UID 100000. + # Use file_mode/dir_mode 0777 + uid=0/gid=0 — CIFS maps them to everyone. + target_uid=0 + target_gid=0 + else + target_uid=0 + target_gid=0 + fi + + # Build new options: strip existing uid/gid/file_mode/dir_mode, add ours + local new_opts + new_opts=$(echo "$mount_opts" | sed -E \ + 's/(^|,)(uid|gid|file_mode|dir_mode)=[^,]*//g' | \ + sed 's/^,//') + new_opts="${new_opts},uid=${target_uid},gid=${target_gid},file_mode=0777,dir_mode=0777" + new_opts="${new_opts/#,/}" + + if dialog --backtitle "ProxMenux" \ + --title "$(translate "Fix CIFS Permissions")" \ + --yesno \ +"$(translate "This CIFS share is mounted with restrictive permissions.")\n\n\ +$(translate "ProxMenux can remount it with open permissions so any LXC can read and write.")\n\n\ +$(translate "Current mount options:")\n${mount_opts}\n\n\ +$(translate "New mount options to apply:")\n${new_opts}\n\n\ +$(translate "Apply fix now? (The share will be briefly remounted)")" \ + 18 84 3>&1 1>&2 2>&3; then + + msg_info "$(translate "Remounting CIFS share with open permissions...")" + if umount "$host_dir" 2>/dev/null && \ + mount -t cifs "$mount_src" "$host_dir" -o "$new_opts" 2>/dev/null; then + msg_ok "$(translate "CIFS share remounted — LXC containers can now read and write")" + + # Update fstab if the mount is there + if grep -qF "$host_dir" /etc/fstab 2>/dev/null; then + sed -i "s|^\(${mount_src}[[:space:]].*${host_dir}.*cifs[[:space:]]\).*|\1${new_opts} 0 0|" /etc/fstab 2>/dev/null || true + msg_ok "$(translate "/etc/fstab updated — permissions will persist after reboot")" + fi + else + msg_warn "$(translate "Could not remount automatically. Try manually or check credentials.")" + fi + fi +} + +lmm_fix_nfs_access() { + local host_dir="$1" + local is_unprivileged="$2" + local uid_shift="${3:-100000}" + + # NFS: the host cannot override server-side permissions. + # BUT: if the server exports with root_squash (default), we can check + # if no_root_squash or all_squash is possible, and guide the user. + # What we CAN do on the host: apply a sticky+open directory as a cache layer + # if the NFS mount allows it. + + local mount_src mount_opts + mount_src=$(findmnt -n -o SOURCE --target "$host_dir" 2>/dev/null) + mount_opts=$(findmnt -n -o OPTIONS --target "$host_dir" 2>/dev/null) + + # Try to detect if we can write to the NFS share as root + local can_write=false + local testfile="${host_dir}/.proxmenux_write_test_$$" + if touch "$testfile" 2>/dev/null; then + rm -f "$testfile" 2>/dev/null + can_write=true + fi + + local server_hint="" + if [[ -n "$mount_src" ]]; then + server_hint="${mount_src%%:*}" + fi + + if [[ "$can_write" == "true" && "$is_unprivileged" == "1" ]]; then + # Root on host CAN write to NFS, but unprivileged LXC UIDs (100000+) + # will be squashed by the NFS server. We can set a world-writable sticky + # dir on the share itself so the container can write to it. + if dialog --backtitle "ProxMenux" \ + --title "$(translate "Fix NFS Access for Unprivileged LXC")" \ + --yesno \ +"$(translate "NFS server export is writable from the host, but unprivileged LXC containers use mapped UIDs (${uid_shift}+) which the NFS server will squash.")\n\n\ +$(translate "ProxMenux can apply open permissions on this NFS directory from the host so the container can read and write:")\n\n\ +$(translate " chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)")\n\n\ +$(translate "Note: this only works if the NFS server does NOT use 'all_squash' for root.")\n\ +$(translate "If it still fails, the NFS server export options must be changed on the server.")\n\n\ +$(translate "Apply fix now?")" \ + 18 84 3>&1 1>&2 2>&3; then + + if chmod 1777 "$host_dir" 2>/dev/null; then + msg_ok "$(translate "NFS directory permissions set — containers should now be able to write")" + else + msg_warn "$(translate "chmod failed — NFS server may be restricting changes from root")" + fi + + if command -v setfacl >/dev/null 2>&1; then + setfacl -m o::rwx "$host_dir" 2>/dev/null || true + setfacl -m d:o::rwx "$host_dir" 2>/dev/null || true + fi + fi + + elif [[ "$can_write" == "false" ]]; then + # Even root cannot write — NFS server is fully restrictive + local server_msg="" + [[ -n "$server_hint" ]] && server_msg="\n$(translate "NFS server:"): ${server_hint}" + + dialog --backtitle "ProxMenux" \ + --title "$(translate "NFS Access Restricted")" \ + --msgbox \ +"$(translate "This NFS share is fully restricted — even the host root cannot write to it.")\n\ +${server_msg}\n\n\ +$(translate "ProxMenux cannot override NFS server-side permissions from the host.")\n\n\ +$(translate "To allow LXC write access, change the NFS export on the server to include:")\n\n\ +$(translate " no_root_squash") $(translate "(if only privileged LXCs need write access)")\n\ +$(translate " all_squash,anonuid=65534,anongid=65534") $(translate "(for unprivileged LXCs)")\n\n\ +$(translate "You can still mount this share for READ-ONLY access.")" \ + 20 84 3>&1 1>&2 2>&3 + fi +} + +# ========================================================== +# HOST PERMISSION CHECK (host-side only, never touches the container) +# ========================================================== + +lmm_offer_host_permissions() { + local host_dir="$1" + local is_unprivileged="$2" + + # Privileged containers: UID 0 inside = UID 0 on host — always accessible + [[ "$is_unprivileged" != "1" ]] && return 0 + + # Check if 'others' already have r+x (minimum to traverse and read) + local stat_perms others_bits + stat_perms=$(stat -c "%a" "$host_dir" 2>/dev/null) || return 0 + others_bits=$(( 8#${stat_perms} & 7 )) + + # Check ACLs first if available (takes precedence over mode bits) + if command -v getfacl >/dev/null 2>&1; then + if getfacl -p "$host_dir" 2>/dev/null | grep -q "^other::.*r.*x"; then + return 0 # ACL already grants others r+x or better + fi + fi + + # 5 = r-x (bits: r=4, x=1). If already r+x or rwx we're fine. + (( (others_bits & 5) == 5 )) && return 0 + + # Permissions are insufficient — offer to fix HOST directory only + local current_perms + current_perms=$(stat -c "%A" "$host_dir" 2>/dev/null) + + if dialog --backtitle "ProxMenux" \ + --title "$(translate "Unprivileged Container Access")" \ + --yesno \ +"$(translate "The host directory may not be accessible from an unprivileged container.")\n\n\ +$(translate "Unprivileged containers map their UIDs to high host UIDs (e.g. 100000+), which appear as 'others' on the host filesystem.")\n\n\ +$(translate "Current permissions:"): ${current_perms}\n\n\ +$(translate "Apply read+write access for 'others' on the host directory?")\n\n\ +$(translate "(Only the host directory is modified. Nothing inside the container is changed.")" \ + 16 80 3>&1 1>&2 2>&3; then + + chmod o+rwx "$host_dir" 2>/dev/null || true + if command -v setfacl >/dev/null 2>&1; then + setfacl -m o::rwx "$host_dir" 2>/dev/null || true + setfacl -m d:o::rwx "$host_dir" 2>/dev/null || true + fi + msg_ok "$(translate "Host directory permissions updated — unprivileged containers can now access it")" + fi +} + # ========================================================== # MAIN FUNCTION — ADD MOUNT # ========================================================== @@ -577,7 +768,7 @@ mount_host_directory_minimal() { # Step 4: Get container type info (for display only) local uid_shift container_type_display - uid_shift=$(awk -F: '/^lxc.idmap.*u 0/ {print $5}' "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | head -1) + uid_shift=$(awk '/^lxc.idmap.*u 0/ {print $5}' "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | head -1) local is_unprivileged is_unprivileged=$(grep "^unprivileged:" "/etc/pve/lxc/${container_id}.conf" 2>/dev/null | awk '{print $2}') if [[ "$is_unprivileged" == "1" ]]; then @@ -588,7 +779,13 @@ mount_host_directory_minimal() { uid_shift="0" fi - # Step 5: Confirmation + # Step 5: Active fix for network storage (before confirmation, while we know container type) + case "${LMM_HOST_DIR_TYPE:-local}" in + cifs) lmm_fix_cifs_access "$host_dir" "$is_unprivileged" ;; + nfs) lmm_fix_nfs_access "$host_dir" "$is_unprivileged" "$uid_shift" ;; + esac + + # Step 6: Confirmation local confirm_msg confirm_msg="$(translate "Mount Configuration Summary:") @@ -597,17 +794,12 @@ $(translate "Host Directory"): $host_dir $(translate "Container Mount Point"): $ct_mount_point $(translate "IMPORTANT NOTES:") -- $(translate "Host directory permissions and ownership are NOT modified") -- $(translate "Container filesystem is NOT modified") -- $(translate "If access fails after mounting, adjust permissions manually:") - -$(if [[ "$is_unprivileged" == "1" ]]; then - echo " # Allow container UID ${uid_shift}+ to access host dir:" - echo " setfacl -m u:${uid_shift}:rwx \"$host_dir\"" - echo " setfacl -d:m u:${uid_shift}:rwx \"$host_dir\"" -else - echo " chmod 755 \"$host_dir\"" -fi) +- $(translate "Nothing inside the container is modified") +- $(if [[ "$is_unprivileged" == "1" ]]; then + translate "Host directory access for unprivileged containers has been prepared above" + else + translate "Privileged container — host root maps directly, no permission changes needed" + fi) $(translate "Proceed")?" @@ -621,7 +813,7 @@ $(translate "Proceed")?" msg_ok "$(translate "Host directory:") $host_dir" msg_ok "$(translate "Container mount point:") $ct_mount_point" - # Step 6: Add bind mount (the ONLY operation that changes anything) + # Step 7: Add bind mount if ! add_bind_mount "$container_id" "$host_dir" "$ct_mount_point"; then echo "" msg_success "$(translate "Press Enter to continue...")" @@ -629,27 +821,25 @@ $(translate "Proceed")?" return 1 fi - # Step 7: Summary with permission hints + # Step 8: Host permission check for local dirs (only if not already handled above for CIFS/NFS) + if [[ "${LMM_HOST_DIR_TYPE:-local}" == "local" ]]; then + lmm_offer_host_permissions "$host_dir" "$is_unprivileged" + fi + + # Step 9: Summary echo "" echo -e "${TAB}${BOLD}$(translate "Mount Added Successfully:")${CL}" echo -e "${TAB}${BGN}$(translate "Container:")${CL} ${BL}$container_id${CL}" echo -e "${TAB}${BGN}$(translate "Host Directory:")${CL} ${BL}$host_dir${CL}" echo -e "${TAB}${BGN}$(translate "Mount Point:")${CL} ${BL}$ct_mount_point${CL}" + if [[ "$is_unprivileged" == "1" ]]; then + echo -e "${TAB}${YW}$(translate "Unprivileged container — UID offset:") ${uid_shift}${CL}" + else + echo -e "${TAB}${DGN}$(translate "Privileged container — direct root access")${CL}" + fi echo "" - if [[ "$is_unprivileged" == "1" ]]; then - local mapped_uid="$uid_shift" - echo -e "${TAB}${YW}$(translate "UNPRIVILEGED container — UID mapping active:")${CL}" - echo -e "${TAB} $(translate "Container UID 0") → $(translate "Host UID") $mapped_uid" - echo -e "${TAB} $(translate "If access fails, run on the host:")" - echo -e "${TAB} ${DGN}setfacl -m u:${mapped_uid}:rwx \"$host_dir\"${CL}" - echo -e "${TAB} ${DGN}setfacl -d:m u:${mapped_uid}:rwx \"$host_dir\"${CL}" - else - echo -e "${TAB}${DGN}$(translate "PRIVILEGED container — direct UID mapping")${CL}" - echo -e "${TAB} $(translate "Ensure") $host_dir $(translate "is accessible by root (chmod 755 or wider)")" - fi - - # Step 8: Offer restart + # Step 10: Offer restart echo "" if whiptail --yesno "$(translate "Restart container to activate mount?")" 8 60; then msg_info "$(translate "Restarting container...")" diff --git a/scripts/share/nfs_host.sh b/scripts/share/nfs_host.sh index 0a96ebb5..6deada4c 100644 --- a/scripts/share/nfs_host.sh +++ b/scripts/share/nfs_host.sh @@ -253,7 +253,7 @@ add_proxmox_nfs_storage() { fi msg_ok "$(translate "Storage ID is available")" - + msg_info "$(translate "NFS storage adding in progress...")" if pvesm_output=$(pvesm add nfs "$storage_id" \ --server "$server" \ --export "$export" \ diff --git a/scripts/storage/add_controller_nvme_vm.sh b/scripts/storage/add_controller_nvme_vm.sh index 642b1da5..3b4c6644 100644 --- a/scripts/storage/add_controller_nvme_vm.sh +++ b/scripts/storage/add_controller_nvme_vm.sh @@ -14,6 +14,7 @@ LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" +TOOLS_JSON="$BASE_DIR/installed_tools.json" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" @@ -51,22 +52,45 @@ SELECTED_VMID="" SELECTED_VM_NAME="" declare -a SELECTED_CONTROLLER_PCIS=() IOMMU_PENDING_REBOOT=0 +IOMMU_ALREADY_ACTIVE=0 +NEED_HOOK_SYNC=false +WIZARD_CONFLICT_POLICY="" +WIZARD_CONFLICT_SCOPE="" set_title() { show_proxmenux_logo msg_title "$(translate "Add Controller or NVMe PCIe to VM")" } +ensure_tools_json() { + [[ -f "$TOOLS_JSON" ]] || echo "{}" > "$TOOLS_JSON" +} + +register_tool() { + local tool="$1" + local state="$2" + command -v jq >/dev/null 2>&1 || return 0 + ensure_tools_json + jq --arg t "$tool" --argjson v "$state" \ + '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" \ + && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON" +} + +register_vfio_iommu_tool() { + register_tool "vfio_iommu" true || true +} + enable_iommu_cmdline() { + local silent="${1:-}" local cpu_vendor iommu_param cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') if [[ "$cpu_vendor" == "GenuineIntel" ]]; then iommu_param="intel_iommu=on" - msg_info "$(translate "Intel CPU detected")" + [[ "$silent" != "silent" ]] && msg_info "$(translate "Intel CPU detected")" elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then iommu_param="amd_iommu=on" - msg_info "$(translate "AMD CPU detected")" + [[ "$silent" != "silent" ]] && msg_info "$(translate "AMD CPU detected")" else msg_error "$(translate "Unknown CPU vendor. Cannot determine IOMMU parameter.")" return 1 @@ -76,22 +100,22 @@ enable_iommu_cmdline() { local grub_file="/etc/default/grub" if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then - if ! grep -q "$iommu_param" "$cmdline_file"; then + if ! grep -q "$iommu_param" "$cmdline_file" || ! grep -q "iommu=pt" "$cmdline_file"; then cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file" proxmox-boot-tool refresh >/dev/null 2>&1 || true - msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")" + [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")" else - msg_ok "$(translate "IOMMU already configured in /etc/kernel/cmdline")" + [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU already configured in /etc/kernel/cmdline")" fi elif [[ -f "$grub_file" ]]; then - if ! grep -q "$iommu_param" "$grub_file"; then + if ! grep -q "$iommu_param" "$grub_file" || ! grep -q "iommu=pt" "$grub_file"; then cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file" update-grub >/dev/null 2>&1 || true - msg_ok "$(translate "IOMMU parameters added to GRUB")" + [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to GRUB")" else - msg_ok "$(translate "IOMMU already configured in GRUB")" + [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU already configured in GRUB")" fi else msg_error "$(translate "Neither /etc/kernel/cmdline nor /etc/default/grub found.")" @@ -101,24 +125,29 @@ enable_iommu_cmdline() { check_iommu_or_offer_enable() { if [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then + register_vfio_iommu_tool return 0 fi if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \ grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then IOMMU_PENDING_REBOOT=1 - msg_warn "$(translate "IOMMU is configured for next boot, but not active yet.")" - msg_info2 "$(translate "Controller/NVMe assignment can continue now and will be effective after reboot.")" + register_vfio_iommu_tool + return 0 fi if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then + IOMMU_ALREADY_ACTIVE=1 + register_vfio_iommu_tool return 0 fi if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \ [[ -d /sys/kernel/iommu_groups ]] && \ [[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then + IOMMU_ALREADY_ACTIVE=1 + register_vfio_iommu_tool return 0 fi @@ -133,13 +162,11 @@ check_iommu_or_offer_enable() { --title "$(translate "IOMMU Required")" \ --yesno "$msg" 15 74 local response=$? - clear [[ $response -ne 0 ]] && return 1 set_title msg_title "$(translate "Enabling IOMMU")" - echo if ! enable_iommu_cmdline; then echo msg_error "$(translate "Failed to configure IOMMU automatically.")" @@ -148,37 +175,36 @@ check_iommu_or_offer_enable() { return 1 fi - echo - msg_success "$(translate "IOMMU configured. Reboot required before using Controller/NVMe passthrough.")" - echo - if whiptail --title "$(translate "Reboot Required")" \ - --yesno "$(translate "Do you want to reboot now?")" 10 64; then - msg_warn "$(translate "Rebooting the system...")" - reboot - else - IOMMU_PENDING_REBOOT=1 - msg_warn "$(translate "Reboot postponed by user.")" - msg_info2 "$(translate "You can continue assigning Controller/NVMe now, but reboot the host before starting the VM.")" - msg_success "$(translate "Press Enter to continue...")" - read -r - fi + register_vfio_iommu_tool + IOMMU_PENDING_REBOOT=1 return 0 } select_target_vm() { local -a vm_menu=() local line vmid vmname vmstatus vm_machine status_label + local max_name_len=0 padded_name + + while IFS= read -r line; do + vmid=$(awk '{print $1}' <<< "$line") + vmname=$(awk '{print $2}' <<< "$line") + [[ -z "$vmid" || "$vmid" == "VMID" ]] && continue + [[ -f "/etc/pve/qemu-server/${vmid}.conf" ]] || continue + [[ ${#vmname} -gt $max_name_len ]] && max_name_len=${#vmname} + done < <(qm list 2>/dev/null) while IFS= read -r line; do vmid=$(awk '{print $1}' <<< "$line") vmname=$(awk '{print $2}' <<< "$line") vmstatus=$(awk '{print $3}' <<< "$line") [[ -z "$vmid" || "$vmid" == "VMID" ]] && continue + [[ -f "/etc/pve/qemu-server/${vmid}.conf" ]] || continue vm_machine=$(qm config "$vmid" 2>/dev/null | awk -F': ' '/^machine:/ {print $2}') [[ -z "$vm_machine" ]] && vm_machine="unknown" status_label="${vmstatus}, ${vm_machine}" - vm_menu+=("$vmid" "${vmname} [${status_label}]") + printf -v padded_name "%-${max_name_len}s" "$vmname" + vm_menu+=("$vmid" "${padded_name} [${status_label}]") done < <(qm list 2>/dev/null) if [[ ${#vm_menu[@]} -eq 0 ]]; then dialog --backtitle "ProxMenux" \ @@ -221,6 +247,10 @@ validate_vm_requirements() { } select_controller_nvme() { + # Show progress during potentially slow PCIe + disk detection + set_title + msg_info "$(translate "Analyzing system for available PCIe storage devices...")" + _refresh_host_storage_cache local -a menu_items=() @@ -251,12 +281,19 @@ select_controller_nvme() { _array_contains "$disk" "${controller_disks[@]}" || controller_disks+=("$disk") done < <(_controller_block_devices "$pci_full") + # blocked_reasons: system disk OR disk in RUNNING guest → hide controller + # warn_reasons: disk in STOPPED guest only → show with ⚠ but allow selection local -a blocked_reasons=() + local -a warn_reasons=() for disk in "${controller_disks[@]}"; do if _disk_is_host_system_used "$disk"; then blocked_reasons+=("${disk} (${DISK_USAGE_REASON})") elif _disk_used_in_guest_configs "$disk"; then - blocked_reasons+=("${disk} ($(translate "In use by VM/LXC config"))") + if _disk_used_in_running_guest "$disk"; then + blocked_reasons+=("${disk} ($(translate "In use by running VM/LXC — stop it first"))") + else + warn_reasons+=("$disk") + fi fi done @@ -266,21 +303,30 @@ select_controller_nvme() { continue fi - local short_name - short_name=$(_shorten_text "$name" 42) + local short_name display_name + display_name=$(_pci_storage_display_name "$pci_full") + short_name=$(_shorten_text "$display_name" 56) local assigned_suffix="" if [[ -n "$(_pci_assigned_vm_ids "$pci_full" "$SELECTED_VMID" 2>/dev/null | head -1)" ]]; then assigned_suffix=" | $(translate "Assigned to VM")" fi - controller_desc="${short_name}${assigned_suffix}" + # Warn if some disks are referenced in stopped VM/CT configs + local warn_suffix="" + if [[ ${#warn_reasons[@]} -gt 0 ]]; then + warn_suffix=" ⚠" + fi + + controller_desc="${short_name}${assigned_suffix}${warn_suffix}" state="off" menu_items+=("$pci_full" "$controller_desc" "$state") safe_count=$((safe_count + 1)) done < <(ls -d /sys/bus/pci/devices/* 2>/dev/null | sort) + stop_spinner + if [[ "$safe_count" -eq 0 ]]; then local msg if [[ "$hidden_target_count" -gt 0 && "$blocked_count" -eq 0 ]]; then @@ -318,29 +364,100 @@ select_controller_nvme() { return 1 fi - if declare -F _vm_storage_confirm_controller_passthrough_risk >/dev/null 2>&1; then - if ! _vm_storage_confirm_controller_passthrough_risk "$SELECTED_VMID" "$SELECTED_VM_NAME" "$(translate "Controller + NVMe")"; then - return 1 - fi - fi - return 0 } -confirm_summary() { - local msg - msg="\n$(translate "The following devices will be added to VM") ${SELECTED_VMID} (${SELECTED_VM_NAME}):\n\n" - local pci info - for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do - info=$(lspci -nn -s "${pci#0000:}" 2>/dev/null | sed 's/^[^ ]* //') - msg+=" - ${pci}${info:+ (${info})}\n" - done - msg+="\n$(translate "Do you want to continue?")" +_prompt_raw_disk_conflict_policy() { + local disk="$1" + shift + local -a guest_ids=("$@") + local msg gid gtype gid_num gname gstatus - dialog --backtitle "ProxMenux" --colors \ + msg="$(translate "Disk") ${disk} $(translate "is referenced in the following stopped VM(s)/CT(s):")\\n\\n" + for gid in "${guest_ids[@]}"; do + gtype="${gid%%:*}"; gid_num="${gid##*:}" + if [[ "$gtype" == "VM" ]]; then + gname=$(_vm_name_by_id "$gid_num") + gstatus=$(qm status "$gid_num" 2>/dev/null | awk '{print $2}') + msg+=" - VM $gid_num ($gname) [${gstatus}]\\n" + else + gname=$(pct config "$gid_num" 2>/dev/null | awk '/^hostname:/ {print $2}') + [[ -z "$gname" ]] && gname="CT-$gid_num" + gstatus=$(pct status "$gid_num" 2>/dev/null | awk '{print $2}') + msg+=" - CT $gid_num ($gname) [${gstatus}]\\n" + fi + done + msg+="\\n$(translate "Choose action:")" + + local choice + choice=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Disk Reference Conflict")" \ + --menu "$msg" 22 84 3 \ + "1" "$(translate "Disable onboot on affected VM(s)/CT(s)")" \ + "2" "$(translate "Remove disk references from affected VM(s)/CT(s) config")" \ + "3" "$(translate "Skip — leave as-is")" \ + 2>&1 >/dev/tty) || { echo "skip"; return; } + + case "$choice" in + 1) echo "disable_onboot" ;; + 2) echo "remove_refs" ;; + *) echo "skip" ;; + esac +} + +confirm_summary() { + # ── Risk detection ───────────────────────────────────────────────────────── + local reinforce_limited_firmware="no" + local bios_date bios_year current_year bios_age cpu_model risk_detail="" + bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null) + bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1) + current_year=$(date +%Y 2>/dev/null) + if [[ -n "$bios_year" && -n "$current_year" ]]; then + bios_age=$(( current_year - bios_year )) + if (( bios_age >= 7 )); then + reinforce_limited_firmware="yes" + risk_detail="$(translate "BIOS from") ${bios_year} (${bios_age} $(translate "years old")) — $(translate "older firmware may increase passthrough instability")" + fi + fi + cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs) + if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then + reinforce_limited_firmware="yes" + [[ -z "$risk_detail" ]] && risk_detail="$(translate "Low-power CPU platform"): ${cpu_model}" + fi + + # ── Build unified message ────────────────────────────────────────────────── + local msg pci display_name + msg="\n" + + # Devices to add + msg+="\Zb$(translate "Devices to add to VM") ${SELECTED_VMID} (${SELECTED_VM_NAME}):\Zn\n" + for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do + display_name=$(_pci_storage_display_name "$pci") + msg+=" \Zb•\Zn ${pci} ${display_name}\n" + done + msg+="\n" + + # Compatibility notice (always shown) + msg+="\Zb\Z4⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\Zn\n\n" + msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n" + msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n" + + # Detected risk (only when applicable) + if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then + msg+="\n\Z1$(translate "Detected risk factor"): ${risk_detail}\Zn\n" + fi + + msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${SELECTED_VMID}.conf\n" + msg+="\n\Zb$(translate "Do you want to continue?")\Zn" + + local height=22 + [[ "$reinforce_limited_firmware" == "yes" ]] && height=25 + + if ! dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Confirm Controller + NVMe Assignment")" \ - --yesno "$msg" 18 90 - [[ $? -ne 0 ]] && return 1 + --yesno "$msg" $height 90; then + return 1 + fi return 0 } @@ -349,7 +466,7 @@ prompt_controller_conflict_policy() { shift local -a source_vms=("$@") local msg vmid vm_name st ob - msg="$(translate "Selected device is already assigned to other VM(s):")\n\n" + msg="\n$(translate "Selected device is already assigned to other VM(s):")\n\n" for vmid in "${source_vms[@]}"; do vm_name=$(_vm_name_by_id "$vmid") st="stopped"; _vm_status_is_running "$vmid" && st="running" @@ -359,11 +476,13 @@ prompt_controller_conflict_policy() { msg+="\n$(translate "Choose action for this controller/NVMe:")" local choice - choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ + choice=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Controller/NVMe Conflict Policy")" \ + --menu "$msg" 20 80 10 \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \ "3" "$(translate "Skip this device")" \ - 3>&1 1>&2 2>&3) || { echo "skip"; return; } + 2>&1 >/dev/tty) || { echo "skip"; return; } case "$choice" in 1) echo "keep_disable_onboot" ;; @@ -372,17 +491,143 @@ prompt_controller_conflict_policy() { esac } +# ── DIALOG PHASE: resolve all conflicts before terminal ─────────────────────── +resolve_disk_conflicts() { + local -a new_pci_list=() + local pci vmid action slot_base scope_key has_running + + # ── hostpci conflicts: controller already assigned to another VM ────────── + for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do + local -a source_vms=() + mapfile -t source_vms < <(_pci_assigned_vm_ids "$pci" "$SELECTED_VMID" 2>/dev/null) + + if [[ ${#source_vms[@]} -eq 0 ]]; then + new_pci_list+=("$pci") + continue + fi + + has_running=false + for vmid in "${source_vms[@]}"; do + if _vm_status_is_running "$vmid"; then + has_running=true + dialog --backtitle "ProxMenux" \ + --title "$(translate "Device In Use")" \ + --msgbox "\n$(translate "Controller") $pci $(translate "is in use by running VM") $vmid.\n\n$(translate "Stop it first and run this option again.")" \ + 10 72 + break + fi + done + $has_running && continue + + scope_key=$(printf '%s,' "${source_vms[@]}") + if [[ -n "$WIZARD_CONFLICT_POLICY" && "$WIZARD_CONFLICT_SCOPE" == "$scope_key" ]]; then + action="$WIZARD_CONFLICT_POLICY" + else + action=$(prompt_controller_conflict_policy "$pci" "${source_vms[@]}") + WIZARD_CONFLICT_POLICY="$action" + WIZARD_CONFLICT_SCOPE="$scope_key" + fi + + case "$action" in + keep_disable_onboot) + for vmid in "${source_vms[@]}"; do + _vm_onboot_is_enabled "$vmid" && qm set "$vmid" -onboot 0 >/dev/null 2>&1 + done + NEED_HOOK_SYNC=true + new_pci_list+=("$pci") + ;; + move_remove_source) + slot_base=$(_pci_slot_base "$pci") + for vmid in "${source_vms[@]}"; do + _remove_pci_slot_from_vm_config "$vmid" "$slot_base" + done + new_pci_list+=("$pci") + ;; + *) ;; # skip — do not add to new_pci_list + esac + done + + SELECTED_CONTROLLER_PCIS=("${new_pci_list[@]}") + + if [[ ${#SELECTED_CONTROLLER_PCIS[@]} -eq 0 ]]; then + dialog --backtitle "ProxMenux" \ + --title "$(translate "Controller + NVMe")" \ + --msgbox "\n$(translate "No controllers remaining after conflict resolution.")" 8 64 + return 1 + fi + + # ── Raw disk passthrough conflicts ─────────────────────────────────────── + local raw_disk_policy="" raw_disk_scope="" + for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do + local -a cdisks=() + while IFS= read -r disk; do + [[ -z "$disk" ]] && continue + _array_contains "$disk" "${cdisks[@]}" || cdisks+=("$disk") + done < <(_controller_block_devices "$pci") + + for disk in "${cdisks[@]}"; do + _disk_used_in_guest_configs "$disk" || continue + _disk_used_in_running_guest "$disk" && continue + + local -a guest_ids=() + mapfile -t guest_ids < <(_disk_guest_ids "$disk") + [[ ${#guest_ids[@]} -eq 0 ]] && continue + + local gscope gaction + gscope=$(printf '%s,' "${guest_ids[@]}") + if [[ -n "$raw_disk_policy" && "$raw_disk_scope" == "$gscope" ]]; then + gaction="$raw_disk_policy" + else + gaction=$(_prompt_raw_disk_conflict_policy "$disk" "${guest_ids[@]}") + raw_disk_policy="$gaction" + raw_disk_scope="$gscope" + fi + + local gid gtype gid_num slot + case "$gaction" in + disable_onboot) + for gid in "${guest_ids[@]}"; do + gtype="${gid%%:*}"; gid_num="${gid##*:}" + if [[ "$gtype" == "VM" ]]; then + _vm_onboot_is_enabled "$gid_num" && qm set "$gid_num" -onboot 0 >/dev/null 2>&1 + else + grep -qE '^onboot:\s*1' "/etc/pve/lxc/$gid_num.conf" 2>/dev/null && \ + pct set "$gid_num" -onboot 0 >/dev/null 2>&1 + fi + done + ;; + remove_refs) + for gid in "${guest_ids[@]}"; do + gtype="${gid%%:*}"; gid_num="${gid##*:}" + if [[ "$gtype" == "VM" ]]; then + while IFS= read -r slot; do + [[ -z "$slot" ]] && continue + qm set "$gid_num" -delete "$slot" >/dev/null 2>&1 + done < <(_find_disk_slots_in_vm "$gid_num" "$disk") + else + while IFS= read -r slot; do + [[ -z "$slot" ]] && continue + pct set "$gid_num" -delete "$slot" >/dev/null 2>&1 + done < <(_find_disk_slots_in_ct "$gid_num" "$disk") + fi + done + ;; + esac + done + done + + return 0 +} + apply_assignment() { : >"$LOG_FILE" set_title - echo msg_info "$(translate "Applying Controller/NVMe passthrough to VM") ${SELECTED_VMID}..." msg_ok "$(translate "Target VM validated") (${SELECTED_VM_NAME} / ${SELECTED_VMID})" msg_ok "$(translate "Selected devices"): ${#SELECTED_CONTROLLER_PCIS[@]}" local hostpci_idx=0 - msg_info "$(translate "Calculating next available hostpci slot...")" if declare -F _pci_next_hostpci_index >/dev/null 2>&1; then hostpci_idx=$(_pci_next_hostpci_index "$SELECTED_VMID" 2>/dev/null || echo 0) else @@ -392,10 +637,8 @@ apply_assignment() { hostpci_idx=$((hostpci_idx + 1)) done fi - msg_ok "$(translate "Next available hostpci slot"): hostpci${hostpci_idx}" local pci bdf assigned_count=0 - local need_hook_sync=false for pci in "${SELECTED_CONTROLLER_PCIS[@]}"; do bdf="${pci#0000:}" if declare -F _pci_function_assigned_to_vm >/dev/null 2>&1; then @@ -408,50 +651,11 @@ apply_assignment() { continue fi - local -a source_vms=() - mapfile -t source_vms < <(_pci_assigned_vm_ids "$pci" "$SELECTED_VMID" 2>/dev/null) - if [[ ${#source_vms[@]} -gt 0 ]]; then - local has_running=false vmid action slot_base - for vmid in "${source_vms[@]}"; do - if _vm_status_is_running "$vmid"; then - has_running=true - msg_warn "$(translate "Controller/NVMe is in use by running VM") ${vmid} ($(translate "stop source VM first"))" - fi - done - - if $has_running; then - continue - fi - - action=$(prompt_controller_conflict_policy "$pci" "${source_vms[@]}") - case "$action" in - keep_disable_onboot) - for vmid in "${source_vms[@]}"; do - if _vm_onboot_is_enabled "$vmid"; then - if qm set "$vmid" -onboot 0 >>"$LOG_FILE" 2>&1; then - msg_warn "$(translate "Start on boot disabled for VM") ${vmid}" - fi - fi - done - need_hook_sync=true - ;; - move_remove_source) - slot_base=$(_pci_slot_base "$pci") - for vmid in "${source_vms[@]}"; do - if _remove_pci_slot_from_vm_config "$vmid" "$slot_base"; then - msg_ok "$(translate "Controller/NVMe removed from source VM") ${vmid} (${pci})" - fi - done - ;; - *) - msg_info2 "$(translate "Skipped device"): ${pci}" - continue - ;; - esac - fi - - if qm set "$SELECTED_VMID" --hostpci${hostpci_idx} "${pci},pcie=1" >>"$LOG_FILE" 2>&1; then - msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx} -> ${pci})" + local display_name + display_name=$(_pci_storage_display_name "$pci") + msg_info "$(translate "Adding") ${display_name} (${pci}) → hostpci${hostpci_idx}..." + if qm set "$SELECTED_VMID" "--hostpci${hostpci_idx}" "${pci},pcie=1" >>"$LOG_FILE" 2>&1; then + msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx} → ${pci})" assigned_count=$((assigned_count + 1)) hostpci_idx=$((hostpci_idx + 1)) else @@ -459,33 +663,50 @@ apply_assignment() { fi done - if $need_hook_sync && declare -F sync_proxmenux_gpu_guard_hooks >/dev/null 2>&1; then + if $NEED_HOOK_SYNC && declare -F sync_proxmenux_gpu_guard_hooks >/dev/null 2>&1; then ensure_proxmenux_gpu_guard_hookscript sync_proxmenux_gpu_guard_hooks msg_ok "$(translate "VM hook guard synced for shared controller/NVMe protection")" fi - echo + echo "" echo -e "${TAB}${BL}Log: ${LOG_FILE}${CL}" if [[ "$assigned_count" -gt 0 ]]; then - msg_success "$(translate "Completed. Controller/NVMe passthrough configured for VM") ${SELECTED_VMID}." - if [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then - msg_warn "$(translate "IOMMU was configured during this run. Reboot the host before starting the VM.")" - fi + msg_ok "$(translate "Completed.") $assigned_count $(translate "device(s) added to VM") ${SELECTED_VMID}." else msg_warn "$(translate "No new Controller/NVMe entries were added.")" fi + + if [[ "${IOMMU_ALREADY_ACTIVE:-0}" == "1" ]]; then + msg_ok "$(translate "IOMMU is enabled on the system")" + elif [[ "${IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then + msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")" + echo "" + if whiptail --title "$(translate "Reboot Required")" \ + --yesno "\n$(translate "IOMMU has been enabled on this system. A reboot is required to apply the changes. Reboot now?")" 11 64; then + msg_success "$(translate "Press Enter to continue...")" + read -r + msg_warn "$(translate "Rebooting the system...")" + reboot + else + msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")" + msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")" + fi + fi + echo "" msg_success "$(translate "Press Enter to continue...")" read -r } main() { - select_target_vm || exit 0 + export WIZARD_CONFLICT_POLICY + export WIZARD_CONFLICT_SCOPE + select_target_vm || exit 0 validate_vm_requirements || exit 0 - select_controller_nvme || exit 0 - confirm_summary || exit 0 - clear + select_controller_nvme || exit 0 + resolve_disk_conflicts || exit 0 + confirm_summary || exit 0 apply_assignment } diff --git a/scripts/storage/disk-passthrough.sh b/scripts/storage/disk-passthrough.sh index 78b6c59f..1b01a07d 100644 --- a/scripts/storage/disk-passthrough.sh +++ b/scripts/storage/disk-passthrough.sh @@ -6,8 +6,8 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.0 -# Last Updated: 28/01/2025 +# Version : 1.2 +# Last Updated: 12/04/2026 # ========================================================== # Description: # This script allows users to assign physical disks to existing @@ -20,6 +20,7 @@ # - Ensures that disks are not already assigned to active VMs. # - Warns about disk sharing between multiple VMs to avoid data corruption. # - Configures the selected disks for the VM and verifies the assignment. +# - Prefers persistent /dev/disk/by-id paths for assignment when available. # # The goal of this script is to simplify the process of assigning # physical disks to Proxmox VMs, reducing manual configurations @@ -28,134 +29,181 @@ # Configuration ============================================ -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" -VENV_PATH="/opt/googletrans-env" +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi + +# shellcheck source=/dev/null if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi + +# shellcheck source=/dev/null +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" +fi + +BACKTITLE="ProxMenux" +UI_MENU_H=20 +UI_MENU_W=84 +UI_MENU_LIST_H=10 +UI_SHORT_MENU_H=16 +UI_SHORT_MENU_W=72 +UI_SHORT_MENU_LIST_H=6 +UI_MSG_H=10 +UI_MSG_W=72 +UI_YESNO_H=18 +UI_YESNO_W=86 +UI_RESULT_H=18 +UI_RESULT_W=86 + load_language initialize_cache -# ========================================================== +if ! command -v pveversion >/dev/null 2>&1; then + dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \ + --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 + exit 1 +fi +# ========================================================== get_disk_info() { local disk=$1 - MODEL=$(lsblk -dn -o MODEL "$disk" | xargs) - SIZE=$(lsblk -dn -o SIZE "$disk" | xargs) - echo "$MODEL" "$SIZE" + local model size + model=$(lsblk -dn -o MODEL "$disk" | xargs) + size=$(lsblk -dn -o SIZE "$disk" | xargs) + [[ -z "$model" ]] && model="Unknown" + printf '%s\t%s\n' "$model" "$size" +} + +get_all_disk_paths() { + local disk="$1" + local real_path + real_path=$(readlink -f "$disk" 2>/dev/null) + + [[ -n "$disk" ]] && echo "$disk" + [[ -n "$real_path" ]] && echo "$real_path" + + local link + for link in /dev/disk/by-id/* /dev/disk/by-path/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + echo "$link" + done | sort -u +} + +get_preferred_disk_path() { + local disk="$1" + local real_path + real_path=$(readlink -f "$disk" 2>/dev/null) + [[ -z "$real_path" ]] && { echo "$disk"; return 0; } + + local best="" best_score=99999 + local link name score + for link in /dev/disk/by-id/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + name=$(basename "$link") + [[ "$name" == *-part* ]] && continue + + case "$name" in + ata-*|scsi-*|nvme-*) score=100 ;; + wwn-*) score=200 ;; + *) score=300 ;; + esac + score=$((score + ${#name})) + if (( score < best_score )); then + best="$link" + best_score=$score + fi + done + + if [[ -n "$best" ]]; then + echo "$best" + else + echo "$disk" + fi +} + +disk_referenced_in_config() { + local config_text="$1" + local disk="$2" + local alias + while read -r alias; do + [[ -z "$alias" ]] && continue + if grep -Fq "$alias" <<< "$config_text"; then + return 0 + fi + done < <(get_all_disk_paths "$disk") + return 1 } +# ── DIALOG PHASE ────────────────────────────────────────────────────────────── + VM_LIST=$(qm list | awk 'NR>1 {print $1, $2}') if [ -z "$VM_LIST" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No VMs available in the system.")" 8 40 + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No VMs available in the system.")" $UI_MSG_H $UI_MSG_W exit 1 fi - -VMID=$(whiptail --title "$(translate "Select VM")" --menu "$(translate "Select the VM to which you want to add disks:")" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3) +# shellcheck disable=SC2086 +VMID=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select VM")" \ + --menu "$(translate "Select the VM to which you want to add disks:")" $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + $VM_LIST \ + 2>&1 >/dev/tty) if [ -z "$VMID" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No VM was selected.")" 8 40 + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No VM was selected.")" $UI_MSG_H $UI_MSG_W exit 1 fi VMID=$(echo "$VMID" | tr -d '"') -clear -show_proxmenux_logo -echo -e -msg_title "$(translate "Import Disk to VM")" -echo -e - -msg_ok "$(translate "VM selected successfully.")" - - VM_STATUS=$(qm status "$VMID" | awk '{print $2}') if [ "$VM_STATUS" == "running" ]; then - whiptail --title "$(translate "Warning")" --msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" 12 60 + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Warning")" \ + --msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" $UI_MSG_H $UI_MSG_W exit 1 fi -########################################## - +# ── TERMINAL PHASE 1: detect disks ──────────────────────────────────────────── +show_proxmenux_logo +msg_title "$(translate "Import Disk to VM")" +msg_ok "$(translate "VM $VMID selected successfully.")" msg_info "$(translate "Detecting available disks...")" -USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}') -MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') - -ZFS_DISKS="" -ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') - -for entry in $ZFS_RAW; do - - path="" - if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then - if [ -e "/dev/disk/by-id/$entry" ]; then - path=$(readlink -f "/dev/disk/by-id/$entry") - fi - elif [[ "$entry" == /dev/* ]]; then - path="$entry" - fi - - - if [ -n "$path" ]; then - base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) - if [ -n "$base_disk" ]; then - ZFS_DISKS+="/dev/$base_disk"$'\n' - fi - fi -done - -ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) - - -is_disk_in_use() { - local disk="$1" - - - while read -r part fstype; do - case "$fstype" in - zfs_member|linux_raid_member) - return 0 ;; - esac - - if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then - return 0 - fi - done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2) - - - if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then - return 0 - fi - - return 1 -} - - - +_refresh_host_storage_cache +VM_CONFIG=$(qm config "$VMID" 2>/dev/null | grep -vE '^\s*#|^description:') FREE_DISKS=() -LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u) -RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u) - while read -r DISK; do - [[ "$DISK" =~ /dev/zd ]] && continue - INFO=($(get_disk_info "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" + IFS=$'\t' read -r MODEL SIZE < <(get_disk_info "$DISK") LABEL="" SHOW_DISK=true - IS_MOUNTED=false IS_RAID=false IS_ZFS=false @@ -175,46 +223,34 @@ while read -r DISK; do IS_MOUNTED=true fi - - USED_BY="" - REAL_PATH=$(readlink -f "$DISK") - CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) - - if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then + if _disk_used_in_guest_configs "$DISK"; then USED_BY="⚠ $(translate "In use")" - else - for SYMLINK in /dev/disk/by-id/*; do - if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then - if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - break - fi - fi - done fi - - - if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then if grep -q "active raid" /proc/mdstat; then SHOW_DISK=false fi fi - if $IS_ZFS; then SHOW_DISK=false fi + # Catch whole-disk ZFS vdevs with no partitions (e.g. bare NVMe ZFS) + # The tail -n +2 trick misses them; ZFS_DISKS from _refresh_host_storage_cache covers them. + if [[ -n "$ZFS_DISKS" ]] && \ + { grep -qFx "$DISK" <<< "$ZFS_DISKS" || \ + { [[ -n "$REAL_PATH" ]] && grep -qFx "$REAL_PATH" <<< "$ZFS_DISKS"; }; }; then + SHOW_DISK=false + fi if $IS_MOUNTED; then SHOW_DISK=false fi - - if qm config "$VMID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then + if disk_referenced_in_config "$VM_CONFIG" "$DISK"; then SHOW_DISK=false fi @@ -229,144 +265,188 @@ while read -r DISK; do fi done < <(lsblk -dn -e 7,11 -o PATH) - - if [ "${#FREE_DISKS[@]}" -eq 0 ]; then - cleanup - whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this VM.")" 8 40 - clear + stop_spinner + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No disks available for this VM.")" $UI_MSG_H $UI_MSG_W exit 1 fi +stop_spinner msg_ok "$(translate "Available disks detected.")" - -###################################################### - - - +# ── DIALOG PHASE: select disks + interface ──────────────────────────────────── MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1) TOTAL_WIDTH=$((MAX_WIDTH + 20)) - -if [ $TOTAL_WIDTH -lt 50 ]; then - TOTAL_WIDTH=50 +if [ $TOTAL_WIDTH -lt $UI_MENU_W ]; then + TOTAL_WIDTH=$UI_MENU_W +fi +if [ $TOTAL_WIDTH -gt 116 ]; then + TOTAL_WIDTH=116 fi - -SELECTED=$(whiptail --title "$(translate "Select Disks")" --checklist \ - "$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) +SELECTED=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select Disks")" \ + --checklist "\n$(translate "Select the disks you want to add:")" $UI_MENU_H $TOTAL_WIDTH $UI_MENU_LIST_H \ + "${FREE_DISKS[@]}" \ + 2>&1 >/dev/tty) if [ -z "$SELECTED" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64 - clear + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No disks were selected.")" $UI_MSG_H $UI_MSG_W exit 1 fi -msg_ok "$(translate "Disks selected successfully.")" - - -INTERFACE=$(whiptail --title "$(translate "Interface Type")" --menu "$(translate "Select the interface type for all disks:")" 15 40 4 \ - "sata" "$(translate "Add as SATA")" \ - "scsi" "$(translate "Add as SCSI")" \ +INTERFACE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Interface Type")" \ + --menu "$(translate "Select the interface type for all disks:")" $UI_SHORT_MENU_H $UI_SHORT_MENU_W $UI_SHORT_MENU_LIST_H \ + "sata" "$(translate "Add as SATA")" \ + "scsi" "$(translate "Add as SCSI")" \ "virtio" "$(translate "Add as VirtIO")" \ - "ide" "$(translate "Add as IDE")" 3>&1 1>&2 2>&3) + "ide" "$(translate "Add as IDE")" \ + 2>&1 >/dev/tty) if [ -z "$INTERFACE" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No interface type was selected for the disks.")" 8 40 - clear + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No interface type was selected for the disks.")" $UI_MSG_H $UI_MSG_W exit 1 fi -msg_ok "$(translate "Interface type selected: $INTERFACE")" -DISKS_ADDED=0 -ERROR_MESSAGES="" -SUCCESS_MESSAGES="" - - - -msg_info "$(translate "Processing selected disks...")" +# ── DIALOG PHASE: per-disk pre-check ────────────────────────────────────────── +declare -a DISK_LIST=() +declare -a DISK_DESCRIPTIONS=() +declare -a DISK_ASSIGNED_TOS=() +declare -a NVME_SKIPPED=() for DISK in $SELECTED; do - DISK=$(echo "$DISK" | tr -d '"') + DISK="${DISK//\"/}" DISK_INFO=$(get_disk_info "$DISK") ASSIGNED_TO="" RUNNING_VMS="" RUNNING_CTS="" - while read -r VM_ID VM_NAME; do - if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then + VM_CONFIG_RAW=$(qm config "$VM_ID" 2>/dev/null) + if [[ "$VM_ID" =~ ^[0-9]+$ ]] && disk_referenced_in_config "$VM_CONFIG_RAW" "$DISK"; then ASSIGNED_TO+="VM $VM_ID $VM_NAME\n" - VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}') - if [ "$VM_STATUS" == "running" ]; then - RUNNING_VMS+="VM $VM_ID $VM_NAME\n" - fi + VM_STATUS_CHK=$(qm status "$VM_ID" | awk '{print $2}') + [[ "$VM_STATUS_CHK" == "running" ]] && RUNNING_VMS+="VM $VM_ID $VM_NAME\n" fi done < <(qm list | awk 'NR>1 {print $1, $2}') - while read -r CT_ID CT_NAME; do - if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then + CT_CONFIG_RAW=$(pct config "$CT_ID" 2>/dev/null) + if [[ "$CT_ID" =~ ^[0-9]+$ ]] && disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"; then ASSIGNED_TO+="CT $CT_ID $CT_NAME\n" - CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}') - if [ "$CT_STATUS" == "running" ]; then - RUNNING_CTS+="CT $CT_ID $CT_NAME\n" - fi + CT_STATUS_CHK=$(pct status "$CT_ID" | awk '{print $2}') + [[ "$CT_STATUS_CHK" == "running" ]] && RUNNING_CTS+="CT $CT_ID $CT_NAME\n" fi - done < <(pct list | awk 'NR>1 {print $1, $2}') + done < <(pct list | awk 'NR>1 {print $1, $3}') if [ -n "$RUNNING_VMS" ] || [ -n "$RUNNING_CTS" ]; then - ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is currently in use by the following running VM(s) or CT(s):")\\n$RUNNING_VMS$RUNNING_CTS\\n\\n$(translate "You cannot add this disk while the VM or CT is running.")\\n$(translate "Please shut it down first and run this script again to add the disk.")\\n\\n" + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Disk In Use")" \ + --msgbox "$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s) or CT(s):")\\n$RUNNING_VMS$RUNNING_CTS\\n\\n$(translate "Stop them first and run this script again.")" $UI_RESULT_H $UI_RESULT_W continue fi if [ -n "$ASSIGNED_TO" ]; then - cleanup - whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70 - if [ $? -ne 0 ]; then - sleep 1 - exec "$0" + if ! dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Disk Already Assigned")" \ + --yesno "\n\n$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" $UI_YESNO_H $UI_YESNO_W; then + continue fi fi + # NVMe: suggest PCIe passthrough for better performance + if [[ "$DISK" =~ /dev/nvme ]] || \ + [[ "$(lsblk -dn -o TRAN "$DISK" 2>/dev/null | xargs)" == "nvme" ]]; then + NVME_CHOICE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "NVMe Disk Detected")" \ + --default-item "disk" \ + --menu "\n$(translate "Adding this NVMe as a PCIe device (via 'Add Controller or NVMe PCIe to VM') gives better performance.")\n\n$(translate "How do you want to add it?")" \ + $UI_YESNO_H $UI_YESNO_W 2 \ + "disk" "$(translate "Add as disk (standard)")" \ + "pci" "$(translate "Skip — I will add it as PCIe device")" \ + 2>&1 >/dev/tty) + if [[ "$NVME_CHOICE" == "pci" ]]; then + NVME_SKIPPED+=("$DISK") + continue + fi + fi + + DISK_LIST+=("$DISK") + DISK_DESCRIPTIONS+=("$DISK_INFO") + DISK_ASSIGNED_TOS+=("$ASSIGNED_TO") +done + +if [ "${#DISK_LIST[@]}" -eq 0 ]; then + show_proxmenux_logo + msg_title "$(translate "Import Disk to VM")" + msg_warn "$(translate "No disks were configured for processing.")" + echo "" + msg_success "$(translate "Press Enter to return to menu...")" + read -r + exit 0 +fi + + +# ── TERMINAL PHASE: execute all disk operations ─────────────────────────────── +show_proxmenux_logo +msg_title "$(translate "Import Disk to VM")" +msg_ok "$(translate "VM $VMID selected successfully.")" +msg_ok "$(translate "Disks to process:") ${#DISK_LIST[@]}" +for i in "${!DISK_LIST[@]}"; do + IFS=$'\t' read -r _desc_model _desc_size <<< "${DISK_DESCRIPTIONS[$i]}" + echo -e "${TAB}${BL}${DISK_LIST[$i]} $_desc_model $_desc_size${CL}" +done +if [[ ${#NVME_SKIPPED[@]} -gt 0 ]]; then + echo "" + msg_warn "$(translate "NVMe skipped (to add as PCIe use 'Add Controller or NVMe PCIe to VM'):")" + for _nvme in "${NVME_SKIPPED[@]}"; do + echo -e "${TAB}${BL}${_nvme}${CL}" + done +fi +echo "" +msg_ok "$(translate "Interface type:") $INTERFACE" +echo "" + +DISKS_ADDED=0 + +for i in "${!DISK_LIST[@]}"; do + DISK="${DISK_LIST[$i]}" + ASSIGNED_TO="${DISK_ASSIGNED_TOS[$i]}" + IFS=$'\t' read -r _model _size <<< "${DISK_DESCRIPTIONS[$i]}" INDEX=0 while qm config "$VMID" | grep -q "${INTERFACE}${INDEX}"; do ((INDEX++)) done - RESULT=$(qm set "$VMID" -${INTERFACE}${INDEX} "$DISK" 2>&1) - - if [ $? -eq 0 ]; then - MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to VM") $VMID." - if [ -n "$ASSIGNED_TO" ]; then - MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following VM(s):")\\n$ASSIGNED_TO" - MESSAGE+="\\n$(translate "Make sure not to start VMs that share this disk at the same time to avoid data corruption.")" - fi - SUCCESS_MESSAGES+="$MESSAGE\\n\\n" + ASSIGN_PATH=$(get_preferred_disk_path "$DISK") + msg_info "$(translate "Adding") $_model $_size $(translate "as") ${INTERFACE}${INDEX}..." + if RESULT=$(qm set "$VMID" "-${INTERFACE}${INDEX}" "$ASSIGN_PATH" 2>&1); then + msg_ok "$(translate "Disk added as") ${INTERFACE}${INDEX} $(translate "using") $ASSIGN_PATH" + [[ -n "$ASSIGNED_TO" ]] && msg_warn "$(translate "WARNING: This disk is also assigned to:") $(echo -e "$ASSIGNED_TO" | tr '\n' ' ')" ((DISKS_ADDED++)) else - ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to VM") $VMID.\\n$(translate "Error:") $RESULT\\n\\n" + msg_error "$(translate "Could not add") $_model $_size: $RESULT" fi done -msg_ok "$(translate "Disk processing completed.")" - - - -if [ -n "$SUCCESS_MESSAGES" ]; then - MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l) - whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70 +echo "" +if [ "$DISKS_ADDED" -gt 0 ]; then + msg_ok "$(translate "Completed.") $DISKS_ADDED $(translate "disk(s) added to VM") $VMID." +else + msg_warn "$(translate "No disks were added.")" fi - -if [ -n "$ERROR_MESSAGES" ]; then - whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70 -fi - - - +msg_success "$(translate "Press Enter to return to menu...")" +read -r exit 0 diff --git a/scripts/storage/disk-passthrough_ct.sh b/scripts/storage/disk-passthrough_ct.sh index d73e3d0d..2aa6e10c 100644 --- a/scripts/storage/disk-passthrough_ct.sh +++ b/scripts/storage/disk-passthrough_ct.sh @@ -5,8 +5,8 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.2 -# Last Updated: 30/07/2025 +# Version : 1.3 +# Last Updated: 07/04/2026 # ========================================================== # Description: # This script allows users to assign physical disks to existing @@ -20,223 +20,280 @@ # ========================================================== # Configuration ============================================ -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" -VENV_PATH="/opt/googletrans-env" +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi + +# shellcheck source=/dev/null if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +# shellcheck source=/dev/null +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" +fi + +BACKTITLE="ProxMenux" +UI_MENU_H=20 +UI_MENU_W=84 +UI_MENU_LIST_H=10 +UI_SHORT_MENU_H=16 +UI_SHORT_MENU_W=72 +UI_SHORT_MENU_LIST_H=6 +UI_MSG_H=10 +UI_MSG_W=72 +UI_YESNO_H=18 +UI_YESNO_W=86 +UI_RESULT_H=18 +UI_RESULT_W=86 + load_language initialize_cache -# Get OS codename for repository configuration -OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs )" +if ! command -v pveversion >/dev/null 2>&1; then + dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \ + --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 + exit 1 +fi # ========================================================== -# Function to get persistent device path -get_persistent_path() { - local device="$1" - local persistent_path="" - - # Try by-id first (most reliable) - for path in /dev/disk/by-id/*; do - if [[ -e "$path" && "$(readlink -f "$path")" == "$device" ]]; then - # Prefer ata- or scsi- over wwn- for readability - if [[ "$path" =~ ata-|scsi- ]]; then - echo "$path" - return 0 - elif [[ -z "$persistent_path" ]]; then - persistent_path="$path" - fi +# Returns the most stable /dev/disk/by-id symlink for a device. +# Prefers ata-/scsi-/nvme- > wwn- > other by-id > by-path > raw path. +get_preferred_disk_path() { + local disk="$1" + local real_path + real_path=$(readlink -f "$disk" 2>/dev/null) + [[ -z "$real_path" ]] && { echo "$disk"; return 0; } + + local best="" best_score=99999 + local link name score + for link in /dev/disk/by-id/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + name=$(basename "$link") + [[ "$name" == *-part* ]] && continue + + case "$name" in + ata-*|scsi-*|nvme-*) score=100 ;; + wwn-*) score=200 ;; + *) score=300 ;; + esac + score=$((score + ${#name})) + if (( score < best_score )); then + best="$link" + best_score=$score fi done - - # Return the first found by-id path if any - if [[ -n "$persistent_path" ]]; then - echo "$persistent_path" + + if [[ -n "$best" ]]; then + echo "$best" return 0 fi - - # Try by-path as fallback - for path in /dev/disk/by-path/*; do - if [[ -e "$path" && "$(readlink -f "$path")" == "$device" ]]; then - echo "$path" - return 0 - fi + + for link in /dev/disk/by-path/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + echo "$link" + return 0 done - - # Fallback to original device if no persistent path found - msg_warn "$(translate "No persistent path found for") $device, $(translate "using direct path")" - echo "$device" + + msg_warn "$(translate "No persistent path found for") $disk — $(translate "using direct path (not guaranteed to survive reboots).")" + echo "$disk" } -# Function to ensure repositories are properly configured -ensure_repositories() { - local sources_file="/etc/apt/sources.list" - local need_update=false - - # Only verify the main repository with contrib and non-free - if ! grep -q "deb.*${OS_CODENAME}.*main" "$sources_file"; then - echo "deb http://deb.debian.org/debian ${OS_CODENAME} main contrib non-free non-free-firmware" >> "$sources_file" - need_update=true +install_fs_tools_in_ct() { + local ctid="$1" + local pkg="$2" + + if pct exec "$ctid" -- sh -c "[ -f /etc/alpine-release ]"; then + pct exec "$ctid" -- sh -c "apk update >/dev/null 2>&1 && apk add --no-progress $pkg >/dev/null 2>&1" + elif pct exec "$ctid" -- sh -c "grep -qi 'arch' /etc/os-release 2>/dev/null"; then + pct exec "$ctid" -- sh -c "pacman -Sy --noconfirm $pkg >/dev/null 2>&1" + elif pct exec "$ctid" -- sh -c "grep -qiE 'debian|ubuntu' /etc/os-release 2>/dev/null"; then + pct exec "$ctid" -- sh -c "apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq $pkg >/dev/null 2>&1" + else + return 1 fi - - if [ "$need_update" = true ]; then - apt update >/dev/null 2>&1 - fi - - return 0 } get_disk_info() { local disk=$1 - MODEL=$(lsblk -dn -o MODEL "$disk" | xargs) - SIZE=$(lsblk -dn -o SIZE "$disk" | xargs) - echo "$MODEL" "$SIZE" + local model size + model=$(lsblk -dn -o MODEL "$disk" | xargs) + size=$(lsblk -dn -o SIZE "$disk" | xargs) + [[ -z "$model" ]] && model="Unknown" + printf '%s\t%s\n' "$model" "$size" } -# Ensure repositories are configured -ensure_repositories +# Suggest an unused mount point inside the CT for the given disk. +# Reads the CT config to collect already-used mp= paths, then returns +# the first free candidate: /mnt/disk_, /mnt/disk__2, ... +_get_suggested_mount_point() { + local ctid="$1" + local disk="$2" + local devname + devname=$(basename "$disk") + local base="/mnt/disk_${devname}" + + local used_mps + used_mps=$(pct config "$ctid" 2>/dev/null | grep '^mp[0-9]*:' | \ + grep -oP 'mp=\K[^,]+' | sort) + + if ! grep -qxF "$base" <<< "$used_mps"; then + echo "$base"; return + fi + local n=2 + while grep -qxF "${base}_${n}" <<< "$used_mps"; do + ((n++)) + done + echo "${base}_${n}" +} + +get_all_disk_paths() { + local disk="$1" + local real_path + real_path=$(readlink -f "$disk" 2>/dev/null) + + [[ -n "$disk" ]] && echo "$disk" + [[ -n "$real_path" ]] && echo "$real_path" + + local link + for link in /dev/disk/by-id/* /dev/disk/by-path/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + echo "$link" + done | sort -u +} + +disk_referenced_in_config() { + local config_text="$1" + local disk="$2" + local alias + while read -r alias; do + [[ -z "$alias" ]] && continue + if grep -Fq "$alias" <<< "$config_text"; then + return 0 + fi + done < <(get_all_disk_paths "$disk") + return 1 +} + + CT_LIST=$(pct list | awk 'NR>1 {print $1, $3}') if [ -z "$CT_LIST" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No CTs available in the system.")" 8 40 + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No CTs available in the system.")" $UI_MSG_H $UI_MSG_W exit 1 fi -CTID=$(whiptail --title "$(translate "Select CT for destination disk")" --menu "$(translate "Select the CT to which you want to add disks:")" 15 60 8 $CT_LIST 3>&1 1>&2 2>&3) +# shellcheck disable=SC2086 # CT_LIST is intentionally word-split into dialog menu pairs +CTID=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select CT for destination disk")" \ + --menu "$(translate "Select the CT to which you want to add disks:")" $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + $CT_LIST \ + 2>&1 >/dev/tty) if [ -z "$CTID" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No CT was selected.")" 8 40 + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No CT was selected.")" $UI_MSG_H $UI_MSG_W exit 1 fi CTID=$(echo "$CTID" | tr -d '"') -clear -show_proxmenux_logo -echo -e -msg_title "$(translate "Import Disk to LXC")" -echo -e -msg_ok "$(translate "CT selected successfully.")" - CT_STATUS=$(pct status "$CTID" | awk '{print $2}') -if [ "$CT_STATUS" != "running" ]; then - msg_info "$(translate "Starting CT") $CTID..." - pct start "$CTID" - sleep 2 - if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then - msg_error "$(translate "Failed to start the CT.")" - sleep 2 +CT_RUNNING=false +[[ "$CT_STATUS" == "running" ]] && CT_RUNNING=true + +# ── Check for unprivileged container — also a dialog, stays before show_proxmenux_logo ── +CONF_FILE="/etc/pve/lxc/$CTID.conf" +CONVERT_PRIVILEGED=false +if grep -q '^unprivileged: 1' "$CONF_FILE"; then + if dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Privileged Container")" \ + --yesno "\n\n$(translate "The selected container is unprivileged. A privileged container is required for direct device passthrough.")\\n\\n$(translate "Do you want to convert it to a privileged container now?")" $UI_YESNO_H $UI_YESNO_W; then + CONVERT_PRIVILEGED=true + else + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Aborted")" \ + --msgbox "$(translate "Operation cancelled. Cannot continue with an unprivileged container.")" $UI_MSG_H $UI_MSG_W exit 1 fi - msg_ok "$(translate "CT started successfully.")" fi -CONF_FILE="/etc/pve/lxc/$CTID.conf" -if grep -q '^unprivileged: 1' "$CONF_FILE"; then - if whiptail --title "$(translate "Privileged Container")" \ - --yesno "$(translate "The selected container is unprivileged. A privileged container is required for direct device passthrough.")\\n\\n$(translate "Do you want to convert it to a privileged container now?")" 12 70; then - +# ── TERMINAL PHASE 1 ────────────────────────────────────────────────────────── +show_proxmenux_logo +msg_title "$(translate "Import Disk to LXC")" +msg_ok "$(translate "CT $CTID selected successfully.")" + +if [ "$CONVERT_PRIVILEGED" = true ]; then + + show_proxmenux_logo + msg_title "$(translate "Import Disk to LXC")" + + CURRENT_CT_STATUS=$(pct status "$CTID" | awk '{print $2}') + if [ "$CURRENT_CT_STATUS" == "running" ]; then msg_info "$(translate "Stopping container") $CTID..." pct shutdown "$CTID" &>/dev/null for i in {1..10}; do sleep 1 - if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then - break - fi + [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ] && break done - if [ "$(pct status "$CTID" | awk '{print $2}')" == "running" ]; then msg_error "$(translate "Failed to stop the container.")" exit 1 fi msg_ok "$(translate "Container stopped.")" - - cp "$CONF_FILE" "$CONF_FILE.bak" - sed -i '/^unprivileged: 1/d' "$CONF_FILE" - echo "unprivileged: 0" >> "$CONF_FILE" - msg_ok "$(translate "Container successfully converted to privileged.")" - + fi + + cp "$CONF_FILE" "$CONF_FILE.bak" + sed -i '/^unprivileged: 1/d' "$CONF_FILE" + echo "unprivileged: 0" >> "$CONF_FILE" + msg_ok "$(translate "Container successfully converted to privileged.")" + + if [ "$CT_RUNNING" = true ]; then msg_info "$(translate "Starting container") $CTID..." pct start "$CTID" &>/dev/null sleep 2 if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then msg_error "$(translate "Failed to start the container.")" - exit 1 + CT_RUNNING=false + else + msg_ok "$(translate "Container started successfully.")" fi - msg_ok "$(translate "Container started successfully.")" - else - whiptail --title "$(translate "Aborted")" \ - --msgbox "$(translate "Operation cancelled. Cannot continue with an unprivileged container.")" 10 60 - exit 1 fi fi ########################################## msg_info "$(translate "Detecting available disks...")" -USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}') -MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') - -ZFS_DISKS="" -ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') -for entry in $ZFS_RAW; do - path="" - if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then - if [ -e "/dev/disk/by-id/$entry" ]; then - path=$(readlink -f "/dev/disk/by-id/$entry") - fi - elif [[ "$entry" == /dev/* ]]; then - path="$entry" - fi - - if [ -n "$path" ]; then - base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) - if [ -n "$base_disk" ]; then - ZFS_DISKS+="/dev/$base_disk"$'\n' - fi - fi -done -ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) - -is_disk_in_use() { - local disk="$1" - while read -r part fstype; do - case "$fstype" in - zfs_member|linux_raid_member) - return 0 ;; - esac - if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then - return 0 - fi - done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2) - - if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then - return 0 - fi - return 1 -} +_refresh_host_storage_cache +# Read this CT's current config for the "already assigned to this CT" check +CT_CONFIG=$(pct config "$CTID" 2>/dev/null | grep -vE '^\s*#|^description:') FREE_DISKS=() -LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -r -n1 readlink -f | sort -u) - -if [[ -n "$LVM_DEVICES" ]] && echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then - IS_MOUNTED=true -fi - -RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u) while read -r DISK; do [[ "$DISK" =~ /dev/zd ]] && continue - - INFO=($(get_disk_info "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" + + IFS=$'\t' read -r MODEL SIZE < <(get_disk_info "$DISK") LABEL="" SHOW_DISK=true IS_MOUNTED=false @@ -259,45 +316,34 @@ while read -r DISK; do fi USED_BY="" - REAL_PATH=$(readlink -f "$DISK") - CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) - - if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then + if _disk_used_in_guest_configs "$DISK"; then USED_BY="⚠ $(translate "In use")" - else - for SYMLINK in /dev/disk/by-id/*; do - if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then - if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - break - fi - fi - done fi - + if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then if grep -q "active raid" /proc/mdstat; then SHOW_DISK=false fi fi - + if $IS_ZFS; then SHOW_DISK=false fi - + + # Catch whole-disk ZFS vdevs with no partitions (e.g. bare NVMe ZFS) + # The tail -n +2 trick misses them; ZFS_DISKS from _refresh_host_storage_cache covers them. + if [[ -n "$ZFS_DISKS" ]] && \ + { grep -qFx "$DISK" <<< "$ZFS_DISKS" || \ + { [[ -n "$REAL_PATH" ]] && grep -qFx "$REAL_PATH" <<< "$ZFS_DISKS"; }; }; then + SHOW_DISK=false + fi + if $IS_MOUNTED; then SHOW_DISK=false fi - - # Check if disk is already assigned to this CT using persistent paths - if pct config "$CTID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then + + if disk_referenced_in_config "$CT_CONFIG" "$DISK"; then SHOW_DISK=false - else - # Also check persistent paths - PERSISTENT_DISK=$(get_persistent_path "$DISK") - if [[ "$PERSISTENT_DISK" != "$DISK" ]] && pct config "$CTID" | grep -vE '^\s*#|^description:' | grep -q "$PERSISTENT_DISK"; then - SHOW_DISK=false - fi fi if $SHOW_DISK; then @@ -312,263 +358,363 @@ while read -r DISK; do done < <(lsblk -dn -e 7,11 -o PATH) if [ "${#FREE_DISKS[@]}" -eq 0 ]; then - cleanup - whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this CT.")" 8 40 - clear + stop_spinner + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No disks available for this CT.")" $UI_MSG_H $UI_MSG_W exit 1 fi -msg_ok "$(translate "Available disks detected.")" +stop_spinner ###################################################### MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1) TOTAL_WIDTH=$((MAX_WIDTH + 20)) -if [ $TOTAL_WIDTH -lt 50 ]; then - TOTAL_WIDTH=50 +if [ $TOTAL_WIDTH -lt $UI_MENU_W ]; then + TOTAL_WIDTH=$UI_MENU_W +fi +if [ $TOTAL_WIDTH -gt 116 ]; then + TOTAL_WIDTH=116 fi -SELECTED=$(whiptail --title "$(translate "Select Disks")" --radiolist \ - "$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) +SELECTED=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select Disks")" \ + --checklist "$(translate "Select the disks you want to add:")" $UI_MENU_H $TOTAL_WIDTH $UI_MENU_LIST_H \ + "${FREE_DISKS[@]}" \ + 2>&1 >/dev/tty) if [ -z "$SELECTED" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64 - clear + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No disks were selected.")" $UI_MSG_H $UI_MSG_W exit 1 fi -msg_ok "$(translate "Disks selected successfully.")" +show_proxmenux_logo +msg_title "$(translate "Import Disk to LXC")" -DISKS_ADDED=0 -ERROR_MESSAGES="" -SUCCESS_MESSAGES="" +msg_ok "$(translate "CT $CTID selected successfully.")" +msg_info "$(translate "Analyzing selected disks...")" -msg_info "$(translate "Processing selected disks...")" + + +# ── DIALOG PHASE: collect config for each disk ──────────────────────────────── +declare -a DISK_LIST=() +declare -a DISK_DESCRIPTIONS=() +declare -a DISK_MOUNT_POINTS=() +declare -a DISK_SKIP_FORMATS=() +declare -a DISK_FORMAT_TYPES=() +declare -a DISK_NEEDS_PARTITION=() +declare -a DISK_PARTITIONS=() +declare -a DISK_ASSIGNED_TOS=() +declare -a DISK_CURRENT_FSes=() for DISK in $SELECTED; do - DISK=$(echo "$DISK" | tr -d '"') + DISK="${DISK//\"/}" DISK_INFO=$(get_disk_info "$DISK") - + ASSIGNED_TO="" RUNNING_CTS="" RUNNING_VMS="" - + while read -r CT_ID CT_NAME; do - if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then + CT_CONFIG_RAW=$(pct config "$CT_ID" 2>/dev/null) + if [[ "$CT_ID" =~ ^[0-9]+$ ]] && disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"; then ASSIGNED_TO+="CT $CT_ID $CT_NAME\n" CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}') - if [ "$CT_STATUS" == "running" ]; then - RUNNING_CTS+="CT $CT_ID $CT_NAME\n" - fi + [[ "$CT_STATUS" == "running" ]] && RUNNING_CTS+="CT $CT_ID $CT_NAME\n" fi done < <(pct list | awk 'NR>1 {print $1, $3}') - + while read -r VM_ID VM_NAME; do - if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then + VM_CONFIG_RAW=$(qm config "$VM_ID" 2>/dev/null) + if [[ "$VM_ID" =~ ^[0-9]+$ ]] && disk_referenced_in_config "$VM_CONFIG_RAW" "$DISK"; then ASSIGNED_TO+="VM $VM_ID $VM_NAME\n" VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}') - if [ "$VM_STATUS" == "running" ]; then - RUNNING_VMS+="VM $VM_ID $VM_NAME\n" - fi + [[ "$VM_STATUS" == "running" ]] && RUNNING_VMS+="VM $VM_ID $VM_NAME\n" fi done < <(qm list | awk 'NR>1 {print $1, $2}') - + + + stop_spinner if [ -n "$RUNNING_CTS" ] || [ -n "$RUNNING_VMS" ]; then - ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s) or CT(s):")\\n$RUNNING_CTS$RUNNING_VMS\\n\\n" + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Disk In Use")" \ + --msgbox "$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s) or CT(s):")\\n$RUNNING_CTS$RUNNING_VMS\\n\\n$(translate "Stop them first and run this script again.")" $UI_RESULT_H $UI_RESULT_W continue fi - + if [ -n "$ASSIGNED_TO" ]; then - cleanup - whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70 - if [ $? -ne 0 ]; then - sleep 1 - exec "$0" + if ! dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Disk Already Assigned")" \ + --yesno "\n\n$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" $UI_YESNO_H $UI_YESNO_W; then + continue fi fi - - cleanup - + if lsblk "$DISK" | grep -q "raid" || grep -q "${DISK##*/}" /proc/mdstat; then - whiptail --title "$(translate "RAID Detected")" --msgbox "$(translate "The disk") $DISK_INFO $(translate "appears to be part of a") RAID. $(translate "For security reasons, the system cannot format it.")\\n\\n$(translate "If you are sure you want to use it, please remove the") RAID metadata $(translate "or format it manually using external tools.")\\n\\n$(translate "After that, run this script again to add it.")" 18 70 - clear - exit - fi - - MOUNT_POINT=$(whiptail --title "$(translate "Mount Point")" --inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/disk_passthrough):")" 10 60 "/mnt/disk_passthrough" 3>&1 1>&2 2>&3) - - if [ -z "$MOUNT_POINT" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40 + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "RAID Detected")" \ + --msgbox "\n$(translate "The disk") $DISK_INFO $(translate "appears to be part of a") RAID. $(translate "For security reasons, the system cannot format it.")\\n\\n$(translate "If you are sure you want to use it, please remove the") RAID metadata $(translate "or format it manually using external tools.")\\n\\n$(translate "After that, run this script again to add it.")" $UI_RESULT_H $UI_RESULT_W continue fi - - msg_ok "$(translate "Mount point specified: $MOUNT_POINT")" - + + # 1. Detect current partition/FS state PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}') SKIP_FORMAT=false - + NEEDS_PARTITION=false + if [ -n "$PARTITION" ]; then PARTITION="/dev/$PARTITION" CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs) - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then - SKIP_FORMAT=true - msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION." - else - whiptail --title "$(translate "Unsupported Filesystem")" --yesno "$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\\nDo you want to format it?")" 10 70 - if [ $? -ne 0 ]; then - continue - fi - fi else CURRENT_FS=$(lsblk -no FSTYPE "$DISK" | xargs) - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then + PARTITION="$DISK" + fi + + # 2. Ask what to do with this disk + if [ -n "$CURRENT_FS" ]; then + # Disk already has a filesystem — offer use-as-is or reformat + DISK_ACTION=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Disk Setup")" \ + --menu "$(translate "Disk") $DISK_INFO\n$(translate "Detected filesystem:") $CURRENT_FS\n\n$(translate "What do you want to do?")" \ + $UI_SHORT_MENU_H $UI_SHORT_MENU_W $UI_SHORT_MENU_LIST_H \ + "use" "$(translate "Use as-is — keep data and filesystem")" \ + "format" "$(translate "Format — erase and create new filesystem")" \ + 2>&1 >/dev/tty) + [ -z "$DISK_ACTION" ] && continue + else + DISK_ACTION="format" + fi + + FORMAT_TYPE="" + if [ "$DISK_ACTION" = "use" ]; then + SKIP_FORMAT=true + FORMAT_TYPE="$CURRENT_FS" + # PARTITION already set correctly by the detection block above — do not modify + else + # 3. Ask desired filesystem for format + FORMAT_TYPE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select Filesystem")" \ + --menu "$(translate "Select the filesystem for") $DISK_INFO:" \ + $UI_SHORT_MENU_H $UI_SHORT_MENU_W $UI_SHORT_MENU_LIST_H \ + "ext4" "$(translate "ext4 — recommended, most compatible")" \ + "xfs" "$(translate "xfs — better for large files")" \ + "btrfs" "$(translate "btrfs — snapshots and compression")" \ + 2>&1 >/dev/tty) + [ -z "$FORMAT_TYPE" ] && continue + + # Check if already the right FS — otherwise need partition + format + if [ "$CURRENT_FS" = "$FORMAT_TYPE" ]; then SKIP_FORMAT=true - PARTITION="$DISK" - msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $DISK." - else - whiptail --title "$(translate "No Valid Partitions")" --yesno "$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70 - if [ $? -ne 0 ]; then - continue - fi - - echo -e "$(translate "Creating partition table and partition...")" - parted -s "$DISK" mklabel gpt - parted -s "$DISK" mkpart primary 0% 100% - sleep 2 - partprobe "$DISK" - sleep 2 - - PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}') - if [ -n "$PARTITION" ]; then - PARTITION="/dev/$PARTITION" - else - whiptail --title "$(translate "Partition Error")" --msgbox "$(translate "Failed to create partition on disk") $DISK_INFO." 8 70 + elif [ -z "$CURRENT_FS" ] && [ "$PARTITION" = "$DISK" ]; then + NEEDS_PARTITION=true + PARTITION="" + fi + + # 4. Warn if data will be erased + if [ "$SKIP_FORMAT" != true ]; then + if ! dialog --backtitle "$BACKTITLE" \ + --title "$(translate "WARNING")" \ + --yesno "\n$(translate "WARNING: This will FORMAT the disk") $DISK_INFO $(translate "as") $FORMAT_TYPE.\\n\\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\\n\\n$(translate "Are you sure?")" \ + $UI_YESNO_H $UI_YESNO_W; then continue fi fi fi - - if [ "$SKIP_FORMAT" != true ]; then - CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs) - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then - SKIP_FORMAT=true - msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION. $(translate "Skipping format.")" - else - FORMAT_TYPE=$(whiptail --title "$(translate "Select Format Type")" --menu "$(translate "Select the filesystem type for") $DISK_INFO:" 15 60 6 \ - "ext4" "$(translate "Extended Filesystem 4 (recommended)")" \ - "xfs" "$(translate "XFS Filesystem")" \ - "btrfs" "$(translate "Btrfs Filesystem")" 3>&1 1>&2 2>&3) - - if [ -z "$FORMAT_TYPE" ]; then - whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60 - continue - fi - - whiptail --title "$(translate "WARNING")" --yesno "$(translate "WARNING: This operation will FORMAT the disk") $DISK_INFO $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\\n\\n$(translate "Are you sure you want to continue")" 15 70 - if [ $? -ne 0 ]; then - whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60 - continue - fi - fi + + # 5. Ask mount point — suggest unique path based on device name + SUGGESTED_MP=$(_get_suggested_mount_point "$CTID" "$DISK") + MOUNT_POINT=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Mount Point")" \ + --inputbox "$(translate "Enter the mount point inside the CT for") $DISK_INFO:" \ + $UI_MSG_H $UI_MSG_W "$SUGGESTED_MP" \ + 2>&1 >/dev/tty) + + if [ -z "$MOUNT_POINT" ]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Error")" \ + --msgbox "$(translate "No mount point was specified.")" $UI_MSG_H $UI_MSG_W + continue fi - - if [ "$SKIP_FORMAT" != true ]; then - echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..." - case "$FORMAT_TYPE" in - "ext4") mkfs.ext4 -F "$PARTITION" ;; - "xfs") mkfs.xfs -f "$PARTITION" ;; - "btrfs") mkfs.btrfs -f "$PARTITION" ;; - esac - - if [ $? -ne 0 ]; then - whiptail --title "$(translate "Format Failed")" --msgbox "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "The disk may be in use by the system or have hardware issues.")" 12 70 + + DISK_LIST+=("$DISK") + DISK_DESCRIPTIONS+=("$DISK_INFO") + DISK_MOUNT_POINTS+=("$MOUNT_POINT") + DISK_SKIP_FORMATS+=("$SKIP_FORMAT") + DISK_FORMAT_TYPES+=("$FORMAT_TYPE") + DISK_NEEDS_PARTITION+=("$NEEDS_PARTITION") + DISK_PARTITIONS+=("$PARTITION") + DISK_ASSIGNED_TOS+=("$ASSIGNED_TO") + DISK_CURRENT_FSes+=("$CURRENT_FS") +done + +if [ "${#DISK_LIST[@]}" -eq 0 ]; then + show_proxmenux_logo + msg_title "$(translate "Import Disk to LXC")" + msg_warn "$(translate "No disks were configured for processing.")" + echo "" + msg_success "$(translate "Press Enter to return to menu...")" + read -r + exit 0 +fi + +# ── TERMINAL PHASE: execute all disk operations ─────────────────────────────── +show_proxmenux_logo +msg_title "$(translate "Import Disk to LXC")" +msg_ok "$(translate "CT $CTID selected successfully.")" +msg_ok "$(translate "Disks to process:") ${#DISK_LIST[@]}" +for i in "${!DISK_LIST[@]}"; do + IFS=$'\t' read -r _desc_model _desc_size <<< "${DISK_DESCRIPTIONS[$i]}" + echo -e "${TAB}${BL}${DISK_LIST[$i]} $_desc_model $_desc_size${CL}" +done +echo "" + +DISKS_ADDED=0 + +for i in "${!DISK_LIST[@]}"; do + DISK="${DISK_LIST[$i]}" + MOUNT_POINT="${DISK_MOUNT_POINTS[$i]}" + SKIP_FORMAT="${DISK_SKIP_FORMATS[$i]}" + FORMAT_TYPE="${DISK_FORMAT_TYPES[$i]}" + NEEDS_PARTITION="${DISK_NEEDS_PARTITION[$i]}" + PARTITION="${DISK_PARTITIONS[$i]}" + ASSIGNED_TO="${DISK_ASSIGNED_TOS[$i]}" + CURRENT_FS="${DISK_CURRENT_FSes[$i]}" + DISK_INFO=$(get_disk_info "$DISK") + + echo "" + msg_ok "$(translate "Disk:") $DISK → $MOUNT_POINT" + + if [ "$NEEDS_PARTITION" = true ]; then + msg_info "$(translate "Creating partition table and partition...")" + if ! parted -s "$DISK" mklabel gpt mkpart primary 0% 100% >/dev/null 2>&1; then + msg_error "$(translate "Failed to create partition table on disk") $DISK_INFO." continue + fi + sleep 2 + partprobe "$DISK" 2>/dev/null || true + udevadm settle 2>/dev/null || true + # Wait up to 5 s for by-id symlinks to be created by udev + for _i in {1..5}; do + for _p in /dev/disk/by-id/*; do + [[ "$(readlink -f "$_p" 2>/dev/null)" == "$DISK"* ]] && break 2 + done + sleep 1 + done + PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}') + if [ -n "$PARTITION" ]; then + PARTITION="/dev/$PARTITION" + msg_ok "$(translate "Partition created:") $PARTITION" else - msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE." - partprobe "$DISK" - sleep 2 + msg_error "$(translate "Failed to detect partition on disk") $DISK_INFO." + continue fi fi - + + if [ "$SKIP_FORMAT" != true ]; then + msg_info "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..." + if ! case "$FORMAT_TYPE" in + "ext4") mkfs.ext4 -F "$PARTITION" >/dev/null 2>&1 ;; + "xfs") mkfs.xfs -f "$PARTITION" >/dev/null 2>&1 ;; + "btrfs") mkfs.btrfs -f "$PARTITION" >/dev/null 2>&1 ;; + esac; then + msg_error "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE." + continue + fi + msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE." + partprobe "$DISK" >/dev/null 2>&1 || true + sleep 2 + else + msg_ok "$(translate "Disk already has") $FORMAT_TYPE $(translate "filesystem. Skipping format.")" + fi + INDEX=0 while pct config "$CTID" | grep -q "mp${INDEX}:"; do ((INDEX++)) done - - # Determine the filesystem type for mount options - CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs) - if [[ -n "$CURRENT_FS" ]]; then - FORMAT_TYPE="$CURRENT_FS" - fi - - # Install filesystem tools in container if needed + FS_PKG="" FS_BIN="" - if [[ "$FORMAT_TYPE" == "xfs" ]]; then - FS_PKG="xfsprogs" - FS_BIN="mkfs.xfs" - elif [[ "$FORMAT_TYPE" == "btrfs" ]]; then - FS_PKG="btrfs-progs" - FS_BIN="mkfs.btrfs" - fi - + [[ "$FORMAT_TYPE" == "xfs" ]] && FS_PKG="xfsprogs" && FS_BIN="xfs_repair" + [[ "$FORMAT_TYPE" == "btrfs" ]] && FS_PKG="btrfs-progs" && FS_BIN="btrfsck" + if [[ -n "$FS_PKG" && -n "$FS_BIN" ]]; then - if ! pct exec "$CTID" -- sh -c "command -v $FS_BIN >/dev/null 2>&1"; then - msg_info "$(translate "Installing required tools for $FORMAT_TYPE in CT $CTID...")" - if pct exec "$CTID" -- sh -c "[ -f /etc/alpine-release ]"; then - pct exec "$CTID" -- sh -c "apk update >/dev/null && apk add --no-progress $FS_PKG >/dev/null" - elif pct exec "$CTID" -- sh -c "[ -f /etc/os-release ] && (grep -qE 'debian|ubuntu' /etc/os-release)"; then - pct exec "$CTID" -- sh -c "apt-get update -qq >/dev/null && apt-get install -y -qq $FS_PKG >/dev/null" + if [ "$CT_RUNNING" = true ]; then + if ! pct exec "$CTID" -- sh -c "command -v $FS_BIN >/dev/null 2>&1"; then + msg_info "$(translate "Installing required tools for $FORMAT_TYPE in CT $CTID...")" + if install_fs_tools_in_ct "$CTID" "$FS_PKG"; then + msg_ok "$(translate "Required tools for $FORMAT_TYPE installed in CT $CTID.")" + else + msg_warn "$(translate "Could not install") $FS_PKG $(translate "automatically. Install it manually inside the container.")" + fi + fi + else + # CT is stopped — ask via whiptail (terminal-safe, no dialog on top of output) + if whiptail --backtitle "$BACKTITLE" \ + --title "$(translate "Filesystem Tools Required")" \ + --yesno "$(translate "The filesystem") $FORMAT_TYPE $(translate "requires the package") $FS_PKG $(translate "installed inside CT") $CTID.\n\n$(translate "The container is currently stopped. Do you want to start it now to install the package?")\n\n$(translate "If you choose No, install") $FS_PKG $(translate "manually inside the container before starting it.")" \ + $UI_YESNO_H $UI_YESNO_W; then + msg_info "$(translate "Starting CT") $CTID..." + pct start "$CTID" &>/dev/null + sleep 2 + if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then + msg_error "$(translate "Failed to start CT") $CTID. $(translate "Install") $FS_PKG $(translate "manually inside the container.")" + else + msg_ok "$(translate "CT") $CTID $(translate "started.")" + CT_RUNNING=true + msg_info "$(translate "Installing") $FS_PKG $(translate "in CT") $CTID..." + if install_fs_tools_in_ct "$CTID" "$FS_PKG"; then + msg_ok "$FS_PKG $(translate "installed in CT") $CTID." + else + msg_warn "$(translate "Could not install") $FS_PKG $(translate "automatically. Install it manually inside the container.")" + fi + fi + else + msg_warn "$(translate "Manual install required inside CT") $CTID:" + echo -e "${DGN}${TAB} Debian/Ubuntu:${CL} ${BL}apt-get install -y $FS_PKG${CL}" + echo -e "${DGN}${TAB} Arch:${CL} ${BL}pacman -S --noconfirm $FS_PKG${CL}" + echo -e "${DGN}${TAB} Alpine:${CL} ${BL}apk add $FS_PKG${CL}" + echo fi - msg_ok "$(translate "Required tools for $FORMAT_TYPE installed in CT $CTID.")" fi fi - - ############################################################################## - # Get persistent path for the partition - PERSISTENT_PARTITION=$(get_persistent_path "$PARTITION") - - # Apply passthrough with persistent path - CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs) - if [ "$CURRENT_FS" == "xfs" ] || [ "$FORMAT_TYPE" == "xfs" ]; then + + PERSISTENT_PARTITION=$(get_preferred_disk_path "$PARTITION") + + msg_info "$(translate "Applying passthrough to CT") $CTID..." + if [ "$FORMAT_TYPE" == "xfs" ]; then RESULT=$(pct set "$CTID" -mp${INDEX} "$PERSISTENT_PARTITION,mp=$MOUNT_POINT,backup=0,ro=0" 2>&1) else RESULT=$(pct set "$CTID" -mp${INDEX} "$PERSISTENT_PARTITION,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1) fi - - # Adjust permissions inside the CT - pct exec "$CTID" -- chmod -R 775 "$MOUNT_POINT" 2>/dev/null || true - - # Show confirmation with persistent identifier - msg_ok "$(translate "Assigned using") $PERSISTENT_PARTITION" - ############################################################################## - - if [ $? -eq 0 ]; then - MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to CT") $CTID $(translate "as a mount point at") $MOUNT_POINT." - MESSAGE+="\\n$(translate "Using persistent path"): $PERSISTENT_PARTITION" - - if [ -n "$ASSIGNED_TO" ]; then - MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following CT(s):")\\n$ASSIGNED_TO" - MESSAGE+="\\n$(translate "Make sure not to start CTs that share this disk at the same time to avoid data corruption.")" + SET_STATUS=$? + + if [ $SET_STATUS -eq 0 ]; then + msg_ok "$(translate "Disk assigned at") $MOUNT_POINT $(translate "using") $PERSISTENT_PARTITION" + [[ -n "$ASSIGNED_TO" ]] && msg_warn "$(translate "WARNING: This disk is also assigned to:") $(echo -e "$ASSIGNED_TO" | tr '\n' ' ')" + + # Verify disk is accessible inside the CT + if [ "$CT_RUNNING" = true ]; then + msg_info "$(translate "Verifying disk accessibility in CT") $CTID..." + sleep 1 + if pct exec "$CTID" -- sh -c "mountpoint -q '$MOUNT_POINT' || [ -d '$MOUNT_POINT' ]" 2>/dev/null; then + msg_ok "$(translate "Disk verified and accessible inside CT at") $MOUNT_POINT" + fi fi - - SUCCESS_MESSAGES+="$MESSAGE\\n\\n" + ((DISKS_ADDED++)) else - ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to CT") $CTID.\\n$(translate "Error:") $RESULT\\n\\n" + msg_error "$(translate "Could not add disk") $DISK_INFO $(translate "to CT") $CTID. $(translate "Error:") $RESULT" fi done -msg_ok "$(translate "Disk processing completed.")" - -if [ -n "$SUCCESS_MESSAGES" ]; then - MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l) - whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70 +echo "" +if [ "$DISKS_ADDED" -gt 0 ]; then + msg_ok "$(translate "Completed.") $DISKS_ADDED $(translate "disk(s) added to CT") $CTID." +else + msg_warn "$(translate "No disks were added.")" fi - -if [ -n "$ERROR_MESSAGES" ]; then - whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70 -fi - msg_success "$(translate "Press Enter to return to menu...")" read -r exit 0 diff --git a/scripts/storage/disk-storage-manual-guide.sh b/scripts/storage/disk-storage-manual-guide.sh new file mode 100644 index 00000000..96fe8bba --- /dev/null +++ b/scripts/storage/disk-storage-manual-guide.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# ========================================================== +# ProxMenux - Disk and Storage Manager Manual CLI Guide +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : GPL-3.0 +# Version : 1.0 +# Last Updated: 07/04/2026 +# ========================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi + +if [[ -f "$UTILS_FILE" ]]; then + source "$UTILS_FILE" +fi +load_language +initialize_cache + +GREEN=$'\033[0;32m' +NC=$'\033[0m' + +_cl() { + local num="$1" disp="$2" desc="$3" + local pad=$((47 - ${#disp})) + [[ $pad -lt 1 ]] && pad=1 + local spaces + spaces=$(printf '%*s' "$pad" '') + printf " %2d) %s%s%s%s - %s\n" "$num" "$GREEN" "$disp" "$NC" "$spaces" "$desc" +} + +while true; do + clear + show_proxmenux_logo + msg_title "$(translate "Disk and Storage Manager - Manual CLI Guide")" + echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}" + echo + + _cl 1 "lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL" "$(translate 'Inspect disks before any action')" + _cl 2 "ls -lh /dev/disk/by-id/" "$(translate 'Identify persistent disk paths')" + _cl 3 "qm list && pct list" "$(translate 'List VM/CT IDs to operate on')" + _cl 4 "qm config | grep 'sata|scsi|hostpci'" "$(translate 'Check VM disk/PCI slots')" + _cl 5 "pvesm status -content images" "$(translate 'List storages valid for image import')" + _cl 6 "qm importdisk " "[T] $(translate 'Import disk image to VM')" + _cl 7 "qm set -- " "[T] $(translate 'Attach imported disk to VM')" + _cl 8 "qm set --boot order=" "[T] $(translate 'Set VM boot order')" + _cl 9 "lspci -nn | grep -Ei 'SATA|RAID|NVMe'" "$(translate 'Detect controller/NVMe BDF')" + _cl 10 "find /sys/kernel/iommu_groups -type l | grep BDF" "$(translate 'Verify IOMMU group for PCI device')" + _cl 11 "qm set --hostpci ,pcie=1" "[T] $(translate 'Assign controller/NVMe passthrough')" + _cl 12 "pct config | grep '^mp'" "$(translate 'Check container mount points')" + _cl 13 "pct set -mp ,mp=" "[T] $(translate 'Add disk to LXC container')" + _cl 14 "wipefs -a -f /dev/sdX && sgdisk --zap-all /dev/sdX" "[T] $(translate 'Clean disk metadata')" + _cl 15 "parted -s /dev/sdX mklabel gpt mkpart primary" "[T] $(translate 'Create GPT partition')" + _cl 16 "mkfs.ext4 -F /dev/sdX1 (or mkfs.xfs / mkfs.btrfs)" "[T] $(translate 'Format filesystem')" + _cl 17 "pvesm status && zpool status" "$(translate 'Final storage health/status check')" + echo -e " ${DEF} 0) $(translate 'Back to previous menu or Esc + Enter')${CL}" + echo + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}" + read -r user_input + + if [[ "$user_input" == $'\x1b' ]]; then + break + fi + + mode="exec" + case "$user_input" in + 1) cmd="lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL" ;; + 2) cmd="ls -lh /dev/disk/by-id/" ;; + 3) cmd="qm list && pct list" ;; + 4) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}" + read -r vmid + cmd="qm config $vmid | grep -E '^(sata|scsi|virtio|ide|hostpci|boot:)'" + ;; + 5) cmd="pvesm status -content images" ;; + 6) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter image full path: ')${CL}"; read -r image_path + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter target storage: ')${CL}"; read -r storage + cmd="qm importdisk $vmid $image_path $storage" + mode="template" + ;; + 7) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter interface (sata/scsi/virtio/ide): ')${CL}"; read -r iface + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter slot number (e.g. 0): ')${CL}"; read -r slot + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter imported disk reference (e.g. local-lvm:vm-100-disk-0): ')${CL}"; read -r imported_disk + cmd="qm set $vmid --${iface}${slot} $imported_disk" + mode="template" + ;; + 8) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter boot target (e.g. scsi0, sata0, ide0): ')${CL}"; read -r boot_target + cmd="qm set $vmid --boot order=$boot_target" + mode="template" + ;; + 9) cmd="lspci -nn | grep -Ei 'SATA|RAID|Non-Volatile|NVMe'" ;; + 10) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:04:00.0): ')${CL}" + read -r bdf + cmd="find /sys/kernel/iommu_groups -type l | grep $bdf" + ;; + 11) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot number (e.g. 0): ')${CL}"; read -r slot + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:04:00.0): ')${CL}"; read -r bdf + cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1" + mode="template" + ;; + 12) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}" + read -r ctid + cmd="pct config $ctid | grep '^mp'" + ;; + 13) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"; read -r ctid + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter mp slot number (e.g. 0): ')${CL}"; read -r mpslot + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter disk or partition path (prefer /dev/disk/by-id/...): ')${CL}"; read -r disk_part + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter mount point in CT (e.g. /mnt/data): ')${CL}"; read -r mount_point + cmd="pct set $ctid -mp${mpslot} ${disk_part},mp=${mount_point},backup=0,ro=0" + mode="template" + ;; + 14) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter target disk (e.g. /dev/sdX): ')${CL}" + read -r disk + cmd="wipefs -a -f $disk && sgdisk --zap-all $disk" + mode="template" + ;; + 15) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter target disk (e.g. /dev/sdX): ')${CL}" + read -r disk + cmd="parted -s -f $disk mklabel gpt mkpart primary 1MiB 100%" + mode="template" + ;; + 16) + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter partition path (e.g. /dev/sdX1): ')${CL}"; read -r part + echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter filesystem (ext4/xfs/btrfs): ')${CL}"; read -r fs + case "$fs" in + ext4) cmd="mkfs.ext4 -F $part" ;; + xfs) cmd="mkfs.xfs -f $part" ;; + btrfs) cmd="mkfs.btrfs -f $part" ;; + *) cmd="mkfs.ext4 -F $part" ;; + esac + mode="template" + ;; + 17) cmd="pvesm status && zpool status" ;; + 0) break ;; + *) + if [[ -n "$user_input" ]]; then + cmd="$user_input" + else + continue + fi + ;; + esac + + if [[ "$mode" == "template" ]]; then + echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n" + echo "$cmd" + echo + msg_success "$(translate 'Press ENTER to continue...')" + read -r tmp + continue + fi + + echo -e "\n${GREEN}> $cmd${NC}\n" + bash -c "$cmd" + echo + msg_success "$(translate 'Press ENTER to continue...')" + read -r tmp +done + diff --git a/scripts/storage/format-disk.sh b/scripts/storage/format-disk.sh index d3825e86..d6c946bc 100644 --- a/scripts/storage/format-disk.sh +++ b/scripts/storage/format-disk.sh @@ -1,296 +1,799 @@ #!/bin/bash # ========================================================== -# ProxMenux - A menu-driven script for Proxmox VE management +# ProxMenux - Secure Disk Formatter # ========================================================== # Author : MacRimi # Copyright : (c) 2024 MacRimi -# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.0 -# Last Updated: 28/01/2025 +# License : GPL-3.0 +# Version : 2.0 +# Last Updated: 11/04/2026 # ========================================================== -# Description : Select and format physical disks +# Description: +# Formats a physical disk with strict safety controls. +# +# Visibility rules: +# SHOWN — only fully free disks: +# not system-used and not referenced by VM/LXC configs. +# HIDDEN — host/system disks (root pool, swap, mounted, active ZFS/LVM/RAID). +# HIDDEN — disks referenced by VM/LXC (running or stopped). +# +# Safety at confirmation: +# - Disks with stale/active metadata show detailed warnings. +# - Disks used by running VMs are hard-blocked at confirmation. +# - Disks with mounted partitions are hard-blocked at execution (revalidation). +# - Double confirmation required: yesno + type full disk path. # ========================================================== - -# Configuration ============================================ -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" +LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" -VENV_PATH="/opt/googletrans-env" +UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" + +if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then + LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" + UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" +elif [[ ! -f "$UTILS_FILE" ]]; then + UTILS_FILE="$BASE_DIR/utils.sh" +fi if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi load_language initialize_cache -# ========================================================== +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" +fi +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/disk_ops_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/disk_ops_helpers.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" +fi +# shellcheck source=/dev/null +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/utils-install-functions.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/utils-install-functions.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh" +fi + +BACKTITLE="ProxMenux" +UI_MENU_H=20 +UI_MENU_W=84 +UI_MENU_LIST_H=10 +UI_MSG_H=10 +UI_MSG_W=72 +UI_YESNO_H=20 +UI_YESNO_W=86 +UI_RESULT_H=14 +UI_RESULT_W=86 +OPERATION_MODE="" +REVALIDATE_ERROR_DETAIL="" +ZFS_POOL_NAME="" +declare -A DISK_RUNNING_VM_FLAG + +# ────────────────────────────────────────────────────────────────────────────── +# Basic disk info +# ────────────────────────────────────────────────────────────────────────────── get_disk_info() { - local disk=$1 - MODEL=$(lsblk -dn -o MODEL "$disk" | xargs) - SIZE=$(lsblk -dn -o SIZE "$disk" | xargs) - echo "$MODEL" "$SIZE" + local disk="$1" model size + model=$(lsblk -dn -o MODEL "$disk" 2>/dev/null | xargs) + size=$(lsblk -dn -o SIZE "$disk" 2>/dev/null | xargs) + [[ -z "$model" ]] && model="Unknown model" + [[ -z "$size" ]] && size="Unknown size" + printf '%s\t%s' "$model" "$size" } -msg_info "$(translate "Detecting available disks...")" - -USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}') -MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') - -ZFS_DISKS="" -ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') - -for entry in $ZFS_RAW; do - path="" - if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then - if [ -e "/dev/disk/by-id/$entry" ]; then - path=$(readlink -f "/dev/disk/by-id/$entry") - fi - elif [[ "$entry" == /dev/* ]]; then - path="$entry" - fi - - if [ -n "$path" ]; then - base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) - if [ -n "$base_disk" ]; then - ZFS_DISKS+="/dev/$base_disk"$'\n' - fi - fi - -done - -ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) -LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u) -CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) - -FREE_DISKS=() - -while read -r DISK; do - [[ "$DISK" =~ /dev/zd ]] && continue - - INFO=($(get_disk_info "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" - LABEL="" - SHOW_DISK=true - - REAL_PATH=$(readlink -f "$DISK") - USED_BY="" - IS_MOUNTED=false - IS_RAID=false - IS_ZFS=false - IS_LVM=false - - while read -r part fstype; do - [[ "$fstype" == "zfs_member" ]] && IS_ZFS=true - [[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true - [[ "$fstype" == "LVM2_member" ]] && IS_LVM=true - if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then - IS_MOUNTED=true - fi - done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2) - - if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then - IS_LVM=true - fi - - if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" +# Collect command stdout with timeout protection (best-effort). +_fmt_collect_cmd() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout --kill-after=2 "${seconds}s" "$@" 2>/dev/null || true else - for SYMLINK in /dev/disk/by-id/*; do - if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then - if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - break + "$@" 2>/dev/null || true + fi +} + +# ────────────────────────────────────────────────────────────────────────────── +# Mount classification helpers +# ────────────────────────────────────────────────────────────────────────────── + +# Returns 0 if the mountpoint is part of the OS root filesystem tree. +# These mounts trigger a hard block — the disk contains the running OS. +_is_system_mount() { + local mp="$1" + case "$mp" in + /|/boot|/boot/*|/usr|/usr/*|/var|/var/*|/etc|/lib|/lib/*|/lib64|/run|/proc|/sys) + return 0 ;; + *) return 1 ;; + esac +} + +# ────────────────────────────────────────────────────────────────────────────── +# ZFS root-pool detection +# ────────────────────────────────────────────────────────────────────────────── + +# Returns the name of the ZFS pool that holds the root filesystem, or empty +# if root is on a traditional block device (ext4/xfs/btrfs). +_get_zfs_root_pool() { + local root_fs + root_fs=$(df / 2>/dev/null | awk 'NR==2 {print $1}') + # A ZFS dataset looks like "rpool/ROOT/pve-1" — not /dev/ + if [[ "$root_fs" != /dev/* && "$root_fs" == */* ]]; then + echo "${root_fs%%/*}" + fi +} + +# ────────────────────────────────────────────────────────────────────────────── +# ZFS pool membership helpers +# ────────────────────────────────────────────────────────────────────────────── + +# Resolve a raw ZFS device entry (from zpool list -v -H) to a canonical +# /dev/sdX path. Handles: full /dev/ paths, by-id names, short kernel names. +_resolve_zfs_entry() { + local entry="$1" path base + if [[ "$entry" == /dev/* ]]; then + path=$(readlink -f "$entry" 2>/dev/null) + elif [[ -e "/dev/disk/by-id/$entry" ]]; then + path=$(readlink -f "/dev/disk/by-id/$entry" 2>/dev/null) + elif [[ -e "/dev/$entry" ]]; then + path=$(readlink -f "/dev/$entry" 2>/dev/null) + fi + [[ -z "$path" ]] && return + base=$(lsblk -no PKNAME "$path" 2>/dev/null) + if [[ -n "$base" ]]; then + echo "/dev/$base" + else + echo "$path" # whole-disk vdev — path is the disk itself + fi +} + +# Emit one /dev/sdX line per disk that is a member of a SPECIFIC ZFS pool. +_build_pool_disks() { + local pool_name="$1" entry + while read -r entry; do + [[ -z "$entry" ]] && continue + _resolve_zfs_entry "$entry" + done < <(_fmt_collect_cmd 8 zpool list -v -H "$pool_name" | awk '{print $1}' | \ + grep -v '^-' | grep -v '^mirror' | grep -v '^raidz' | \ + grep -v "^${pool_name}$") +} + +# ────────────────────────────────────────────────────────────────────────────── +# VM/CT config helpers +# ────────────────────────────────────────────────────────────────────────────── + +# Return 0 if $disk appears (by real path or any by-id link) in $cfg_text. +_disk_in_config_text() { + local disk="$1" cfg_text="$2" + [[ -z "$cfg_text" ]] && return 1 + local rp link + rp=$(readlink -f "$disk" 2>/dev/null) + [[ -n "$rp" ]] && grep -qF "$rp" <<< "$cfg_text" && return 0 + for link in /dev/disk/by-id/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$rp" ]] || continue + grep -qF "$link" <<< "$cfg_text" && return 0 + done + return 1 +} + +# Return the concatenated config text of all CURRENTLY RUNNING VMs and CTs. +_get_running_vm_config_text() { + local result="" vmid state conf + while read -r vmid state; do + [[ -z "$vmid" || "$state" != "running" ]] && continue + for conf in "/etc/pve/qemu-server/${vmid}.conf" "/etc/pve/lxc/${vmid}.conf"; do + [[ -f "$conf" ]] && result+=$(grep -vE '^\s*#' "$conf" 2>/dev/null)$'\n' + done + done < <( + qm list --noborder 2>/dev/null | awk 'NR>1 {print $1, $3}' + pct list --noborder 2>/dev/null | awk 'NR>1 {print $1, $2}' + ) + printf '%s' "$result" +} + +# Wrapper for disk_referenced_in_guest_configs (uses global helper when available). +disk_referenced_in_guest_configs() { + local disk="$1" + if declare -F _disk_used_in_guest_configs >/dev/null 2>&1; then + _disk_used_in_guest_configs "$disk" + return $? + fi + local real_path config_data link + real_path=$(readlink -f "$disk" 2>/dev/null) + config_data=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) + [[ -z "$config_data" ]] && return 1 + [[ -n "$real_path" ]] && grep -Fq "$real_path" <<< "$config_data" && return 0 + for link in /dev/disk/by-id/*; do + [[ -e "$link" ]] || continue + [[ "$(readlink -f "$link" 2>/dev/null)" == "$real_path" ]] || continue + grep -Fq "$link" <<< "$config_data" && return 0 + done + return 1 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Build candidate disk list with smart classification +# ────────────────────────────────────────────────────────────────────────────── +# +# Hard blocks (disk hidden completely): +# • Any partition mounted at a system path (/, /boot, /usr, /var, etc.) +# • Disk is a member of the ZFS pool that holds the root filesystem +# • Any partition is active swap +# +# Strict free-disk policy: +# - Only show disks that are NOT used by host system and NOT referenced by +# any VM/CT config (running or stopped). +# - If a disk is shown, it is considered free for formatting. +# +# Populates: DISK_OPTIONS[] (DISK_RUNNING_VM_FLAG kept for compatibility) +# ────────────────────────────────────────────────────────────────────────────── + +build_disk_candidates() { + DISK_OPTIONS=() + DISK_RUNNING_VM_FLAG=() + + if declare -F _refresh_host_storage_cache >/dev/null 2>&1; then + _refresh_host_storage_cache + fi + + # ── Detect ZFS root pool (its disks are hard-blocked) ───────────────── + local root_pool root_pool_disks="" + root_pool=$(_get_zfs_root_pool) + [[ -n "$root_pool" ]] && root_pool_disks=$(_build_pool_disks "$root_pool" | sort -u) + + # ── Classify mounts: system (hard block) ───────────────────────────── + local sys_blocked_disks="" swap_parts + swap_parts=$(swapon --noheadings --raw --show=NAME 2>/dev/null) + + while read -r name mp; do + _is_system_mount "$mp" || continue + local parent + parent=$(lsblk -no PKNAME "/dev/$name" 2>/dev/null) + [[ -z "$parent" ]] && parent="$name" + sys_blocked_disks+="/dev/$parent"$'\n' + done < <(lsblk -ln -o NAME,MOUNTPOINT 2>/dev/null | awk '$2!=""') + sys_blocked_disks=$(sort -u <<< "$sys_blocked_disks") + + # ── Build running VM config text (done once) ────────────────────────── + local running_cfg="" vmid state conf + while read -r vmid state; do + [[ -z "$vmid" || "$state" != "running" ]] && continue + for conf in "/etc/pve/qemu-server/${vmid}.conf" "/etc/pve/lxc/${vmid}.conf"; do + [[ -f "$conf" ]] && running_cfg+=$(grep -vE '^\s*#' "$conf" 2>/dev/null)$'\n' + done + done < <( + qm list --noborder 2>/dev/null | awk 'NR>1 {print $1, $3}' + pct list --noborder 2>/dev/null | awk 'NR>1 {print $1, $2}' + ) + + # ── Main disk enumeration ───────────────────────────────────────────── + local disk ro type + while read -r disk ro type; do + [[ -z "$disk" ]] && continue + [[ "$type" != "disk" ]] && continue + [[ "$ro" == "1" ]] && continue + [[ "$disk" =~ ^/dev/zd ]] && continue + + local real_disk + real_disk=$(readlink -f "$disk" 2>/dev/null) + + # ── Hard blocks ─────────────────────────────────────────────────── + + # Disk contains a system-critical mount (/, /boot, /usr, /var, ...) + grep -qFx "$disk" <<< "$sys_blocked_disks" && continue + [[ -n "$real_disk" ]] && grep -qFx "$real_disk" <<< "$sys_blocked_disks" && continue + + # Disk has an active swap partition + local has_swap=0 part_name + while read -r part_name; do + [[ -z "$part_name" ]] && continue + grep -qFx "/dev/$part_name" <<< "$swap_parts" && { has_swap=1; break; } + done < <(lsblk -ln -o NAME "$disk" 2>/dev/null) + (( has_swap )) && continue + + # Disk is a member of the ZFS root pool + grep -qFx "$disk" <<< "$root_pool_disks" && continue + [[ -n "$real_disk" ]] && grep -qFx "$real_disk" <<< "$root_pool_disks" && continue + + # Running VM/CT reference => show but flag for hard block at confirmation + if _disk_in_config_text "$disk" "$running_cfg"; then + DISK_RUNNING_VM_FLAG["$disk"]="1" + else + DISK_RUNNING_VM_FLAG["$disk"]="0" + fi + # NOTE: stopped VM reference, active ZFS/LVM/RAID, and mounted data + # partitions are NOT hidden — they show with metadata warnings in the + # confirmation dialog. The revalidation step handles auto-unmount/export. + + # ── Build display label ─────────────────────────────────────────── + local model size desc + IFS=$'\t' read -r model size < <(get_disk_info "$disk") + + desc=$(printf "%-30s %10s" "$model" "$size") + + DISK_OPTIONS+=("$disk" "$desc" "OFF") + done < <(lsblk -dn -e 7,11 -o PATH,RO,TYPE 2>/dev/null) +} + +# ────────────────────────────────────────────────────────────────────────────── +# Disk selection dialog +# ────────────────────────────────────────────────────────────────────────────── + +select_target_disk() { + build_disk_candidates + + if [[ ${#DISK_OPTIONS[@]} -eq 0 ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "No Disks Available")" \ + --msgbox "\n$(translate "No format-safe disks are available.")\n\n$(translate "Only fully free disks are shown (not system-used and not referenced by VM/LXC).")" \ + $UI_RESULT_H $UI_RESULT_W + return 1 + fi + + local prompt_text + prompt_text="\n$(translate "Select the disk you want to format:")" + + local max_width total_width selected + max_width=$(printf "%s\n" "${DISK_OPTIONS[@]}" | awk '{print length}' | sort -nr | head -n1) + total_width=$((max_width + 22)) + (( total_width < UI_MENU_W )) && total_width=$UI_MENU_W + (( total_width > 116 )) && total_width=116 + + selected=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select Disk")" \ + --radiolist "$prompt_text" $UI_MENU_H "$total_width" $UI_MENU_LIST_H \ + "${DISK_OPTIONS[@]}" \ + 2>&1 >/dev/tty) || return 1 + + [[ -z "$selected" ]] && return 1 + SELECTED_DISK="$selected" + return 0 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Metadata flag reader (for confirmation dialog display) +# ────────────────────────────────────────────────────────────────────────────── + +get_disk_metadata_flags() { + local disk="$1" flags="" fstype mp + while read -r fstype; do + case "$fstype" in + linux_raid_member) [[ "$flags" != *"RAID"* ]] && flags+=" RAID" ;; + LVM2_member) [[ "$flags" != *"LVM"* ]] && flags+=" LVM" ;; + zfs_member) [[ "$flags" != *"ZFS"* ]] && flags+=" ZFS" ;; + esac + done < <(lsblk -ln -o FSTYPE "$disk" 2>/dev/null | awk 'NF') + # Mounted data partitions + while read -r mp; do + [[ -z "$mp" ]] && continue + _is_system_mount "$mp" && continue + [[ "$flags" != *"MOUNT"* ]] && flags+=" MOUNT ($mp)" + done < <(lsblk -ln -o MOUNTPOINT "$disk" 2>/dev/null | awk 'NF') + echo "$flags" +} + +# ────────────────────────────────────────────────────────────────────────────── +# Confirmation dialogs +# ────────────────────────────────────────────────────────────────────────────── + +confirm_format_action() { + # Hard block: disk is currently referenced by a RUNNING VM/CT + if [[ "${DISK_RUNNING_VM_FLAG[$SELECTED_DISK]:-0}" == "1" ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Disk In Use by Running VM")" \ + --msgbox "\n⛔ $(translate "CRITICAL: The selected disk is referenced by a RUNNING VM or CT.")\n\n$(translate "Stop the VM/CT before formatting this disk.")" \ + $UI_RESULT_H $UI_RESULT_W + return 1 + fi + + local model size flags msg typed + IFS=$'\t' read -r model size < <(get_disk_info "$SELECTED_DISK") + flags=$(get_disk_metadata_flags "$SELECTED_DISK") + + msg="$(translate "Target disk"): $SELECTED_DISK\n" + msg+="$(translate "Model"): $model\n" + msg+="$(translate "Size"): $size\n" + case "$OPERATION_MODE" in + wipe_all) + msg+="$(translate "Operation"): $(translate "Wipe all — remove partitions + metadata")\n" ;; + clean_sigs) + msg+="$(translate "Operation"): $(translate "Remove FS labels — partitions and data preserved")\n" ;; + wipe_data) + msg+="$(translate "Operation"): $(translate "Zero all data — partition table preserved")\n" ;; + clean_and_format) + msg+="$(translate "Operation"): $(translate "Full format: clean + new GPT partition + filesystem")\n" ;; + esac + [[ -n "$flags" ]] && msg+="$(translate "Detected"): $flags\n" + + # Stopped VM warning + if disk_referenced_in_guest_configs "$SELECTED_DISK"; then + msg+="\n⚠ $(translate "WARNING: This disk is referenced in a stopped VM/LXC config.")\n" + msg+="$(translate "The VM/LXC will lose access to this disk after formatting.")\n" + fi + + # Mounted partition warning + if [[ "$flags" == *"MOUNT"* ]]; then + msg+="\n⚠ $(translate "WARNING: This disk has a mounted partition.")\n" + msg+="$(translate "Unmount it before proceeding. The script will verify this at execution.")\n" + fi + + msg+="\n$(translate "WARNING: This will ERASE all data on this disk.")\n" + msg+="$(translate "Do you want to continue?")" + + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Confirm Format")" \ + --yesno "\n$msg" $UI_YESNO_H $UI_YESNO_W || return 1 + + typed=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Final Confirmation")" \ + --inputbox "$(translate "Type the full disk path to confirm"):\n$SELECTED_DISK" $UI_MSG_H $UI_MSG_W \ + 2>&1 >/dev/tty) || return 1 + + if [[ "$typed" != "$SELECTED_DISK" ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Confirmation Failed")" \ + --msgbox "\n$(translate "Typed value does not match selected disk. Operation cancelled.")" $UI_MSG_H $UI_MSG_W + return 1 + fi + return 0 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Operation and filesystem selection +# ────────────────────────────────────────────────────────────────────────────── + +select_operation_mode() { + local selected + selected=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Format Mode")" \ + --menu "\n$(translate "Choose what to do with the selected disk:")" 16 76 4 \ + "1" "$(translate "Wipe all — erase partitions + metadata")" \ + "2" "$(translate "Remove FS labels — partitions and data preserved")" \ + "3" "$(translate "Zero all data — partition table preserved, data wiped")" \ + "4" "$(translate "Full format — new GPT partition + filesystem")" \ + 2>&1 >/dev/tty) || return 1 + + [[ -z "$selected" ]] && return 1 + case "$selected" in + 1) OPERATION_MODE="wipe_all" ;; + 2) OPERATION_MODE="clean_sigs" ;; + 3) OPERATION_MODE="wipe_data" ;; + 4) OPERATION_MODE="clean_and_format" ;; + *) return 1 ;; + esac + return 0 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Pre-execution safety revalidation +# Refreshes state and blocks if the selected disk becomes system-critical, +# mounted, swapped, or referenced by a running VM/CT. +# ────────────────────────────────────────────────────────────────────────────── + +revalidate_selected_disk() { + REVALIDATE_ERROR_DETAIL="" + + if declare -F _refresh_host_storage_cache >/dev/null 2>&1; then + _refresh_host_storage_cache + fi + + # Hard block: disk now contains a system-critical mount + local name mp parent + while read -r name mp; do + _is_system_mount "$mp" || continue + parent=$(lsblk -no PKNAME "/dev/$name" 2>/dev/null) + [[ "/dev/${parent:-$name}" == "$SELECTED_DISK" ]] && { + REVALIDATE_ERROR_DETAIL="$(translate "The selected disk now contains a system-critical mount. Aborting.")" + return 1 + } + done < <(lsblk -ln -o NAME,MOUNTPOINT 2>/dev/null | awk '$2!=""') + + # Hard block: disk is now a member of the ZFS root pool + local root_pool root_pool_disks + root_pool=$(_get_zfs_root_pool) + if [[ -n "$root_pool" ]]; then + root_pool_disks=$(_build_pool_disks "$root_pool" | sort -u) + if grep -qFx "$SELECTED_DISK" <<< "$root_pool_disks"; then + REVALIDATE_ERROR_DETAIL="$(translate "The selected disk is now part of the system ZFS pool. Aborting.")" + return 1 + fi + fi + + # Hard block: disk has a swap partition + local swap_parts pname + swap_parts=$(swapon --noheadings --raw --show=NAME 2>/dev/null) + while read -r pname; do + [[ -z "$pname" ]] && continue + if grep -qFx "/dev/$pname" <<< "$swap_parts"; then + REVALIDATE_ERROR_DETAIL="$(translate "The selected disk has an active swap partition. Aborting.")" + return 1 + fi + done < <(lsblk -ln -o NAME "$SELECTED_DISK" 2>/dev/null) + + # Auto-unmount data partitions still mounted on this disk + while read -r pname mp; do + [[ -z "$mp" ]] && continue + _is_system_mount "$mp" && continue # already blocked above + local disk_of_part + disk_of_part=$(lsblk -no PKNAME "/dev/$pname" 2>/dev/null) + [[ "/dev/${disk_of_part:-$pname}" == "$SELECTED_DISK" ]] || continue + if ! umount "/dev/$pname" 2>/dev/null; then + REVALIDATE_ERROR_DETAIL="$(translate "Partition") /dev/$pname $(translate "is mounted at") $mp $(translate "and could not be unmounted — disk may be busy.")" + return 1 + fi + done < <(lsblk -ln -o NAME,MOUNTPOINT "$SELECTED_DISK" 2>/dev/null | awk '$2!=""') + + # Auto-export any active ZFS pool that contains this disk + local pool + while read -r pool; do + [[ -z "$pool" ]] && continue + if _build_pool_disks "$pool" 2>/dev/null | grep -qFx "$SELECTED_DISK"; then + zpool export "$pool" 2>/dev/null || true + fi + done < <(_fmt_collect_cmd 5 zpool list -H -o name 2>/dev/null) + + # Hard block: disk is currently referenced by a RUNNING VM or CT + local running_cfg + running_cfg=$(_get_running_vm_config_text) + if _disk_in_config_text "$SELECTED_DISK" "$running_cfg"; then + REVALIDATE_ERROR_DETAIL="$(translate "The selected disk is currently used by a RUNNING VM or CT. Stop it before formatting.")" + return 1 + fi + + return 0 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Filesystem selection and ZFS pool name prompt +# ────────────────────────────────────────────────────────────────────────────── + +select_filesystem() { + local selected + selected=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select Filesystem")" \ + --menu "\n$(translate "Choose the filesystem for the new partition:")" 18 76 8 \ + "ext4" "$(translate "Extended Filesystem 4 (recommended)")" \ + "xfs" "XFS" \ + "exfat" "$(translate "exFAT (portable: Windows/Linux/macOS)")" \ + "btrfs" "Btrfs" \ + "zfs" "ZFS" \ + 2>&1 >/dev/tty) || return 1 + [[ -z "$selected" ]] && return 1 + FORMAT_TYPE="$selected" + return 0 +} + +prompt_zfs_pool_name() { + local disk_suffix suggested name + disk_suffix=$(basename "$SELECTED_DISK" | sed 's|[^a-zA-Z0-9_-]|-|g') + suggested="pool_${disk_suffix}" + + name=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "ZFS Pool Name")" \ + --inputbox "$(translate "Enter ZFS pool name for the selected disk:")" \ + 10 72 "$suggested" 2>&1 >/dev/tty) || return 1 + + [[ -n "$name" ]] || return 1 + if [[ ! "$name" =~ ^[a-zA-Z][a-zA-Z0-9_.:-]*$ ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Invalid name")" \ + --msgbox "\n$(translate "Invalid ZFS pool name.")" $UI_MSG_H $UI_MSG_W + return 1 + fi + if zpool list "$name" >/dev/null 2>&1; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Pool exists")" \ + --msgbox "\n$(translate "A ZFS pool with this name already exists.")\n\n$name" $UI_MSG_H $UI_RESULT_W + return 1 + fi + + ZFS_POOL_NAME="$name" + return 0 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Filesystem tool check / install +# ────────────────────────────────────────────────────────────────────────────── + +ensure_fs_tool() { + case "$FORMAT_TYPE" in + exfat) + command -v mkfs.exfat >/dev/null 2>&1 && return 0 + if declare -F ensure_repositories >/dev/null 2>&1; then + ensure_repositories || true + fi + if DEBIAN_FRONTEND=noninteractive apt-get install -y exfatprogs >/dev/null 2>&1; then + command -v mkfs.exfat >/dev/null 2>&1 && { + msg_ok "$(translate "exFAT tools installed successfully.")" + return 0 + } + fi + msg_error "$(translate "Could not install exFAT tools automatically.")" + msg_info3 "$(translate "Install manually and retry: apt-get install -y exfatprogs")" + return 1 + ;; + btrfs) + command -v mkfs.btrfs >/dev/null 2>&1 && return 0 + msg_error "$(translate "mkfs.btrfs not found. Install btrfs-progs and retry.")" + return 1 + ;; + zfs) + command -v zpool >/dev/null 2>&1 && command -v zfs >/dev/null 2>&1 && return 0 + msg_error "$(translate "ZFS tools not found. Install zfsutils-linux and retry.")" + return 1 + ;; + esac + return 0 +} + +# ────────────────────────────────────────────────────────────────────────────── +# Terminal phase helpers +# ────────────────────────────────────────────────────────────────────────────── + +show_terminal_stage_header() { + show_proxmenux_logo + msg_title "$(translate "Secure Disk Formatter")" +} + +wait_for_enter_to_main() { + echo + msg_success "$(translate "Press Enter to return to menu...")" + read -r +} + +# ────────────────────────────────────────────────────────────────────────────── +# Main +# ────────────────────────────────────────────────────────────────────────────── + +main() { + select_target_disk || exit 0 + select_operation_mode || exit 0 + confirm_format_action || exit 0 + + if [[ "$OPERATION_MODE" == "clean_and_format" ]]; then + select_filesystem || exit 0 + if [[ "$FORMAT_TYPE" == "zfs" ]]; then + prompt_zfs_pool_name || exit 0 + fi + fi + + show_terminal_stage_header + local _model _size + IFS=$'\t' read -r _model _size < <(get_disk_info "$SELECTED_DISK") + msg_ok "$(translate "Disk"): ${CL}${BL}$SELECTED_DISK — $_model $_size${CL}" + case "$OPERATION_MODE" in + wipe_all) msg_ok "$(translate "Mode"): $(translate "Wipe all — remove partitions + metadata")" ;; + clean_sigs) msg_ok "$(translate "Mode"): $(translate "Remove FS labels — partitions and data preserved")" ;; + wipe_data) msg_ok "$(translate "Mode"): $(translate "Zero all data — partition table preserved")" ;; + clean_and_format) msg_ok "$(translate "Mode"): $(translate "Full format — new GPT partition + filesystem")" + msg_ok "$(translate "Filesystem"): $FORMAT_TYPE" + [[ "$FORMAT_TYPE" == "zfs" ]] && msg_ok "$(translate "ZFS pool"): $ZFS_POOL_NAME" ;; + esac + echo + + if [[ "$OPERATION_MODE" == "clean_and_format" ]]; then + if ! ensure_fs_tool; then + wait_for_enter_to_main + exit 1 + fi + fi + + msg_info "$(translate "Validating disk safety...")" + if ! revalidate_selected_disk; then + msg_error "${REVALIDATE_ERROR_DETAIL:-$(translate "Disk safety revalidation failed.")}" + wait_for_enter_to_main + exit 1 + fi + msg_ok "$(translate "Disk safety validation passed.")" + + # ── Execute the selected operation ──────────────────────────────────────── + export DOH_SHOW_PROGRESS=0 + export DOH_ENABLE_STACK_RELEASE=0 + + if [[ "$OPERATION_MODE" == "wipe_all" ]]; then + msg_info "$(translate "Wiping partitions and metadata...")" + doh_wipe_disk "$SELECTED_DISK" + msg_ok "$(translate "All partitions and metadata removed.")" + echo + msg_success "$(translate "Disk is ready to be added to Proxmox storage.")" + echo + wait_for_enter_to_main + exit 0 + fi + + if [[ "$OPERATION_MODE" == "clean_sigs" ]]; then + msg_info "$(translate "Removing filesystem signatures...")" + wipefs -af "$SELECTED_DISK" >/dev/null 2>&1 || true + local pname + while read -r pname; do + [[ -z "$pname" ]] && continue + [[ "/dev/$pname" == "$SELECTED_DISK" ]] && continue + [[ -b "/dev/$pname" ]] && wipefs -af "/dev/$pname" >/dev/null 2>&1 || true + done < <(lsblk -ln -o NAME "$SELECTED_DISK" 2>/dev/null | tail -n +2) + msg_ok "$(translate "Signatures removed. Partition table preserved.")" + echo + msg_success "$(translate "Disk is ready for VM passthrough.")" + echo + wait_for_enter_to_main + exit 0 + fi + + if [[ "$OPERATION_MODE" == "wipe_data" ]]; then + local wiped=0 part_path + while read -r pname; do + [[ -z "$pname" ]] && continue + part_path="/dev/$pname" + [[ "$part_path" == "$SELECTED_DISK" ]] && continue + if [[ -b "$part_path" ]]; then + msg_info "$(translate "Zeroing partition"): $part_path" + if dd if=/dev/zero of="$part_path" bs=4M status=none 2>/dev/null; then + msg_ok "$part_path $(translate "zeroed.")" + wiped=$((wiped + 1)) + else + msg_warn "$(translate "Could not fully zero"): $part_path" fi fi - done - fi - - if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then - if grep -q "active raid" /proc/mdstat; then - SHOW_DISK=false + done < <(lsblk -ln -o NAME "$SELECTED_DISK" 2>/dev/null | tail -n +2) + echo + if (( wiped == 0 )); then + msg_warn "$(translate "No partitions found on disk. Nothing was wiped.")" + else + msg_ok "$(translate "Data wiped from") $wiped $(translate "partition(s). Partition table preserved.")" + echo + msg_success "$(translate "Data wipe complete.")" fi + echo + wait_for_enter_to_main + exit 0 fi - if $IS_ZFS || $IS_MOUNTED; then - SHOW_DISK=false + # OPERATION_MODE == "clean_and_format" + msg_info "$(translate "Cleaning disk metadata...")" + doh_wipe_disk "$SELECTED_DISK" + msg_ok "$(translate "Disk metadata cleaned.")" + + msg_info "$(translate "Creating partition...")" + if ! doh_create_partition "$SELECTED_DISK"; then + msg_error "$(translate "Failed to create partition.")" + local detail_msg + detail_msg="$(printf '%s' "$DOH_PARTITION_ERROR_DETAIL" | head -n 3)" + [[ -n "$detail_msg" ]] && msg_warn "$(translate "Details"): $detail_msg" + wait_for_enter_to_main + exit 1 fi + PARTITION="$DOH_CREATED_PARTITION" + msg_ok "$(translate "Partition created"): $PARTITION" - if $SHOW_DISK; then - [[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID" - [[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]" - [[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM" - [[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS" - DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL") - FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF") - fi - -done < <(lsblk -dn -e 7,11 -o PATH) - -cleanup - -if [ ${#FREE_DISKS[@]} -eq 0 ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No available disks found on the host.")" 8 50 - clear - exit 1 -fi - -msg_ok "$(translate "Available disks detected.")" - -MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1) -TOTAL_WIDTH=$((MAX_WIDTH + 20)) -TOTAL_WIDTH=$((TOTAL_WIDTH < 50 ? 50 : TOTAL_WIDTH)) - -SELECTED=$(whiptail --title "$(translate "Select Disk")" --radiolist \ - "$(translate "Select the disk you want to format:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) - -if [ -z "$SELECTED" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64 - clear - exit 1 -fi - -SELECTED=$(echo "$SELECTED" | tr -d '"') -SELECTED_DISK="$SELECTED" - - -REAL_PATH=$(readlink -f "$SELECTED") -CT_MATCH="" -VM_MATCH="" - -while read -r CT_ID CT_NAME; do - if pct config "$CT_ID" | grep -q "$REAL_PATH"; then - STATUS=$(pct status "$CT_ID" | awk '{print $2}') - if [[ "$STATUS" == "running" ]]; then - CT_MATCH="CT $CT_ID ($CT_NAME)" - break + msg_info "$(translate "Formatting") $PARTITION $(translate "as") $FORMAT_TYPE..." + if doh_format_partition "$PARTITION" "$FORMAT_TYPE" "" "$ZFS_POOL_NAME"; then + if [[ "$FORMAT_TYPE" == "zfs" ]]; then + msg_ok "$(translate "ZFS pool created"): $ZFS_POOL_NAME" + else + msg_ok "$PARTITION $(translate "formatted as") $FORMAT_TYPE" fi + echo + msg_success "$(translate "Disk formatted successfully.")" + echo + wait_for_enter_to_main + exit 0 fi -done < <(pct list | awk 'NR>1 {print $1, $3}') -while read -r VM_ID VM_NAME; do - if qm config "$VM_ID" | grep -q "$REAL_PATH"; then - STATUS=$(qm status "$VM_ID" | awk '{print $2}') - if [[ "$STATUS" == "running" ]]; then - VM_MATCH="VM $VM_ID ($VM_NAME)" - break - fi - fi -done < <(qm list | awk 'NR>1 {print $1, $2}') - -if [[ -n "$CT_MATCH" || -n "$VM_MATCH" ]]; then - whiptail --title "$(translate "Disk In Use")" --msgbox "$(translate "The selected disk is currently assigned to:")\n\n$CT_MATCH $VM_MATCH\n\n$(translate "You must power off the VM or CT before formatting.")" 12 70 + msg_error "$(translate "Failed to format the partition.")" + [[ -n "$DOH_FORMAT_ERROR_DETAIL" ]] && msg_warn "$(translate "Details"): $DOH_FORMAT_ERROR_DETAIL" + echo + wait_for_enter_to_main exit 1 -fi +} - - - - - - - -######################################################### - - - - -SELECTED_DISK=$(echo "$SELECTED_DISK" | tr -d '"') - -WARNING_FLAGS="" -if lsblk -no FSTYPE "$SELECTED_DISK" | grep -q "linux_raid_member"; then - WARNING_FLAGS+=" RAID" -fi -if lsblk -no FSTYPE "$SELECTED_DISK" | grep -q "LVM2_member"; then - WARNING_FLAGS+=" LVM" -fi -if lsblk -no FSTYPE "$SELECTED_DISK" | grep -q "zfs_member"; then - WARNING_FLAGS+=" ZFS" -fi - -if [ -n "$WARNING_FLAGS" ]; then - whiptail --title "$(translate "Warning")" --msgbox "$(translate "This disk appears to have the following metadata:")$WARNING_FLAGS\\n\\n$(translate "They will be erased during formatting.")" 10 60 -fi - -whiptail --title "$(translate "Confirm Format")" --yesno "$(translate "WARNING: You are about to erase all data on")\\n$SELECTED_DISK\\n\\n$(translate "Are you sure you want to continue?")" 10 70 || exit 0 - -whiptail --title "$(translate "Final Confirmation")" --yesno "$(translate "FINAL WARNING: This operation will completely format the disk")\\n$SELECTED_DISK\\n\\n$(translate "ALL DATA WILL BE LOST. Proceed?")" 10 70 || exit 0 - - - - -######################################## - - - -echo -e "$(translate "Stopping residual RAID or device mappings...")" - -mdadm --misc --stop /dev/md* >/dev/null 2>&1 -dmsetup remove_all >/dev/null 2>&1 - -echo -e "$(translate "Wiping disk metadata and old RAID signatures...")" - -sgdisk --zap-all "$SELECTED_DISK" >/dev/null 2>&1 -wipefs -a "$SELECTED_DISK" >/dev/null 2>&1 - -udevadm settle -partprobe "$SELECTED_DISK" -sleep 2 - -echo -e "$(translate "Creating partition table and partition...")" - -parted -s "$SELECTED_DISK" mklabel gpt -parted -s "$SELECTED_DISK" mkpart primary 0% 100% - -udevadm settle -partprobe "$SELECTED_DISK" -sleep 2 - - - - - - -########################################### - - - -udevadm settle -partprobe "$SELECTED_DISK" -sleep 2 - - -PARTITION=$(lsblk -rno NAME "$SELECTED_DISK" | awk -v disk="$(basename "$SELECTED_DISK")" '$1 != disk {print $1; exit}') -if [ -z "$PARTITION" ]; then - whiptail --title "$(translate "Partition Error")" --msgbox "$(translate "Failed to create partition on disk.")" 8 60 - exit 1 -fi -PARTITION="/dev/$PARTITION" - -FORMAT_TYPE=$(whiptail --title "$(translate "Select Filesystem")" --menu "$(translate "Choose the filesystem for the disk:")" 15 60 5 \ - "ext4" "$(translate "Extended Filesystem 4 (recommended)")" \ - "xfs" "XFS" \ - "btrfs" "Btrfs" 3>&1 1>&2 2>&3) - -[[ -z "$FORMAT_TYPE" ]] && exit 0 - -echo -e "$(translate "Formatting partition") $PARTITION $(translate "as") $FORMAT_TYPE..." - -case "$FORMAT_TYPE" in - ext4) mkfs.ext4 -F "$PARTITION" ;; - xfs) mkfs.xfs -f "$PARTITION" ;; - btrfs) mkfs.btrfs -f "$PARTITION" ;; -esac - -if [ $? -eq 0 ]; then - msg_ok "$(translate "Disk formatted successfully:") $PARTITION" - whiptail --title "$(translate "Success")" --msgbox "$(translate "Disk has been formatted successfully.")" 8 50 -else - whiptail --title "$(translate "Error")" --msgbox "$(translate "Failed to format the disk.")" 8 60 - exit 1 -fi +main "$@" diff --git a/scripts/storage/import-disk-image.sh b/scripts/storage/import-disk-image.sh index 145c5869..d3c70790 100644 --- a/scripts/storage/import-disk-image.sh +++ b/scripts/storage/import-disk-image.sh @@ -6,24 +6,14 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.1 -# Last Updated: 29/05/2025 +# Version : 1.3 +# Last Updated: 12/04/2026 # ========================================================== # Description: -# This script automates the process of importing disk images into Proxmox VE virtual machines (VMs), -# making it easy to attach pre-existing disk files without manual configuration. -# -# Before running the script, ensure that disk images are available in /var/lib/vz/template/images/. -# The script scans this directory for compatible formats (.img, .qcow2, .vmdk, .raw) and lists the available files. -# -# Using an interactive menu, you can: -# - Select a VM to attach the imported disk. -# - Choose one or multiple disk images for import. -# - Pick a storage volume in Proxmox for disk placement. -# - Assign a suitable interface (SATA, SCSI, VirtIO, or IDE). -# - Enable optional settings like SSD emulation or bootable disk configuration. -# -# Once completed, the script ensures the selected images are correctly attached and ready to use. +# Imports disk images (.img, .qcow2, .vmdk, .raw) into Proxmox VE VMs. +# Supports the default system ISO directory and custom paths. +# All user decisions are collected in Phase 1 (dialogs) before +# any operation is executed in Phase 2 (terminal output). # ========================================================== # Configuration ============================================ @@ -31,256 +21,340 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" BASE_DIR="/usr/local/share/proxmenux" UTILS_FILE="$BASE_DIR/utils.sh" VENV_PATH="/opt/googletrans-env" +BACKTITLE="ProxMenux" +UI_MENU_H=20 +UI_MENU_W=84 +UI_MENU_LIST_H=10 +UI_SHORT_MENU_H=16 +UI_SHORT_MENU_W=72 +UI_SHORT_MENU_LIST_H=6 +UI_MSG_H=10 +UI_MSG_W=72 +UI_YESNO_H=10 +UI_YESNO_W=72 +UI_RESULT_H=14 +UI_RESULT_W=86 +# shellcheck source=/dev/null [[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE" load_language initialize_cache # Configuration ============================================ - -detect_image_dir() { - for store in $(pvesm status -content images | awk 'NR>1 {print $1}'); do +_get_default_images_dir() { + for dir in /var/lib/vz/template/iso /var/lib/vz/template/images; do + [[ -d "$dir" ]] && echo "$dir" && return 0 + done + local store path + for store in $(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1}'); do path=$(pvesm path "${store}:template" 2>/dev/null) - if [[ -d "$path" ]]; then - for ext in raw img qcow2 vmdk; do - if compgen -G "$path/*.$ext" > /dev/null; then - echo "$path" - return 0 - fi - done - for sub in images iso; do - dir="$path/$sub" - if [[ -d "$dir" ]]; then - for ext in raw img qcow2 vmdk; do - if compgen -G "$dir/*.$ext" > /dev/null; then - echo "$dir" - return 0 - fi - done - fi - done - fi + [[ -d "$path" ]] && echo "$path" && return 0 done - for fallback in /var/lib/vz/template/images /var/lib/vz/template/iso; do - if [[ -d "$fallback" ]]; then - for ext in raw img qcow2 vmdk; do - if compgen -G "$fallback/*.$ext" > /dev/null; then - echo "$fallback" - return 0 - fi - done - fi - done - return 1 + echo "/var/lib/vz/template/iso" } -IMAGES_DIR=$(detect_image_dir) -if [[ -z "$IMAGES_DIR" ]]; then - dialog --title "$(translate 'No Images Found')" \ - --msgbox "$(translate 'Could not find any directory containing disk images')\n\n$(translate 'Make sure there is at least one file with extension .img, .qcow2, .vmdk or .raw')" 15 60 +# ========================================================== +# PHASE 1 — SELECTION +# All dialogs run here. No execution, no show_proxmenux_logo. +# ========================================================== + +# ── Step 1: Select VM ───────────────────────────────────── +VM_OPTIONS=() +while read -r vmid vmname _rest; do + VM_OPTIONS+=("$vmid" "${vmname:-VM-$vmid}") +done < <(qm list 2>/dev/null | awk 'NR>1') +stop_spinner + +if [[ ${#VM_OPTIONS[@]} -eq 0 ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'No VMs Found')" \ + --msgbox "\n$(translate 'No VMs available in the system.')" \ + $UI_MSG_H $UI_MSG_W exit 1 fi -IMAGES=$(ls -A "$IMAGES_DIR" | grep -E "\.(img|qcow2|vmdk|raw)$") -if [ -z "$IMAGES" ]; then - dialog --title "$(translate 'No Disk Images Found')" \ - --msgbox "$(translate 'No compatible disk images found in:')\n\n$IMAGES_DIR\n\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" 15 60 - exit 1 -fi - - -# 1. Select VM -msg_info "$(translate 'Getting VM list')" -VM_LIST=$(qm list | awk 'NR>1 {print $1" "$2}') -if [ -z "$VM_LIST" ]; then - msg_error "$(translate 'No VMs available in the system')" - exit 1 -fi -msg_ok "$(translate 'VM list obtained')" - -VMID=$(whiptail --title "$(translate 'Select VM')" --menu "$(translate 'Select the VM where you want to import the disk image:')" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3) - -if [ -z "$VMID" ]; then - - exit 1 -fi - - - -# 2. Select storage volume -msg_info "$(translate 'Getting storage volumes')" -STORAGE_LIST=$(pvesm status -content images | awk 'NR>1 {print $1}') -if [ -z "$STORAGE_LIST" ]; then - msg_error "$(translate 'No storage volumes available')" - exit 1 -fi -msg_ok "$(translate 'Storage volumes obtained')" +VMID=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Select VM')" \ + --menu "$(translate 'Select the VM where you want to import the disk image:')" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${VM_OPTIONS[@]}" \ + 2>&1 >/dev/tty) +[[ -z "$VMID" ]] && exit 0 +# ── Step 2: Select storage ──────────────────────────────── STORAGE_OPTIONS=() -while read -r storage; do - STORAGE_OPTIONS+=("$storage" "") -done <<< "$STORAGE_LIST" +while read -r storage type _rest; do + STORAGE_OPTIONS+=("$storage" "$type") +done < <(pvesm status -content images 2>/dev/null | awk 'NR>1') +stop_spinner -STORAGE=$(whiptail --title "$(translate 'Select Storage')" --menu "$(translate 'Select the storage volume for disk import:')" 15 60 8 "${STORAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3) +if [[ ${#STORAGE_OPTIONS[@]} -eq 0 ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'No Storage Found')" \ + --msgbox "\n$(translate 'No storage volumes available for disk images.')" \ + $UI_MSG_H $UI_MSG_W + exit 1 +fi -if [ -z "$STORAGE" ]; then - - exit 1 +if [[ ${#STORAGE_OPTIONS[@]} -eq 2 ]]; then + # Only one storage available — auto-select it + STORAGE="${STORAGE_OPTIONS[0]}" +else + STORAGE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Select Storage')" \ + --menu "$(translate 'Select the storage volume for disk import:')" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${STORAGE_OPTIONS[@]}" \ + 2>&1 >/dev/tty) + [[ -z "$STORAGE" ]] && exit 0 fi +# ── Step 3: Select image source directory ──────────────── +ISO_DIR="/var/lib/vz/template/iso" -# 3. Select disk images -msg_info "$(translate 'Scanning disk images')" -if [ -z "$IMAGES" ]; then - msg_warn "$(translate 'No compatible disk images found in') $IMAGES_DIR" - exit 0 +DIR_CHOICE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Image Source Directory')" \ + --menu "$(translate 'Select the directory containing disk images:')" \ + $UI_SHORT_MENU_H $UI_MENU_W $UI_SHORT_MENU_LIST_H \ + "$ISO_DIR" "$(translate 'Default ISO directory')" \ + "custom" "$(translate 'Custom path...')" \ + 2>&1 >/dev/tty) +[[ -z "$DIR_CHOICE" ]] && exit 0 + +if [[ "$DIR_CHOICE" == "custom" ]]; then + IMAGES_DIR=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Custom Directory')" \ + --inputbox "\n$(translate 'Enter the full path to the directory containing disk images:')\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" \ + 10 $UI_RESULT_W "" \ + 2>&1 >/dev/tty) + [[ -z "$IMAGES_DIR" ]] && exit 0 +else + IMAGES_DIR="$ISO_DIR" fi -msg_ok "$(translate 'Disk images found')" +if [[ ! -d "$IMAGES_DIR" ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Directory Not Found')" \ + --msgbox "\n$(translate 'The specified directory does not exist:')\n\n$IMAGES_DIR" \ + $UI_MSG_H $UI_MSG_W + exit 1 +fi + +IMAGES=$(find "$IMAGES_DIR" -maxdepth 1 -type f \ + \( -name "*.img" -o -name "*.qcow2" -o -name "*.vmdk" -o -name "*.raw" \) \ + -printf '%f\n' 2>/dev/null | sort) + +if [[ -z "$IMAGES" ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'No Disk Images Found')" \ + --msgbox "\n$(translate 'No compatible disk images found in:')\n\n$IMAGES_DIR\n\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" \ + $UI_RESULT_H $UI_RESULT_W + exit 1 +fi + + +# ── Step 4: Select images ───────────────────────────────── IMAGE_OPTIONS=() -while read -r img; do - IMAGE_OPTIONS+=("$img" "" "OFF") +while IFS= read -r img; do + IMAGE_OPTIONS+=("$img" "" "OFF") done <<< "$IMAGES" -SELECTED_IMAGES=$(whiptail --title "$(translate 'Select Disk Images')" --checklist "$(translate 'Select the disk images to import:')" 20 60 10 "${IMAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3) +SELECTED_IMAGES_STR=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Select Disk Images')" \ + --checklist "$(translate 'Select one or more disk images to import:')" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${IMAGE_OPTIONS[@]}" \ + 2>&1 >/dev/tty) +[[ -z "$SELECTED_IMAGES_STR" ]] && exit 0 -if [ -z "$SELECTED_IMAGES" ]; then - - exit 1 +eval "declare -a SELECTED_ARRAY=($SELECTED_IMAGES_STR)" + + +# ── Step 5: Per-image options ───────────────────────────── +declare -a IMG_NAMES=() +declare -a IMG_INTERFACES=() +declare -a IMG_SSD_OPTIONS=() +declare -a IMG_BOOTABLE=() + +for IMAGE in "${SELECTED_ARRAY[@]}"; do + IMAGE="${IMAGE//\"/}" + + INTERFACE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Interface Type') — $IMAGE" \ + --default-item "scsi" \ + --menu "$(translate 'Select the interface type for:') $IMAGE" \ + $UI_SHORT_MENU_H $UI_SHORT_MENU_W $UI_SHORT_MENU_LIST_H \ + "scsi" "SCSI $(translate '(recommended)')" \ + "virtio" "VirtIO" \ + "sata" "SATA" \ + "ide" "IDE" \ + 2>&1 >/dev/tty) + [[ -z "$INTERFACE" ]] && continue + + SSD_OPTION="" + if [[ "$INTERFACE" != "virtio" ]]; then + if dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'SSD Emulation') — $IMAGE" \ + --yesno "\n$(translate 'Enable SSD emulation for this disk?')" \ + $UI_YESNO_H $UI_YESNO_W; then + SSD_OPTION=",ssd=1" + fi + fi + + BOOTABLE="no" + if dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Boot Disk') — $IMAGE" \ + --yesno "\n$(translate 'Set this disk as the primary boot disk?')" \ + $UI_YESNO_H $UI_YESNO_W; then + BOOTABLE="yes" + fi + + IMG_NAMES+=("$IMAGE") + IMG_INTERFACES+=("$INTERFACE") + IMG_SSD_OPTIONS+=("$SSD_OPTION") + IMG_BOOTABLE+=("$BOOTABLE") +done + +if [[ ${#IMG_NAMES[@]} -eq 0 ]]; then + exit 0 fi +# ========================================================== +# PHASE 2 — EXECUTION +# show_proxmenux_logo appears here exactly once. +# No dialogs from this point on. +# ========================================================== -# 4. Import each selected image -for IMAGE in $SELECTED_IMAGES; do +show_proxmenux_logo +msg_title "$(translate 'Import Disk Image to VM')" +VM_NAME=$(qm config "$VMID" 2>/dev/null | awk '/^name:/ {print $2}') +msg_ok "$(translate 'VM:') ${VM_NAME:-VM-$VMID} (${VMID})" +msg_ok "$(translate 'Storage:') $STORAGE" +msg_ok "$(translate 'Image directory:') $IMAGES_DIR" +msg_ok "$(translate 'Images to import:') ${#IMG_NAMES[@]}" +echo "" - IMAGE=$(echo "$IMAGE" | tr -d '"') +PROCESSED=0 +FAILED=0 +for i in "${!IMG_NAMES[@]}"; do + IMAGE="${IMG_NAMES[$i]}" + INTERFACE="${IMG_INTERFACES[$i]}" + SSD_OPTION="${IMG_SSD_OPTIONS[$i]}" + BOOTABLE="${IMG_BOOTABLE[$i]}" + FULL_PATH="$IMAGES_DIR/$IMAGE" - INTERFACE=$(whiptail --title "$(translate 'Interface Type')" --menu "$(translate 'Select the interface type for the image:') $IMAGE" 15 40 4 \ - "sata" "SATA" \ - "scsi" "SCSI" \ - "virtio" "VirtIO" \ - "ide" "IDE" 3>&1 1>&2 2>&3) + if [[ ! -f "$FULL_PATH" ]]; then + msg_error "$(translate 'Image file not found:') $FULL_PATH" + FAILED=$((FAILED + 1)) + continue + fi - if [ -z "$INTERFACE" ]; then - msg_error "$(translate 'No interface type selected for') $IMAGE" - continue + # Snapshot of unused entries before import for reliable detection + BEFORE_UNUSED=$(qm config "$VMID" 2>/dev/null | grep -E '^unused[0-9]+:' || true) + + TEMP_STATUS_FILE=$(mktemp) + TEMP_DISK_FILE=$(mktemp) + + msg_info "$(translate 'Importing') $IMAGE..." + + ( + qm importdisk "$VMID" "$FULL_PATH" "$STORAGE" 2>&1 + echo $? > "$TEMP_STATUS_FILE" + ) | while IFS= read -r line; do + if [[ "$line" =~ [0-9]+\.[0-9]+% ]]; then + echo -ne "\r${TAB}${BL}$(translate 'Importing') ${IMAGE}${CL} ${BASH_REMATCH[0]} " + fi + if echo "$line" | grep -qiF "successfully imported disk"; then + echo "$line" | sed -n "s/.*successfully imported disk as '\\([^']*\\)'.*/\\1/p" > "$TEMP_DISK_FILE" + fi + done + echo -ne "\n" + + IMPORT_STATUS=$(cat "$TEMP_STATUS_FILE" 2>/dev/null) + rm -f "$TEMP_STATUS_FILE" + [[ -z "$IMPORT_STATUS" ]] && IMPORT_STATUS=1 + + if [[ "$IMPORT_STATUS" -ne 0 ]]; then + msg_error "$(translate 'Failed to import') $IMAGE" + rm -f "$TEMP_DISK_FILE" + FAILED=$((FAILED + 1)) + continue + fi + + msg_ok "$(translate 'Image imported:') $IMAGE" + + # Primary: parse disk name from qm importdisk output + IMPORTED_DISK=$(cat "$TEMP_DISK_FILE" 2>/dev/null | xargs) + rm -f "$TEMP_DISK_FILE" + + # Fallback: compare unused entries before/after import + if [[ -z "$IMPORTED_DISK" ]]; then + AFTER_UNUSED=$(qm config "$VMID" 2>/dev/null | grep -E '^unused[0-9]+:' || true) + NEW_LINE=$(comm -13 \ + <(echo "$BEFORE_UNUSED" | sort) \ + <(echo "$AFTER_UNUSED" | sort) | head -1) + if [[ -n "$NEW_LINE" ]]; then + IMPORTED_DISK=$(echo "$NEW_LINE" | cut -d':' -f2- | xargs) + fi + fi + + if [[ -z "$IMPORTED_DISK" ]]; then + msg_error "$(translate 'Could not identify the imported disk in VM config')" + FAILED=$((FAILED + 1)) + continue + fi + + # Find the unusedN key that holds this disk (needed to clean it up after assignment) + IMPORTED_ID=$(qm config "$VMID" 2>/dev/null | grep -F "$IMPORTED_DISK" | cut -d':' -f1 | head -1) + + # Find next available slot for the chosen interface + LAST_SLOT=$(qm config "$VMID" 2>/dev/null | grep -oE "^${INTERFACE}[0-9]+" | grep -oE '[0-9]+' | sort -n | tail -1) + if [[ -z "$LAST_SLOT" ]]; then + NEXT_SLOT=0 + else + NEXT_SLOT=$((LAST_SLOT + 1)) + fi + + msg_info "$(translate 'Configuring disk as') ${INTERFACE}${NEXT_SLOT}..." + if qm set "$VMID" "--${INTERFACE}${NEXT_SLOT}" "${IMPORTED_DISK}${SSD_OPTION}" >/dev/null 2>&1; then + msg_ok "$(translate 'Disk configured as') ${INTERFACE}${NEXT_SLOT}${SSD_OPTION:+ (SSD)}" + + # Remove the unusedN entry now that the disk is properly assigned + if [[ -n "$IMPORTED_ID" ]]; then + qm set "$VMID" -delete "$IMPORTED_ID" >/dev/null 2>&1 fi - FULL_PATH="$IMAGES_DIR/$IMAGE" - - - msg_info "$(translate 'Importing image:')" - - - TEMP_DISK_FILE=$(mktemp) - - - qm importdisk "$VMID" "$FULL_PATH" "$STORAGE" 2>&1 | while read -r line; do - if [[ "$line" =~ transferred ]]; then - - PERCENT=$(echo "$line" | grep -oP "\d+\.\d+(?=%)") - - echo -ne "\r${TAB}${BL}-$(translate 'Importing image:') $IMAGE-${CL} ${PERCENT}%" - elif [[ "$line" =~ successfully\ imported\ disk ]]; then - - echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE" - fi - done - echo -ne "\n" - - IMPORT_STATUS=${PIPESTATUS[0]} - - if [ $IMPORT_STATUS -eq 0 ]; then - msg_ok "$(translate 'Image imported successfully')" - - - IMPORTED_DISK=$(cat "$TEMP_DISK_FILE") - rm -f "$TEMP_DISK_FILE" - - - if [ -z "$IMPORTED_DISK" ]; then - - STORAGE_TYPE=$(pvesm status -storage "$STORAGE" | awk 'NR>1 {print $2}') - - if [[ "$STORAGE_TYPE" == "btrfs" || "$STORAGE_TYPE" == "dir" || "$STORAGE_TYPE" == "nfs" ]]; then - - UNUSED_LINE=$(qm config "$VMID" | grep -E '^unused[0-9]+:') - IMPORTED_ID=$(echo "$UNUSED_LINE" | cut -d: -f1) - IMPORTED_DISK=$(echo "$UNUSED_LINE" | cut -d: -f2- | xargs) - else - - IMPORTED_DISK=$(qm config "$VMID" | grep -E 'unused[0-9]+' | tail -1 | cut -d: -f2- | xargs) - IMPORTED_ID=$(qm config "$VMID" | grep -E 'unused[0-9]+' | tail -1 | cut -d: -f1) - fi - fi - - if [ -n "$IMPORTED_DISK" ]; then - - EXISTING_DISKS=$(qm config "$VMID" | grep -oP "${INTERFACE}\d+" | sort -n) - if [ -z "$EXISTING_DISKS" ]; then - NEXT_SLOT=0 - else - LAST_SLOT=$(echo "$EXISTING_DISKS" | tail -n1 | sed "s/${INTERFACE}//") - NEXT_SLOT=$((LAST_SLOT + 1)) - fi - - - if [ "$INTERFACE" != "virtio" ]; then - if (whiptail --title "$(translate 'SSD Emulation')" --yesno "$(translate 'Do you want to use SSD emulation for this disk?')" 10 60); then - SSD_OPTION=",ssd=1" - else - SSD_OPTION="" - fi - else - SSD_OPTION="" - fi - - msg_info "$(translate 'Configuring disk')" - - - if qm set "$VMID" --${INTERFACE}${NEXT_SLOT} "$IMPORTED_DISK${SSD_OPTION}" &>/dev/null; then - msg_ok "$(translate 'Image') $IMAGE $(translate 'configured as') ${INTERFACE}${NEXT_SLOT}" - - - if [[ -n "$IMPORTED_ID" ]]; then - qm set "$VMID" -delete "$IMPORTED_ID" >/dev/null 2>&1 - fi - - - if (whiptail --title "$(translate 'Make Bootable')" --yesno "$(translate 'Do you want to make this disk bootable?')" 10 60); then - msg_info "$(translate 'Configuring disk as bootable')" - - if qm set "$VMID" --boot c --bootdisk ${INTERFACE}${NEXT_SLOT} &>/dev/null; then - msg_ok "$(translate 'Disk configured as bootable')" - else - msg_error "$(translate 'Could not configure the disk as bootable')" - fi - fi - else - msg_error "$(translate 'Could not configure disk') ${INTERFACE}${NEXT_SLOT} $(translate 'for VM') $VMID" - echo "DEBUG: Tried to configure: --${INTERFACE}${NEXT_SLOT} \"$IMPORTED_DISK${SSD_OPTION}\"" - echo "DEBUG: VM config after import:" - qm config "$VMID" | grep -E "(unused|${INTERFACE})" - fi - else - msg_error "$(translate 'Could not find the imported disk')" - echo "DEBUG: VM config after import:" - qm config "$VMID" - fi - else - msg_error "$(translate 'Could not import') $IMAGE" + if [[ "$BOOTABLE" == "yes" ]]; then + msg_info "$(translate 'Setting boot order...')" + if qm set "$VMID" --boot "order=${INTERFACE}${NEXT_SLOT}" >/dev/null 2>&1; then + msg_ok "$(translate 'Boot order set to') ${INTERFACE}${NEXT_SLOT}" + else + msg_error "$(translate 'Could not set boot order for') ${INTERFACE}${NEXT_SLOT}" + fi fi + + PROCESSED=$((PROCESSED + 1)) + else + msg_error "$(translate 'Could not assign disk') ${INTERFACE}${NEXT_SLOT} $(translate 'to VM') $VMID" + FAILED=$((FAILED + 1)) + fi done +echo "" +if [[ $FAILED -eq 0 ]]; then + msg_ok "$(translate 'All images imported and configured successfully')" +elif [[ $PROCESSED -gt 0 ]]; then + msg_warn "$(translate 'Completed with errors —') $(translate 'imported:') $PROCESSED, $(translate 'failed:') $FAILED" +else + msg_error "$(translate 'All imports failed')" +fi - -msg_ok "$(translate 'All selected images have been processed')" -msg_success "$(translate "Press Enter to return to menu...")" +msg_success "$(translate 'Press Enter to return to menu...')" read -r diff --git a/scripts/storage/mount-disk-on-host.sh b/scripts/storage/mount-disk-on-host.sh deleted file mode 100644 index 8e701960..00000000 --- a/scripts/storage/mount-disk-on-host.sh +++ /dev/null @@ -1,353 +0,0 @@ -#!/bin/bash - -# ========================================================== -# ProxMenux - Mount independent disk on Proxmox host -# ========================================================== -# Author : MacRimi -# Copyright : (c) 2024 MacRimi -# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.0 -# Last Updated: 08/04/2025 -# ========================================================== -# Description: -# This script detects unassigned physical disks and allows -# the user to mount one of them on the host Proxmox system. -# - Detects unmounted and unassigned disks. -# - Filters out ZFS, LVM, RAID and system disks. -# - Allows selecting a disk. -# - Prepares partition and filesystem if needed. -# - Mounts the disk in the host at a defined mount point. -# ========================================================== - -# Configuration ============================================ -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" -BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" -VENV_PATH="/opt/googletrans-env" - -if [[ -f "$UTILS_FILE" ]]; then - source "$UTILS_FILE" -fi -load_language -initialize_cache -# ========================================================== - -get_disk_info() { - local disk=$1 - MODEL=$(lsblk -dn -o MODEL "$disk" | xargs) - SIZE=$(lsblk -dn -o SIZE "$disk" | xargs) - echo "$MODEL" "$SIZE" -} - -msg_info "$(translate "Detecting available disks...")" - -USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}') -MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') - -ZFS_DISKS="" -ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') - -for entry in $ZFS_RAW; do - path="" - if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then - if [ -e "/dev/disk/by-id/$entry" ]; then - path=$(readlink -f "/dev/disk/by-id/$entry") - fi - elif [[ "$entry" == /dev/* ]]; then - path="$entry" - fi - - if [ -n "$path" ]; then - base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) - if [ -n "$base_disk" ]; then - ZFS_DISKS+="/dev/$base_disk"$'\n' - fi - fi -done - -ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) - -is_disk_in_use() { - local disk="$1" - - while read -r part fstype; do - case "$fstype" in - zfs_member|linux_raid_member) - return 0 ;; - esac - - if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then - return 0 - fi - done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2) - - if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then - return 0 - fi - - return 1 -} - - -FREE_DISKS=() - -LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u) -RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u) - -while read -r DISK; do - [[ "$DISK" =~ /dev/zd ]] && continue - - INFO=($(get_disk_info "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" - LABEL="" - SHOW_DISK=true - - IS_MOUNTED=false - IS_RAID=false - IS_ZFS=false - IS_LVM=false - - while read -r part fstype; do - [[ "$fstype" == "zfs_member" ]] && IS_ZFS=true - [[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true - [[ "$fstype" == "LVM2_member" ]] && IS_LVM=true - if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then - IS_MOUNTED=true - fi - done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2) - - REAL_PATH=$(readlink -f "$DISK") - if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then - IS_MOUNTED=true - fi - - - USED_BY="" - REAL_PATH=$(readlink -f "$DISK") - CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) - - if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - else - for SYMLINK in /dev/disk/by-id/*; do - if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then - if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - break - fi - fi - done - fi - - - - if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then - if grep -q "active raid" /proc/mdstat; then - SHOW_DISK=false - fi - fi - - if $IS_ZFS; then - SHOW_DISK=false - fi - - if $IS_MOUNTED; then - SHOW_DISK=false - fi - - if $SHOW_DISK; then - [[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]" - [[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID" - [[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM" - [[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS" - - DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL") - FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF") - fi - -done < <(lsblk -dn -e 7,11 -o PATH) - -if [ "${#FREE_DISKS[@]}" -eq 0 ]; then - cleanup - whiptail --title "$(translate "Error")" --msgbox "$(translate "No available disks found on the host.")" 8 50 - clear - exit 1 -fi - -msg_ok "$(translate "Available disks detected.")" - -MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1) -TOTAL_WIDTH=$((MAX_WIDTH + 20)) -TOTAL_WIDTH=$((TOTAL_WIDTH < 50 ? 50 : TOTAL_WIDTH)) - -SELECTED=$(whiptail --title "$(translate "Select Disk")" --radiolist \ - "$(translate "Select the disk you want to mount on the host:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) - -if [ -z "$SELECTED" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No disk was selected.")" 10 50 - clear - exit 1 -fi - - - -msg_ok "$(translate "Disk selected successfully:") $SELECTED" - - - - - -################################################################ - - - - -PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}') - -SKIP_FORMAT=false -DEFAULT_MOUNT="/mnt/data_shared" - -if [ -n "$PARTITION" ]; then - PARTITION="/dev/$PARTITION" - CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs) - - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then - SKIP_FORMAT=true - msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION." - else - whiptail --title "$(translate "Unsupported Filesystem")" --yesno "$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\\nDo you want to format it?")" 10 70 - if [ $? -ne 0 ]; then - exit 0 - fi - fi -else - CURRENT_FS=$(lsblk -no FSTYPE "$SELECTED" | xargs) - - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then - SKIP_FORMAT=true - PARTITION="$SELECTED" - msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $SELECTED." - else - whiptail --title "$(translate "No Valid Partitions")" --yesno "$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70 - if [ $? -ne 0 ]; then - exit 0 - fi - - echo -e "$(translate "Creating partition table and partition...")" - parted -s "$SELECTED" mklabel gpt - parted -s "$SELECTED" mkpart primary 0% 100% - sleep 2 - partprobe "$SELECTED" - sleep 2 - - PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}') - if [ -n "$PARTITION" ]; then - PARTITION="/dev/$PARTITION" - else - whiptail --title "$(translate "Partition Error")" --msgbox "$(translate "Failed to create partition on disk") $SELECTED." 8 70 - exit 1 - fi - fi -fi - -if [ "$SKIP_FORMAT" != true ]; then - FORMAT_TYPE=$(whiptail --title "$(translate "Select Format Type")" --menu "$(translate "Select the filesystem type for") $PARTITION:" 15 60 5 \ - "ext4" "$(translate "Extended Filesystem 4 (recommended)")" \ - "xfs" "XFS" \ - "btrfs" "Btrfs" 3>&1 1>&2 2>&3) - - if [ -z "$FORMAT_TYPE" ]; then - whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60 - exit 0 - fi - - whiptail --title "$(translate "WARNING")" --yesno "$(translate "WARNING: This operation will FORMAT the disk") $PARTITION $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\\n\\n$(translate "Are you sure you want to continue")" 15 70 - if [ $? -ne 0 ]; then - exit 0 - fi - - echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..." - case "$FORMAT_TYPE" in - "ext4") mkfs.ext4 -F "$PARTITION" ;; - "xfs") mkfs.xfs -f "$PARTITION" ;; - "btrfs") mkfs.btrfs -f "$PARTITION" ;; - esac - - if [ $? -ne 0 ]; then - cleanup - whiptail --title "$(translate "Format Failed")" --msgbox "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE." 12 70 - exit 1 - else - msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE." - partprobe "$SELECTED" - sleep 2 - fi -fi - - - - -################################################################ - - - - - -MOUNT_POINT=$(whiptail --title "$(translate "Mount Point")" \ - --inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/data_shared):")" \ - 10 60 "$DEFAULT_MOUNT" 3>&1 1>&2 2>&3) - -if [ -z "$MOUNT_POINT" ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40 - exit 1 -fi - -msg_ok "$(translate "Mount point specified:") $MOUNT_POINT" - -mkdir -p "$MOUNT_POINT" - -UUID=$(blkid -s UUID -o value "$PARTITION") - -# Obtener sistema de archivos real -FS_TYPE=$(lsblk -no FSTYPE "$PARTITION" | xargs) - -FSTAB_ENTRY="UUID=$UUID $MOUNT_POINT $FS_TYPE defaults 0 0" - -if grep -q "UUID=$UUID" /etc/fstab; then - sed -i "s|^.*UUID=$UUID.*|$FSTAB_ENTRY|" /etc/fstab - msg_ok "$(translate "fstab entry updated for") $UUID" -else - echo "$FSTAB_ENTRY" >> /etc/fstab - msg_ok "$(translate "fstab entry added for") $UUID" -fi - - -################################################################## - -mount "$MOUNT_POINT" 2> >(grep -v "systemd still uses") - -################################################################## - - -if [ $? -eq 0 ]; then - if ! getent group sharedfiles >/dev/null; then - groupadd sharedfiles - msg_ok "$(translate "Group 'sharedfiles' created")" - else - msg_ok "$(translate "Group 'sharedfiles' already exists")" - fi - - chown root:sharedfiles "$MOUNT_POINT" - chmod 2775 "$MOUNT_POINT" - - whiptail --title "$(translate "Success")" --msgbox "$(translate "The disk has been successfully mounted at") $MOUNT_POINT" 8 60 - msg_ok "$(translate "Disk mounted at") $MOUNT_POINT" - msg_success "$(translate "Press Enter to return to menu...")" - read -r -else - whiptail --title "$(translate "Mount Error")" --msgbox "$(translate "Failed to mount the disk at") $MOUNT_POINT" 8 60 - msg_success "$(translate "Press Enter to return to menu...")" - read -r - exit 1 -fi diff --git a/scripts/storage/mount-point-to-ct.sh b/scripts/storage/mount-point-to-ct.sh deleted file mode 100644 index 13dba7c1..00000000 --- a/scripts/storage/mount-point-to-ct.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/bin/bash - -# ========================================================== -# ProxMenux - Mount point from host into LXC container (CT) -# ========================================================== -# Author : MacRimi -# License : MIT -# Description : Mount a folder from /mnt on the host to a mount point in a CT -# ========================================================== - -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" -BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" - -if [[ -f "$UTILS_FILE" ]]; then - source "$UTILS_FILE" -fi -load_language -initialize_cache - -####################################################### - -CT_LIST=($(pct list | awk 'NR>1 {print $1":"$3}')) - -if [[ ${#CT_LIST[@]} -eq 0 ]]; then - whiptail --title "$(translate "No CTs")" --msgbox "$(translate "No containers found.")" 8 40 - exit 0 -fi - -CT_OPTIONS=() -for entry in "${CT_LIST[@]}"; do - ID="${entry%%:*}" - NAME="${entry##*:}" - CT_OPTIONS+=("$ID" "$NAME") -done - -CTID=$(whiptail --title "$(translate "Select CT")" --menu "$(translate "Select the container:")" 20 60 10 "${CT_OPTIONS[@]}" 3>&1 1>&2 2>&3) -[[ -z "$CTID" ]] && exit 0 - -CT_STATUS=$(pct status "$CTID" | awk '{print $2}') -if [ "$CT_STATUS" != "running" ]; then - msg_info "$(translate "Starting CT") $CTID..." - pct start "$CTID" - sleep 2 - if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then - msg_error "$(translate "Failed to start the CT.")" - exit 1 - fi - msg_ok "$(translate "CT started successfully.")" -fi - -####################################################### - -select_origin_path() { - METHOD=$(whiptail --title "$(translate "Select Host Folder")" --menu "$(translate "How do you want to select the host folder to mount?")" 15 60 5 \ - "auto" "$(translate "Select from /mnt")" \ - "manual" "$(translate "Enter path manually")" 3>&1 1>&2 2>&3) - - case "$METHOD" in - auto) - HOST_DIRS=(/mnt/*) - OPTIONS=() - for dir in "${HOST_DIRS[@]}"; do - [[ -d "$dir" ]] && OPTIONS+=("$dir" "") - done - - ORIGIN=$(whiptail --title "$(translate "Select Host Folder")" --menu "$(translate "Select the folder to mount:")" 20 60 10 "${OPTIONS[@]}" 3>&1 1>&2 2>&3) - [[ -z "$ORIGIN" ]] && return 1 - ;; - - manual) - ORIGIN=$(whiptail --title "$(translate "Enter Path")" --inputbox "$(translate "Enter the full path to the host folder:")" 10 60 "/mnt/" 3>&1 1>&2 2>&3) - [[ -z "$ORIGIN" ]] && return 1 - ;; - esac - - if [[ ! -d "$ORIGIN" ]]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "The selected path is not a valid directory:")\n$ORIGIN" 8 60 - return 1 - fi - - # Preparar permisos en el host para uso compartido - SHARE_GID=999 - if ! getent group sharedfiles >/dev/null; then - groupadd -g "$SHARE_GID" sharedfiles - msg_ok "$(translate "Group 'sharedfiles' created in the host with GID $SHARE_GID")" - else - msg_ok "$(translate "Group 'sharedfiles' already exists in the host")" - fi - - chown root:sharedfiles "$ORIGIN" - chmod 2775 "$ORIGIN" - setfacl -d -m g:sharedfiles:rwx "$ORIGIN" - setfacl -m g:sharedfiles:rwx "$ORIGIN" - - msg_ok "$(translate "Host folder prepared with shared group and permissions.")" - - return 0 -} - -select_origin_path || exit 0 - -####################################################### - -CT_NAME=$(pct config "$CTID" | awk -F: '/hostname/ {print $2}' | xargs) -DEFAULT_MOUNT_POINT="/mnt/host_share" - -MOUNT_POINT=$(whiptail --title "$(translate "Mount Point to CT")" \ ---inputbox "$(translate "Enter the mount point inside the CT (e.g., /mnt/host_share):")" \ -10 70 "$DEFAULT_MOUNT_POINT" 3>&1 1>&2 2>&3) - -if [[ -z "$MOUNT_POINT" ]]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point specified.")" 8 60 - exit 1 -fi - -if ! pct exec "$CTID" -- test -d "$MOUNT_POINT"; then - if whiptail --yesno "$(translate "Directory does not exist in the CT.")\n\n$MOUNT_POINT\n\n$(translate "Do you want to create it?")" 12 70 --title "$(translate "Create Directory")"; then - pct exec "$CTID" -- mkdir -p "$MOUNT_POINT" - msg_ok "$(translate "Directory created inside CT:") $MOUNT_POINT" - else - msg_error "$(translate "Directory not created. Operation cancelled.")" - exit 1 - fi -fi - -INDEX=0 -while pct config "$CTID" | grep -q "mp${INDEX}:"; do - ((INDEX++)) - [[ $INDEX -ge 100 ]] && msg_error "Too many mount points." && exit 1 -done - -msg_info "$(translate "Mounting folder from host to CT...")" -RESULT=$(pct set "$CTID" -mp${INDEX} "$ORIGIN,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1) - -if [[ $? -eq 0 ]]; then - msg_ok "$(translate "Successfully mounted:")\n$ORIGIN → $CT_NAME:$MOUNT_POINT" -else - msg_error "$(translate "Error mounting folder:")\n$RESULT" - exit 1 -fi - -msg_success "$(translate "Press Enter to return to menu...")" -read -r - -exit 0 diff --git a/scripts/storage/mount_disk_host_bk.sh b/scripts/storage/mount_disk_host_bk.sh deleted file mode 100644 index df085338..00000000 --- a/scripts/storage/mount_disk_host_bk.sh +++ /dev/null @@ -1,446 +0,0 @@ -#!/bin/bash - -# ========================================================== -# ProxMenux - Mount independent disk on Proxmox host -# ========================================================== -# Author : MacRimi -# Copyright : (c) 2024 MacRimi -# License : MIT -# Version : 1.3-dialog -# Last Updated: 13/12/2024 -# ========================================================== - -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" -BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" -VENV_PATH="/opt/googletrans-env" - -if [[ -f "$UTILS_FILE" ]]; then - source "$UTILS_FILE" -fi -load_language -initialize_cache - - -mount_disk_host_bk() { - - - -get_disk_info() { - local disk=$1 - MODEL=$(lsblk -dn -o MODEL "$disk" | xargs) - SIZE=$(lsblk -dn -o SIZE "$disk" | xargs) - echo "$MODEL" "$SIZE" -} - - -is_usb_disk() { - local disk=$1 - local disk_name=$(basename "$disk") - - - if readlink -f "/sys/block/$disk_name/device" 2>/dev/null | grep -q "usb"; then - return 0 - fi - - - if udevadm info --query=property --name="$disk" 2>/dev/null | grep -q "ID_BUS=usb"; then - return 0 - fi - - return 1 -} - - -is_system_disk() { - local disk=$1 - local disk_name=$(basename "$disk") - - - local system_mounts=$(df -h | grep -E '^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(/|/boot|/usr|/var|/home)$' | awk '{print $1}') - - - for mount_dev in $system_mounts; do - - local mount_disk="" - if [[ "$mount_dev" =~ ^/dev/mapper/ ]]; then - - local vg_name=$(lvs --noheadings -o vg_name "$mount_dev" 2>/dev/null | xargs) - if [[ -n "$vg_name" ]]; then - local pvs_list=$(pvs --noheadings -o pv_name -S vg_name="$vg_name" 2>/dev/null | xargs) - for pv in $pvs_list; do - if [[ -n "$pv" && -e "$pv" ]]; then - mount_disk=$(lsblk -no PKNAME "$pv" 2>/dev/null) - if [[ -n "$mount_disk" && "/dev/$mount_disk" == "$disk" ]]; then - return 0 - fi - fi - done - fi - elif [[ "$mount_dev" =~ ^/dev/[hsv]d[a-z][0-9]* || "$mount_dev" =~ ^/dev/nvme[0-9]+n[0-9]+p[0-9]+ ]]; then - - mount_disk=$(lsblk -no PKNAME "$mount_dev" 2>/dev/null) - if [[ -n "$mount_disk" && "/dev/$mount_disk" == "$disk" ]]; then - return 0 - fi - fi - done - - - local fs_type=$(lsblk -no FSTYPE "$disk" 2>/dev/null | head -1) - if [[ "$fs_type" == "btrfs" ]]; then - - local temp_mount=$(mktemp -d) - if mount -o ro "$disk" "$temp_mount" 2>/dev/null; then - - if btrfs subvolume list "$temp_mount" 2>/dev/null | grep -qE '(@|@home|@var|@boot|@root|root)'; then - umount "$temp_mount" 2>/dev/null - rmdir "$temp_mount" 2>/dev/null - return 0 - fi - umount "$temp_mount" 2>/dev/null - fi - rmdir "$temp_mount" 2>/dev/null - - - while read -r part; do - if [[ -n "$part" ]]; then - local part_fs=$(lsblk -no FSTYPE "/dev/$part" 2>/dev/null) - if [[ "$part_fs" == "btrfs" ]]; then - local mount_point=$(lsblk -no MOUNTPOINT "/dev/$part" 2>/dev/null) - if [[ "$mount_point" == "/" || "$mount_point" == "/boot" || "$mount_point" == "/home" || "$mount_point" == "/var" ]]; then - return 0 - fi - fi - fi - done < <(lsblk -ln -o NAME "$disk" | tail -n +2) - fi - - - local disk_uuid=$(blkid -s UUID -o value "$disk" 2>/dev/null) - local part_uuids=() - while read -r part; do - if [[ -n "$part" ]]; then - local uuid=$(blkid -s UUID -o value "/dev/$part" 2>/dev/null) - if [[ -n "$uuid" ]]; then - part_uuids+=("$uuid") - fi - fi - done < <(lsblk -ln -o NAME "$disk" | tail -n +2) - - - for uuid in "${part_uuids[@]}" "$disk_uuid"; do - if [[ -n "$uuid" ]] && grep -q "UUID=$uuid" /etc/fstab; then - local mount_point=$(grep "UUID=$uuid" /etc/fstab | awk '{print $2}') - if [[ "$mount_point" == "/" || "$mount_point" == "/boot" || "$mount_point" == "/home" || "$mount_point" == "/var" ]]; then - return 0 - fi - fi - done - - - if grep -q "$disk" /etc/fstab; then - local mount_point=$(grep "$disk" /etc/fstab | awk '{print $2}') - if [[ "$mount_point" == "/" || "$mount_point" == "/boot" || "$mount_point" == "/home" || "$mount_point" == "/var" ]]; then - return 0 - fi - fi - - - local disk_count=$(lsblk -dn -e 7,11 -o PATH | wc -l) - if [[ "$disk_count" -eq 1 ]]; then - return 0 - fi - - return 1 -} - -msg_info "$(translate "Detecting available disks...")" - -USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}') -MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}') - -ZFS_DISKS="" -ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror') - -for entry in $ZFS_RAW; do - path="" - if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then - if [ -e "/dev/disk/by-id/$entry" ]; then - path=$(readlink -f "/dev/disk/by-id/$entry") - fi - elif [[ "$entry" == /dev/* ]]; then - path="$entry" - fi - - if [ -n "$path" ]; then - base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null) - if [ -n "$base_disk" ]; then - ZFS_DISKS+="/dev/$base_disk"$'\n' - fi - fi -done - -ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u) - -LVM_DEVICES=$( - pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | - while read -r dev; do - [[ -n "$dev" && -e "$dev" ]] && readlink -f "$dev" - done | sort -u -) - -FREE_DISKS=() - -while read -r DISK; do - [[ "$DISK" =~ /dev/zd ]] && continue - - INFO=($(get_disk_info "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" - LABEL="" - SHOW_DISK=true - - IS_MOUNTED=false - IS_RAID=false - IS_ZFS=false - IS_LVM=false - IS_SYSTEM=false - IS_USB=false - - - if is_system_disk "$DISK"; then - IS_SYSTEM=true - fi - - - if is_usb_disk "$DISK"; then - IS_USB=true - fi - - while read -r part fstype; do - [[ "$fstype" == "zfs_member" ]] && IS_ZFS=true - [[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true - [[ "$fstype" == "LVM2_member" ]] && IS_LVM=true - if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then - IS_MOUNTED=true - fi - done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2) - - REAL_PATH="" - if [[ -n "$DISK" && -e "$DISK" ]]; then - REAL_PATH=$(readlink -f "$DISK") - fi - if [[ -n "$REAL_PATH" ]] && echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then - IS_MOUNTED=true - fi - - USED_BY="" - REAL_PATH="" - if [[ -n "$DISK" && -e "$DISK" ]]; then - REAL_PATH=$(readlink -f "$DISK") - fi - CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null) - - if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - else - for SYMLINK in /dev/disk/by-id/*; do - [[ -e "$SYMLINK" ]] || continue - if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then - if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then - USED_BY="⚠ $(translate "In use")" - break - fi - fi - done - fi - - if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then - if grep -q "active raid" /proc/mdstat; then - SHOW_DISK=false - fi - fi - if $IS_ZFS; then SHOW_DISK=false; fi - if $IS_MOUNTED; then SHOW_DISK=false; fi - if $IS_SYSTEM; then SHOW_DISK=false; fi - - if $SHOW_DISK; then - [[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]" - [[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID" - [[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM" - [[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS" - - - if $IS_USB; then - LABEL+=" USB" - else - LABEL+=" $(translate "Internal")" - fi - - DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL") - FREE_DISKS+=("$DISK" "$DESCRIPTION" "off") - fi -done < <(lsblk -dn -e 7,11 -o PATH) - -if [ "${#FREE_DISKS[@]}" -eq 0 ]; then - dialog --title "$(translate "Error")" --msgbox "$(translate "No available disks found on the host.")" 8 60 - clear - exit 1 -fi - -msg_ok "$(translate "Available disks detected.")" - -# Building the array for dialog (format: tag item on/off tag item on/off...) -DLG_LIST=() -for ((i=0; i<${#FREE_DISKS[@]}; i+=3)); do - DLG_LIST+=("${FREE_DISKS[i]}" "${FREE_DISKS[i+1]}" "${FREE_DISKS[i+2]}") -done - -SELECTED=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Select Disk")" \ - --radiolist "\n$(translate "Select the disk you want to mount on the host:")" 20 90 10 \ - "${DLG_LIST[@]}" 2>&1 >/dev/tty) - -if [ -z "$SELECTED" ]; then - dialog --title "$(translate "Error")" --msgbox "$(translate "No disk was selected.")" 8 50 - clear - exit 1 -fi - -msg_ok "$(translate "Disk selected successfully:") $SELECTED" - -# ------------------- Partitions and formatting ------------------------ - -PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}') -SKIP_FORMAT=false -DEFAULT_MOUNT="/mnt/backup" - -if [ -n "$PARTITION" ]; then - PARTITION="/dev/$PARTITION" - CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs) - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then - SKIP_FORMAT=true - msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION." - else - dialog --title "$(translate "Unsupported Filesystem")" --yesno \ - "$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\nDo you want to format it?")" 10 70 - if [ $? -ne 0 ]; then exit 0; fi - fi -else - CURRENT_FS=$(lsblk -no FSTYPE "$SELECTED" | xargs) - if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then - SKIP_FORMAT=true - PARTITION="$SELECTED" - msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $SELECTED." - else - dialog --title "$(translate "No Valid Partitions")" --yesno \ - "$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70 - if [ $? -ne 0 ]; then exit 0; fi - - echo -e "$(translate "Creating partition table and partition...")" - parted -s "$SELECTED" mklabel gpt - parted -s "$SELECTED" mkpart primary 0% 100% - sleep 2 - partprobe "$SELECTED" - sleep 2 - - PARTITION=$(lsblk -rno NAME "$SELECTED" | awk -v disk="$(basename "$SELECTED")" '$1 != disk {print $1; exit}') - if [ -n "$PARTITION" ]; then - PARTITION="/dev/$PARTITION" - else - dialog --title "$(translate "Partition Error")" --msgbox \ - "$(translate "Failed to create partition on disk") $SELECTED." 8 70 - exit 1 - fi - fi -fi - -if [ "$SKIP_FORMAT" != true ]; then - FORMAT_TYPE=$(dialog --title "$(translate "Select Format Type")" --menu \ - "$(translate "Select the filesystem type for") $PARTITION:" 15 60 5 \ - "ext4" "$(translate "Extended Filesystem 4 (recommended)")" \ - "xfs" "XFS" \ - "btrfs" "Btrfs" 2>&1 >/dev/tty) - if [ -z "$FORMAT_TYPE" ]; then - dialog --title "$(translate "Format Cancelled")" --msgbox \ - "$(translate "Format operation cancelled. The disk will not be added.")" 8 60 - exit 0 - fi - - dialog --title "$(translate "WARNING")" --yesno \ - "$(translate "WARNING: This operation will FORMAT the disk") $PARTITION $(translate "with") $FORMAT_TYPE.\n\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\n\n$(translate "Are you sure you want to continue")" 15 70 - if [ $? -ne 0 ]; then exit 0; fi - - echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..." - case "$FORMAT_TYPE" in - "ext4") mkfs.ext4 -F "$PARTITION" ;; - "xfs") mkfs.xfs -f "$PARTITION" ;; - "btrfs") mkfs.btrfs -f "$PARTITION" ;; - esac - - if [ $? -ne 0 ]; then - cleanup - dialog --title "$(translate "Format Failed")" --msgbox \ - "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE." 12 70 - exit 1 - else - msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE." - partprobe "$SELECTED" - sleep 2 - fi -fi - -# ------------------- Mount point and permissions ------------------- - -MOUNT_POINT=$(dialog --title "$(translate "Mount Point")" \ - --inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/backup):")" \ - 10 60 "$DEFAULT_MOUNT" 2>&1 >/dev/tty) -if [ -z "$MOUNT_POINT" ]; then - dialog --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40 - exit 1 -fi - -msg_ok "$(translate "Mount point specified:") $MOUNT_POINT" - -mkdir -p "$MOUNT_POINT" - -UUID=$(blkid -s UUID -o value "$PARTITION") -FS_TYPE=$(lsblk -no FSTYPE "$PARTITION" | xargs) -FSTAB_ENTRY="UUID=$UUID $MOUNT_POINT $FS_TYPE defaults 0 0" - -if grep -q "UUID=$UUID" /etc/fstab; then - sed -i "s|^.*UUID=$UUID.*|$FSTAB_ENTRY|" /etc/fstab - msg_ok "$(translate "fstab entry updated for") $UUID" -else - echo "$FSTAB_ENTRY" >> /etc/fstab - msg_ok "$(translate "fstab entry added for") $UUID" -fi - -mount "$MOUNT_POINT" 2> >(grep -v "systemd still uses") - -if [ $? -eq 0 ]; then - if ! getent group sharedfiles >/dev/null; then - groupadd sharedfiles - msg_ok "$(translate "Group 'sharedfiles' created")" - else - msg_ok "$(translate "Group 'sharedfiles' already exists")" - fi - - chown root:sharedfiles "$MOUNT_POINT" - chmod 2775 "$MOUNT_POINT" - - dialog --title "$(translate "Success")" --msgbox "$(translate "The disk has been successfully mounted at") $MOUNT_POINT" 8 60 - echo "$MOUNT_POINT" > /usr/local/share/proxmenux/last_backup_mount.txt - msg_ok "$(translate "Disk mounted at") $MOUNT_POINT" - msg_success "$(translate "Press Enter to return to menu...")" - read -r -else - dialog --title "$(translate "Mount Error")" --msgbox "$(translate "Failed to mount the disk at") $MOUNT_POINT" 8 60 - msg_success "$(translate "Press Enter to return to menu...")" - read -r - exit 1 -fi - - -} - diff --git a/scripts/storage/smart-disk-test.sh b/scripts/storage/smart-disk-test.sh new file mode 100644 index 00000000..80564ec9 --- /dev/null +++ b/scripts/storage/smart-disk-test.sh @@ -0,0 +1,399 @@ +#!/bin/bash + +# ========================================================== +# ProxMenux - SMART Disk Health & Test Tool +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) +# Version : 1.0 +# Last Updated: 12/04/2026 +# ========================================================== +# Description: +# SMART health check and disk testing tool for Proxmox VE. +# Supports SATA/SAS disks (smartmontools) and NVMe drives (nvme-cli). +# Exports results as JSON to /usr/local/share/proxmenux/smart/ +# for ProxMenux Monitor integration. +# Long tests run on the drive hardware and persist after terminal close. +# ========================================================== + +# Configuration ============================================ +LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$BASE_DIR/utils.sh" +VENV_PATH="/opt/googletrans-env" +BACKTITLE="ProxMenux" +SMART_DIR="$BASE_DIR/smart" +UI_MENU_H=22 +UI_MENU_W=84 +UI_MENU_LIST_H=12 +UI_SHORT_MENU_H=16 +UI_SHORT_MENU_W=72 +UI_SHORT_MENU_LIST_H=6 +UI_MSG_H=10 +UI_MSG_W=72 +UI_RESULT_H=14 +UI_RESULT_W=86 + +# shellcheck source=/dev/null +[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE" +load_language +initialize_cache + +SCRIPT_DIR_SMART="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR_SMART/.." && pwd)" +if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/utils-install-functions.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/global/utils-install-functions.sh" +elif [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then + source "$LOCAL_SCRIPTS/global/utils-install-functions.sh" +fi +# Configuration ============================================ + + +# ========================================================== +# Helpers +# ========================================================== + +_smart_is_nvme() { + [[ "$1" == *nvme* ]] +} + +_smart_disk_label() { + local disk="$1" + local model size + model=$(lsblk -dn -o MODEL "$disk" 2>/dev/null | xargs) + size=$(lsblk -dn -o SIZE "$disk" 2>/dev/null | xargs) + [[ -z "$model" ]] && model="Unknown" + [[ -z "$size" ]] && size="?" + printf '%-8s — %s' "$size" "$model" +} + +_smart_json_path() { + local disk="$1" + echo "${SMART_DIR}/$(basename "$disk").json" +} + +_smart_ensure_packages() { + local need_smartctl=0 need_nvme=0 + command -v smartctl >/dev/null 2>&1 || need_smartctl=1 + command -v nvme >/dev/null 2>&1 || need_nvme=1 + if [[ $need_smartctl -eq 1 || $need_nvme -eq 1 ]]; then + show_proxmenux_logo + msg_title "$(translate 'SMART Disk Health & Test')" + ensure_repositories + [[ $need_smartctl -eq 1 ]] && install_single_package "smartmontools" "smartctl" "SMART monitoring tools" + [[ $need_nvme -eq 1 ]] && install_single_package "nvme-cli" "nvme" "NVMe management tools" + fi +} + + +# ========================================================== +# PHASE 1 — SELECTION +# All dialogs run here. No execution, no show_proxmenux_logo. +# ========================================================== + +# ── Install packages if missing ─────────────────────────── +_smart_ensure_packages + +# ── Step 1: Detect disks ────────────────────────────────── +DISK_OPTIONS=() +while read -r disk; do + [[ -z "$disk" ]] && continue + [[ "$disk" =~ ^/dev/zd ]] && continue + label=$(_smart_disk_label "$disk") + DISK_OPTIONS+=("$disk" "$label") +done < <(lsblk -dn -e 7,11 -o PATH 2>/dev/null | grep -E '^/dev/(sd|nvme|vd|hd)') +stop_spinner + +if [[ ${#DISK_OPTIONS[@]} -eq 0 ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'No Disks Found')" \ + --msgbox "\n$(translate 'No physical disks detected for SMART testing.')" \ + $UI_MSG_H $UI_MSG_W + exit 1 +fi + +# ── Step 2: Select disk ─────────────────────────────────── +SELECTED_DISK=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Select Disk')" \ + --menu "\n$(translate 'Select the disk to test or inspect:')" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${DISK_OPTIONS[@]}" \ + 2>&1 >/dev/tty) +[[ -z "$SELECTED_DISK" ]] && exit 0 + +# ── Steps 3+: Action loop for the selected disk ─────────── +DISK_LABEL=$(_smart_disk_label "$SELECTED_DISK") +mkdir -p "$SMART_DIR" + +while true; do + + # ── Select action ─────────────────────────────────────── + ACTION=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'SMART Action') — $(basename "$SELECTED_DISK") (${DISK_LABEL})" \ + --menu "\n$(translate 'Select what to do with this disk:')" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "status" "$(translate 'Quick health status — overall SMART result + key attributes')" \ + "report" "$(translate 'Full report — complete SMART data (scrollable)')" \ + "short" "$(translate 'Short test — ~2 minutes, basic surface check')" \ + "long" "$(translate 'Long test — full scan, runs in background if closed')" \ + "progress" "$(translate 'Check test progress — show active or last test result')" \ + 2>&1 >/dev/tty) + [[ -z "$ACTION" ]] && exit 0 + + # ── Long test confirmation ─────────────────────────────── + if [[ "$ACTION" == "long" ]]; then + DISK_SIZE=$(lsblk -dn -o SIZE "$SELECTED_DISK" 2>/dev/null | xargs) + if ! dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Long Test — Background')" \ + --yesno "\n$(translate 'The long test runs directly on the disk hardware.')\n\n$(translate 'Disk:') $SELECTED_DISK ($DISK_SIZE)\n\n$(translate 'The test will continue even if you close this terminal.')\n$(translate 'Results will be saved automatically to:')\n$(_smart_json_path "$SELECTED_DISK")\n\n$(translate 'Start long test now?')" \ + 16 $UI_RESULT_W; then + continue + fi + fi + + + # ======================================================== + # PHASE 2 — EXECUTION + # show_proxmenux_logo appears here exactly once per action. + # No dialogs from this point until "Press Enter". + # ======================================================== + + show_proxmenux_logo + msg_title "$(translate 'SMART Disk Health & Test')" + msg_ok "$(translate 'Disk:') ${BL}${SELECTED_DISK} — ${DISK_LABEL}${CL}" + echo "" + + case "$ACTION" in + + # ── Quick status ──────────────────────────────────────── + status) + if _smart_is_nvme "$SELECTED_DISK"; then + msg_info "$(translate 'Reading NVMe SMART data...')" + OUTPUT=$(nvme smart-log "$SELECTED_DISK" 2>/dev/null) + stop_spinner + if [[ -z "$OUTPUT" ]]; then + msg_error "$(translate 'Could not read SMART data from') $SELECTED_DISK" + else + HEALTH=$(echo "$OUTPUT" | grep -i "critical_warning" | awk '{print $NF}') + if [[ "$HEALTH" == "0" ]]; then + msg_ok "$(translate 'NVMe health status: PASSED')" + else + msg_warn "$(translate 'NVMe health status: WARNING (critical_warning =') $HEALTH)" + fi + echo "" + echo "$OUTPUT" | head -20 + fi + else + msg_info "$(translate 'Reading SMART data...')" + HEALTH=$(smartctl -H "$SELECTED_DISK" 2>/dev/null | grep -i "overall-health") + ATTRS=$(smartctl -A "$SELECTED_DISK" 2>/dev/null) + stop_spinner + if [[ -z "$HEALTH" ]]; then + msg_error "$(translate 'Could not read SMART data from') $SELECTED_DISK" + else + if echo "$HEALTH" | grep -qi "PASSED"; then + msg_ok "$(translate 'SMART health status: PASSED')" + else + msg_warn "$HEALTH" + fi + echo "" + echo "$ATTRS" | awk 'NR==1 || /Reallocated_Sector|Current_Pending|Uncorrectable|Temperature_Celsius|Power_On_Hours|Wear_Leveling|Media_Wearout/' + fi + fi + ;; + + # ── Full report (scrollable) ──────────────────────────── + report) + msg_info "$(translate 'Reading full SMART report...')" + TMPFILE=$(mktemp) + if _smart_is_nvme "$SELECTED_DISK"; then + nvme smart-log "$SELECTED_DISK" > "$TMPFILE" 2>/dev/null + nvme id-ctrl "$SELECTED_DISK" >> "$TMPFILE" 2>/dev/null + else + smartctl -x "$SELECTED_DISK" > "$TMPFILE" 2>/dev/null + fi + stop_spinner + if [[ -s "$TMPFILE" ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate 'Full SMART Report') — $SELECTED_DISK" \ + --textbox "$TMPFILE" 40 $UI_RESULT_W + else + msg_error "$(translate 'Could not read SMART data from') $SELECTED_DISK" + fi + rm -f "$TMPFILE" + ;; + + # ── Short test ────────────────────────────────────────── + short) + if _smart_is_nvme "$SELECTED_DISK"; then + msg_info "$(translate 'Starting NVMe short self-test...')" + if nvme device-self-test "$SELECTED_DISK" --self-test-code=1 >/dev/null 2>&1; then + stop_spinner + msg_ok "$(translate 'Short self-test started on') $SELECTED_DISK" + msg_ok "$(translate 'Test typically completes in ~2 minutes.')" + msg_ok "$(translate 'Use "Check test progress" to see results.')" + else + stop_spinner + msg_error "$(translate 'Failed to start self-test on') $SELECTED_DISK" + fi + else + msg_info "$(translate 'Starting SMART short self-test...')" + OUTPUT=$(smartctl -t short "$SELECTED_DISK" 2>/dev/null) + stop_spinner + if echo "$OUTPUT" | grep -qi "Test will complete"; then + msg_ok "$(translate 'Short self-test started on') $SELECTED_DISK" + ESTIMATE=$(echo "$OUTPUT" | grep -i "complete after" | head -1) + [[ -n "$ESTIMATE" ]] && msg_ok "$ESTIMATE" + msg_ok "$(translate 'Use "Check test progress" to see results.')" + else + msg_error "$(translate 'Failed to start self-test on') $SELECTED_DISK" + echo "$OUTPUT" | tail -5 + fi + fi + ;; + + # ── Long test (background) ────────────────────────────── + long) + JSON_PATH=$(_smart_json_path "$SELECTED_DISK") + DISK_SAFE=$(printf '%q' "$SELECTED_DISK") + JSON_SAFE=$(printf '%q' "$JSON_PATH") + + if _smart_is_nvme "$SELECTED_DISK"; then + msg_info "$(translate 'Starting NVMe long self-test...')" + if nvme device-self-test "$SELECTED_DISK" --self-test-code=2 >/dev/null 2>&1; then + stop_spinner + msg_ok "$(translate 'Long self-test started on') $SELECTED_DISK" + DISK_LABEL_SAFE=$(printf '%q' "$DISK_LABEL") + NOTIFY_SCRIPT="/usr/bin/notification_manager.py" + nohup bash -c " + while nvme device-self-test ${DISK_SAFE} --self-test-code=0 2>/dev/null | grep -qi 'in progress'; do + sleep 60 + done + nvme smart-log -o json ${DISK_SAFE} > ${JSON_SAFE} 2>/dev/null + + # Send notification when test completes + if [[ -f \"${NOTIFY_SCRIPT}\" ]]; then + HOSTNAME=\$(hostname -s) + TEST_RESULT=\$(nvme self-test-log ${DISK_SAFE} 2>/dev/null | head -20) + if echo \"\$TEST_RESULT\" | grep -qi 'completed without error\|success'; then + python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity INFO \ + --title \"\${HOSTNAME}: SMART Long Test Completed\" \ + --message \"NVMe disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed successfully.\" 2>/dev/null || true + else + python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity WARNING \ + --title \"\${HOSTNAME}: SMART Long Test Completed\" \ + --message \"NVMe disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed. Check results for details.\" 2>/dev/null || true + fi + fi + " >/dev/null 2>&1 & + disown $! + else + stop_spinner + msg_error "$(translate 'Failed to start long self-test on') $SELECTED_DISK" + fi + else + msg_info "$(translate 'Starting SMART long self-test...')" + OUTPUT=$(smartctl -t long "$SELECTED_DISK" 2>/dev/null) + stop_spinner + if echo "$OUTPUT" | grep -qi "Test will complete"; then + msg_ok "$(translate 'Long self-test started on') $SELECTED_DISK" + ESTIMATE=$(echo "$OUTPUT" | grep -i "complete after" | head -1) + [[ -n "$ESTIMATE" ]] && msg_ok "$ESTIMATE" + echo "" + msg_ok "$(translate 'Test runs on the drive hardware — safe to close this terminal.')" + msg_ok "$(translate 'Results will be saved to:') $JSON_PATH" + DISK_LABEL_SAFE=$(printf '%q' "$DISK_LABEL") + NOTIFY_SCRIPT="/usr/bin/notification_manager.py" + nohup bash -c " + while smartctl -c ${DISK_SAFE} 2>/dev/null | grep -qiE 'Self-test routine in progress|[1-9][0-9]?% of test remaining'; do + sleep 60 + done + smartctl --json=c ${DISK_SAFE} > ${JSON_SAFE} 2>/dev/null + + # Send notification when test completes + if [[ -f \"${NOTIFY_SCRIPT}\" ]]; then + HOSTNAME=\$(hostname -s) + TEST_RESULT=\$(smartctl -l selftest ${DISK_SAFE} 2>/dev/null | grep -E '^# ?1') + if echo \"\$TEST_RESULT\" | grep -qi 'Completed without error'; then + python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity INFO \ + --title \"\${HOSTNAME}: SMART Long Test Completed\" \ + --message \"Disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed successfully.\" 2>/dev/null || true + elif echo \"\$TEST_RESULT\" | grep -qi 'error\|fail'; then + python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity CRITICAL \ + --title \"\${HOSTNAME}: SMART Long Test FAILED\" \ + --message \"Disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed with ERRORS. Check disk health immediately.\" 2>/dev/null || true + else + python3 \"${NOTIFY_SCRIPT}\" --action send-raw --severity INFO \ + --title \"\${HOSTNAME}: SMART Long Test Completed\" \ + --message \"Disk ${DISK_SAFE} (${DISK_LABEL_SAFE}) - Long self-test completed. Check results for details.\" 2>/dev/null || true + fi + fi + " >/dev/null 2>&1 & + disown $! + else + msg_error "$(translate 'Failed to start long self-test on') $SELECTED_DISK" + echo "$OUTPUT" | tail -5 + fi + fi + ;; + + # ── Check progress ────────────────────────────────────── + progress) + if _smart_is_nvme "$SELECTED_DISK"; then + msg_info "$(translate 'Reading NVMe self-test log...')" + OUTPUT=$(nvme self-test-log "$SELECTED_DISK" 2>/dev/null) + stop_spinner + if [[ -z "$OUTPUT" ]]; then + msg_warn "$(translate 'No self-test log available for') $SELECTED_DISK" + else + echo "$OUTPUT" | head -30 + fi + else + msg_info "$(translate 'Reading SMART self-test log...')" + # Active test: only "X% of test remaining" appears when a test is actually running + ACTIVE=$(smartctl -c "$SELECTED_DISK" 2>/dev/null | grep -iE "[1-9][0-9]?% of test remaining|Self-test routine in progress") + # Log: grab only result rows (^# N ...) and the column header (^Num) + LOG_OUT=$(smartctl -l selftest "$SELECTED_DISK" 2>/dev/null) + LOG_HEADER=$(echo "$LOG_OUT" | grep -E "^Num") + LOG_ENTRIES=$(echo "$LOG_OUT" | grep -E "^# ?[0-9]") + stop_spinner + if [[ -n "$ACTIVE" ]]; then + msg_ok "$(translate 'Test in progress:')" + echo "$ACTIVE" + echo "" + else + msg_ok "$(translate 'No test currently running')" + echo "" + fi + if [[ -n "$LOG_ENTRIES" ]]; then + msg_ok "$(translate 'Recent test results:')" + [[ -n "$LOG_HEADER" ]] && echo "$LOG_HEADER" + echo "$LOG_ENTRIES" + else + msg_warn "$(translate 'No self-test history found for') $SELECTED_DISK" + fi + fi + ;; + + esac + + # ── Auto-export JSON (except long — handled by background monitor) + if [[ "$ACTION" != "long" && "$ACTION" != "report" ]]; then + JSON_PATH=$(_smart_json_path "$SELECTED_DISK") + if _smart_is_nvme "$SELECTED_DISK"; then + nvme smart-log -o json "$SELECTED_DISK" > "$JSON_PATH" 2>/dev/null + else + smartctl --json=c "$SELECTED_DISK" > "$JSON_PATH" 2>/dev/null + fi + [[ -s "$JSON_PATH" ]] || rm -f "$JSON_PATH" + fi + + # ── "report" uses dialog --textbox, no Press Enter needed + if [[ "$ACTION" != "report" ]]; then + echo "" + msg_success "$(translate 'Press Enter to continue...')" + read -r + fi + +done diff --git a/scripts/storage/unmount-disk-from-host.sh b/scripts/storage/unmount-disk-from-host.sh deleted file mode 100644 index bc776170..00000000 --- a/scripts/storage/unmount-disk-from-host.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash - -# ========================================================== -# ProxMenux - A menu-driven script for Proxmox VE management -# ========================================================== -# Author : MacRimi -# Copyright : (c) 2024 MacRimi -# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) -# Version : 1.0 -# Last Updated: 28/01/2025 -# Description : Allows unmounting a previously mounted disk -# ========================================================== - -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" -BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" - -if [[ -f "$UTILS_FILE" ]]; then - source "$UTILS_FILE" -fi -load_language -initialize_cache - - -MOUNTED_DISKS=($(mount | grep '^/dev/' | grep 'on /mnt/' | awk '{print $3}')) - -if [[ ${#MOUNTED_DISKS[@]} -eq 0 ]]; then - whiptail --title "$(translate "No Disks")" --msgbox "$(translate "No mounted disks found under /mnt.")" 8 50 - exit 0 -fi - - -MENU_ITEMS=() -for MNT in "${MOUNTED_DISKS[@]}"; do - UUID=$(blkid | grep "$MNT" | awk '{print $2}' | tr -d '"') - DESC="$MNT $UUID" - MENU_ITEMS+=("$MNT" "$DESC") -done - -SELECTED=$(whiptail --title "$(translate "Unmount Disk")" --menu "$(translate "Select the disk you want to unmount:")" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) - -[[ -z "$SELECTED" ]] && exit 0 - - -whiptail --title "$(translate "Confirm Unmount")" --yesno "$(translate "Are you sure you want to unmount") $SELECTED?" 10 60 || exit 0 - - -umount "$SELECTED" 2>/dev/null -if [ $? -ne 0 ]; then - whiptail --title "$(translate "Error")" --msgbox "$(translate "Failed to unmount disk at") $SELECTED" 8 60 - exit 1 -else - msg_ok "$(translate "Unmounted:") $SELECTED" -fi - - -whiptail --title "$(translate "Delete Mount Folder")" --yesno "$(translate "Do you want to delete the mount point folder") $SELECTED?" 10 60 -if [ $? -eq 0 ]; then - rm -rf "$SELECTED" - msg_ok "$(translate "Deleted folder:") $SELECTED" -fi - - -DEVICE=$(findmnt -no SOURCE "$SELECTED") -UUID=$(blkid -s UUID -o value "$DEVICE") - -if [ -n "$UUID" ]; then - sed -i "/UUID=$UUID/d" /etc/fstab - msg_ok "$(translate "fstab entry removed for") $UUID" -fi - -whiptail --title "$(translate "Done")" --msgbox "$(translate "Disk unmounted and cleaned successfully.")" 8 60 - diff --git a/scripts/utilities/export_vm_ova_ovf.sh b/scripts/utilities/export_vm_ova_ovf.sh new file mode 100755 index 00000000..369084c3 --- /dev/null +++ b/scripts/utilities/export_vm_ova_ovf.sh @@ -0,0 +1,628 @@ +#!/bin/bash +# ========================================================== +# ProxMenux - Export VM to OVA or OVF +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) +# Version : 1.0 +# Last Updated: 07/04/2026 +# ========================================================== + +LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$BASE_DIR/utils.sh" + +if [[ -f "$UTILS_FILE" ]]; then + source "$UTILS_FILE" +fi + +load_language +initialize_cache + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + dialog --backtitle "ProxMenux" --title "$(translate "Missing dependency")" \ + --msgbox "$(translate "Required command not found:") $cmd" 8 60 + return 1 + fi + return 0 +} + +human_bytes() { + local bytes="$1" + local units=("B" "KB" "MB" "GB" "TB" "PB") + local idx=0 + local value="$bytes" + + [[ -z "$value" || ! "$value" =~ ^[0-9]+$ ]] && { echo "N/A"; return; } + + while [[ "$value" -ge 1024 && "$idx" -lt 5 ]]; do + value=$((value / 1024)) + idx=$((idx + 1)) + done + + echo "${value}${units[$idx]}" +} + +sanitize_name() { + local raw="$1" + local out + out=$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-zA-Z0-9._-]/_/g' | sed 's/__*/_/g' | sed 's/^_\+//;s/_\+$//') + [[ -z "$out" ]] && out="vm" + echo "$out" +} + +xml_escape() { + local s="$1" + s=${s//&/&} + s=${s///>} + s=${s//\"/"} + s=${s//\'/'} + echo "$s" +} + +validate_destination_dir() { + local dir="$1" + if [[ ! -d "$dir" ]]; then + dialog --backtitle "ProxMenux" --title "$(translate "Directory error")" \ + --msgbox "$(translate "Destination directory does not exist:")\n$dir" 8 74 + return 1 + fi + + if [[ ! -w "$dir" ]]; then + dialog --backtitle "ProxMenux" --title "$(translate "Permission error")" \ + --msgbox "$(translate "Destination directory is not writable:")\n$dir" 8 70 + return 1 + fi + + return 0 +} + +select_vm() { + local options=() + local line vmid name status + + while read -r line; do + [[ -z "$line" ]] && continue + vmid=$(echo "$line" | awk '{print $1}') + name=$(echo "$line" | awk '{print $2}') + status=$(echo "$line" | awk '{print $3}') + [[ -z "$vmid" || "$vmid" == "VMID" ]] && continue + [[ -z "$name" ]] && name="vm-${vmid}" + options+=("$vmid" "$name [$status]") + done < <(qm list 2>/dev/null) + + if [[ ${#options[@]} -eq 0 ]]; then + dialog --backtitle "ProxMenux" --title "$(translate "No VMs found")" \ + --msgbox "$(translate "No virtual machines were found on this host.")" 8 60 + return 1 + fi + + VMID=$(dialog --backtitle "ProxMenux" --title "$(translate "Export VM to OVA or OVF")" \ + --menu "$(translate "Select VM to export:")" 20 80 12 \ + "${options[@]}" 3>&1 1>&2 2>&3) + + [[ -n "$VMID" ]] || return 1 + return 0 +} + +ensure_vm_stopped() { + local status + status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') + + if [[ "$status" == "stopped" ]]; then + return 0 + fi + + if ! dialog --backtitle "ProxMenux" --title "$(translate "VM is running")" --yesno \ + "$(translate "For a consistent export, the VM should be stopped.")\n\n$(translate "Do you want ProxMenux to stop it now?")" 10 70; then + return 1 + fi + + qm shutdown "$VMID" --timeout 120 >/dev/null 2>&1 || true + + local i + for i in $(seq 1 60); do + status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') + [[ "$status" == "stopped" ]] && return 0 + sleep 2 + done + + if dialog --backtitle "ProxMenux" --title "$(translate "Shutdown timeout")" --yesno \ + "$(translate "Graceful shutdown timed out.")\n\n$(translate "Force stop VM now?")" 10 60; then + qm stop "$VMID" >/dev/null 2>&1 || true + sleep 2 + status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') + [[ "$status" == "stopped" ]] && return 0 + fi + + dialog --backtitle "ProxMenux" --title "$(translate "Cannot continue")" \ + --msgbox "$(translate "VM is still running. Export cancelled.")" 8 60 + return 1 +} + +select_export_mode() { + EXPORT_MODE=$(dialog --backtitle "ProxMenux" --title "$(translate "Export Format")" \ + --menu "$(translate "Select export format:")" 14 70 4 \ + "ova" "$(translate "OVA (single portable file)")" \ + "ovf" "$(translate "OVF (descriptor + VMDK files)")" \ + 3>&1 1>&2 2>&3) + [[ -n "$EXPORT_MODE" ]] || return 1 + return 0 +} + +select_destination_dir() { + local dump_dir="/var/lib/vz/dump" + local iso_dir="/var/lib/vz/template/iso" + local options=( + "1" "$dump_dir [$(translate "recommended")]" + "2" "$iso_dir [$(translate "recommended")]" + "M" "$(translate "Manual path entry")" + ) + + while true; do + local choice + choice=$(dialog --backtitle "ProxMenux" --title "$(translate "Destination Directory")" \ + --menu "$(translate "Select where to export VM files (OVA/OVF + temporary workspace):")" \ + 16 84 8 "${options[@]}" 3>&1 1>&2 2>&3) + + [[ -n "$choice" ]] || return 1 + + case "$choice" in + M) + DEST_DIR=$(dialog --backtitle "ProxMenux" --title "$(translate "Manual destination path")" \ + --inputbox "$(translate "Enter destination directory for exported file(s):")" \ + 10 90 "/var/lib/vz/dump" 3>&1 1>&2 2>&3) + [[ -n "$DEST_DIR" ]] || continue + if [[ ! -d "$DEST_DIR" ]]; then + if dialog --backtitle "ProxMenux" --title "$(translate "Create directory")" \ + --yesno "$(translate "The selected directory does not exist:")\n$DEST_DIR\n\n$(translate "Do you want to create it now?")" \ + 11 80; then + if ! mkdir -p "$DEST_DIR" 2>/dev/null; then + dialog --backtitle "ProxMenux" --title "$(translate "Directory error")" \ + --msgbox "$(translate "Could not create destination directory:")\n$DEST_DIR" 8 74 + continue + fi + else + continue + fi + fi + validate_destination_dir "$DEST_DIR" || continue + return 0 + ;; + 1) + DEST_DIR="$dump_dir" + validate_destination_dir "$DEST_DIR" || continue + return 0 + ;; + 2) + DEST_DIR="$iso_dir" + validate_destination_dir "$DEST_DIR" || continue + return 0 + ;; + *) + continue + ;; + esac + done +} + +get_vm_metadata() { + VM_CONF=$(qm config "$VMID" 2>/dev/null) || return 1 + + VM_NAME=$(echo "$VM_CONF" | awk -F': ' '/^name:/{print $2; exit}') + [[ -z "$VM_NAME" ]] && VM_NAME="vm-${VMID}" + + VM_MEMORY=$(echo "$VM_CONF" | awk -F': ' '/^memory:/{print $2; exit}') + [[ -z "$VM_MEMORY" ]] && VM_MEMORY=1024 + + VM_CORES=$(echo "$VM_CONF" | awk -F': ' '/^cores:/{print $2; exit}') + VM_SOCKETS=$(echo "$VM_CONF" | awk -F': ' '/^sockets:/{print $2; exit}') + [[ -z "$VM_CORES" ]] && VM_CORES=1 + [[ -z "$VM_SOCKETS" ]] && VM_SOCKETS=1 + VM_VCPUS=$((VM_CORES * VM_SOCKETS)) + + VM_OSTYPE=$(echo "$VM_CONF" | awk -F': ' '/^ostype:/{print $2; exit}') + case "$VM_OSTYPE" in + l26|l24) VM_OS_DESC="Linux" ;; + win11|win10|win8|win7|w2k8|w2k12|w2k16|w2k19|w2k22|wxp|w2k|w2k3) + VM_OS_DESC="Windows" + ;; + *) VM_OS_DESC="Other" ;; + esac + + NET_COUNT=$(echo "$VM_CONF" | grep -E '^net[0-9]+:' | wc -l) +} + +get_virtual_size_bytes() { + local src="$1" + local bytes="" + + bytes=$(qemu-img info "$src" 2>/dev/null | sed -n 's/.*virtual size:.*(\([0-9]\+\) bytes).*/\1/p' | head -1) + if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then + echo "$bytes" + return 0 + fi + + if [[ -b "$src" ]]; then + bytes=$(blockdev --getsize64 "$src" 2>/dev/null || true) + if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then + echo "$bytes" + return 0 + fi + fi + + bytes=$(stat -c%s "$src" 2>/dev/null || true) + if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then + echo "$bytes" + return 0 + fi + + echo "0" + return 0 +} + +collect_vm_disks() { + DISK_COUNT=0 + unset DISK_SLOTS DISK_SRCS DISK_VSIZES + declare -ga DISK_SLOTS DISK_SRCS DISK_VSIZES + + local line slot value source src + + while IFS= read -r line; do + if [[ "$line" =~ ^(scsi|sata|virtio|ide)[0-9]+: ]]; then + slot="${line%%:*}" + value="${line#*: }" + + [[ "$value" == *"media=cdrom"* ]] && continue + [[ "$value" == *"cloudinit"* ]] && continue + + source="${value%%,*}" + [[ -z "$source" || "$source" == "none" ]] && continue + + src="" + if [[ "$source" == /dev/* || "$source" == /* ]]; then + src="$source" + elif [[ "$source" == *:* ]]; then + src=$(pvesm path "$source" 2>/dev/null || true) + fi + + if [[ -z "$src" || ! -e "$src" ]]; then + continue + fi + + DISK_SLOTS+=("$slot") + DISK_SRCS+=("$src") + DISK_VSIZES+=("$(get_virtual_size_bytes "$src")") + DISK_COUNT=$((DISK_COUNT + 1)) + fi + done <<< "$VM_CONF" + + [[ "$DISK_COUNT" -gt 0 ]] || return 1 + return 0 +} + +check_destination_space() { + local total=0 + local i + for i in "${DISK_VSIZES[@]}"; do + [[ "$i" =~ ^[0-9]+$ ]] && total=$((total + i)) + done + + local factor=120 + [[ "$EXPORT_MODE" == "ova" ]] && factor=220 + REQUIRED_BYTES=$((total * factor / 100)) + + AVAILABLE_BYTES=$(df -PB1 "$DEST_DIR" | awk 'NR==2{print $4}') + [[ "$AVAILABLE_BYTES" =~ ^[0-9]+$ ]] || AVAILABLE_BYTES=0 + + if [[ "$AVAILABLE_BYTES" -lt "$REQUIRED_BYTES" ]]; then + if ! dialog --backtitle "ProxMenux" --title "$(translate "Low free space warning")" --yesno \ + "$(translate "Estimated required free space:") $(human_bytes "$REQUIRED_BYTES") ($REQUIRED_BYTES bytes)\n$(translate "Current free space:") $(human_bytes "$AVAILABLE_BYTES") ($AVAILABLE_BYTES bytes)\n\n$(translate "Do you want to continue anyway?")" 13 90; then + return 1 + fi + fi + + return 0 +} + +generate_ovf_descriptor() { + local ovf_path="$1" + local vm_name_xml os_desc_xml + vm_name_xml=$(xml_escape "$VM_NAME") + os_desc_xml=$(xml_escape "$VM_OS_DESC") + + { + echo '' + echo '' + echo ' ' + } > "$ovf_path" + + local idx file_id disk_id file_name file_size capacity + for idx in "${!EXPORT_DISK_FILES[@]}"; do + file_id="file$((idx + 1))" + file_name="${EXPORT_DISK_FILES[$idx]}" + file_size=$(stat -c%s "$WORK_DIR/$file_name") + echo " " >> "$ovf_path" + done + + { + echo ' ' + echo ' ' + echo ' Virtual disk information' + } >> "$ovf_path" + + for idx in "${!EXPORT_DISK_FILES[@]}"; do + file_id="file$((idx + 1))" + disk_id="vmdisk$((idx + 1))" + capacity="${DISK_VSIZES[$idx]}" + [[ -z "$capacity" || "$capacity" -le 0 ]] && capacity=$(stat -c%s "$WORK_DIR/${EXPORT_DISK_FILES[$idx]}") + echo " " >> "$ovf_path" + done + + { + echo ' ' + echo " " + echo ' A virtual machine' + echo " $vm_name_xml" + echo ' ' + echo ' Guest operating system' + echo " $os_desc_xml" + echo ' ' + echo ' ' + echo ' Virtual hardware requirements' + echo ' ' + echo ' Virtual Hardware Family' + echo ' 0' + echo ' vm' + echo ' vmx-14' + echo ' ' + echo ' ' + echo ' hertz * 10^6' + echo ' Number of Virtual CPUs' + echo ' Virtual CPU(s)' + echo ' 1' + echo " $VM_VCPUS" + echo ' 3' + echo ' ' + echo ' ' + echo ' byte * 2^20' + echo ' Memory Size' + echo ' Memory' + echo ' 2' + echo " $VM_MEMORY" + echo ' 4' + echo ' ' + echo ' ' + echo ' 0' + echo ' SCSI Controller' + echo ' SCSI Controller 0' + echo ' 10' + echo ' lsilogic' + echo ' 6' + echo ' ' + } >> "$ovf_path" + + for idx in "${!EXPORT_DISK_FILES[@]}"; do + disk_id="vmdisk$((idx + 1))" + echo ' ' >> "$ovf_path" + echo " $idx" >> "$ovf_path" + echo ' Hard disk' >> "$ovf_path" + echo " Hard disk $((idx + 1))" >> "$ovf_path" + echo " ovf:/disk/$disk_id" >> "$ovf_path" + echo " $((200 + idx + 1))" >> "$ovf_path" + echo ' 10' >> "$ovf_path" + echo ' 17' >> "$ovf_path" + echo ' ' >> "$ovf_path" + done + + if [[ "$NET_COUNT" -gt 0 ]]; then + local n + for n in $(seq 1 "$NET_COUNT"); do + { + echo ' ' + echo ' true' + echo ' VM Network' + echo " Ethernet adapter $n" + echo " $((300 + n))" + echo ' 10' + echo ' ' + } >> "$ovf_path" + done + fi + + { + echo ' ' + echo ' ' + echo '' + } >> "$ovf_path" +} + +generate_manifest() { + local mf_path="$1" + shift + local files=("$@") + : > "$mf_path" + + local f hash + for f in "${files[@]}"; do + hash=$(sha1sum "$WORK_DIR/$f" | awk '{print $1}') + echo "SHA1($f)= $hash" >> "$mf_path" + done +} + +print_export_result() { + local mode="$1" + local path="$2" + + echo "" + msg_title "$(translate "Export Summary")" + + msg_ok "$(translate "VM:") ${VMID} — ${VM_NAME}" + msg_ok "$(translate "vCPUs:") ${VM_VCPUS} $(translate "Memory:") ${VM_MEMORY} MB $(translate "Disks exported:") ${DISK_COUNT}" + echo "" + + if [[ "$mode" == "ova" ]]; then + local ova_size ova_sha1 + ova_size=$(stat -c%s "$path" 2>/dev/null || echo 0) + ova_sha1=$(sha1sum "$path" 2>/dev/null | awk '{print $1}') + msg_ok "$(translate "Format:") OVA — $(translate "single portable archive")" + msg_ok "$(translate "File:") $path" + msg_ok "$(translate "Size:") $(human_bytes "$ova_size") (${ova_size} $(translate "bytes"))" + msg_ok "SHA1: ${ova_sha1}" + else + local fsz total_size=0 f + msg_ok "$(translate "Format:") OVF — $(translate "descriptor + VMDK files")" + msg_ok "$(translate "Directory:") $path" + for f in "${EXPORT_DISK_FILES[@]}"; do + fsz=$(stat -c%s "$path/$f" 2>/dev/null || echo 0) + total_size=$((total_size + fsz)) + msg_info2 " ${f} [$(human_bytes "$fsz")]" + done + msg_ok "$(translate "Total size:") $(human_bytes "$total_size")" + fi + + echo "" + msg_ok "$(translate "Compatible with:") VMware ESXi 6.7+ (vmx-14) · VMware Workstation / Fusion · VirtualBox · Proxmox VE" + msg_info2 "$(translate "Not portable:") $(translate "PCI passthrough, TPM state, cloud-init configuration, Proxmox hooks")" + echo "" +} + +run_export() { + show_proxmenux_logo + msg_title "$(translate "Export VM to OVA or OVF")" + + msg_ok "$(translate "VM selected:") $VMID ($VM_NAME)" + msg_ok "$(translate "Export mode:") ${EXPORT_MODE^^}" + msg_ok "$(translate "Destination:") $DEST_DIR" + + local ts vm_safe base_name + ts=$(date +%Y%m%d_%H%M%S) + vm_safe=$(sanitize_name "$VM_NAME") + base_name="${vm_safe}-${VMID}-${ts}" + + WORK_DIR=$(mktemp -d "$DEST_DIR/.ovaovf-${base_name}-XXXXXX") + if [[ ! -d "$WORK_DIR" ]]; then + msg_error "$(translate "Could not create temporary working directory.")" + return 1 + fi + + msg_ok "$(translate "Working directory:") $WORK_DIR" + + # Clean up temp dir on unexpected exit (Ctrl+C, unhandled error, etc.) + trap 'rm -rf "$WORK_DIR" 2>/dev/null' EXIT + + declare -ga EXPORT_DISK_FILES + EXPORT_DISK_FILES=() + + local i src dst disk_name + for i in "${!DISK_SRCS[@]}"; do + src="${DISK_SRCS[$i]}" + disk_name="${base_name}-disk$((i + 1)).vmdk" + dst="$WORK_DIR/$disk_name" + + echo "" + msg_info "$(translate "Converting disk") $((i + 1))/$DISK_COUNT: ${DISK_SLOTS[$i]}" + msg_info2 "$(translate "Source:") $src" + + if ! qemu-img convert -p -O vmdk -o subformat=streamOptimized "$src" "$dst"; then + msg_error "$(translate "Disk conversion failed for") ${DISK_SLOTS[$i]}" + return 1 + fi + + EXPORT_DISK_FILES+=("$disk_name") + msg_ok "$(translate "Converted:") $disk_name" + done + + local ovf_file mf_file + ovf_file="${base_name}.ovf" + mf_file="${base_name}.mf" + + msg_info "$(translate "Generating OVF descriptor...")" + generate_ovf_descriptor "$WORK_DIR/$ovf_file" + + msg_info "$(translate "Generating manifest...")" + generate_manifest "$WORK_DIR/$mf_file" "$ovf_file" "${EXPORT_DISK_FILES[@]}" + + if [[ "$EXPORT_MODE" == "ovf" ]]; then + local final_dir="$DEST_DIR/${base_name}-ovf" + rm -rf "$final_dir" + trap - EXIT + mv "$WORK_DIR" "$final_dir" + + print_export_result "ovf" "$final_dir" + return 0 + fi + + local ova_path="$DEST_DIR/${base_name}.ova" + msg_info "$(translate "Packaging OVA file...")" + + if ! tar -C "$WORK_DIR" -cf "$ova_path" "$ovf_file" "$mf_file" "${EXPORT_DISK_FILES[@]}"; then + msg_error "$(translate "Failed to create OVA archive.")" + return 1 + fi + + trap - EXIT + rm -rf "$WORK_DIR" + + print_export_result "ova" "$ova_path" + return 0 +} + +main() { + require_cmd dialog || exit 1 + require_cmd qm || exit 1 + require_cmd pvesm || exit 1 + require_cmd qemu-img || exit 1 + require_cmd tar || exit 1 + require_cmd sha1sum || exit 1 + + if ! command -v pveversion >/dev/null 2>&1; then + dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ + --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 + exit 1 + fi + + select_vm || exit 0 + ensure_vm_stopped || exit 0 + select_export_mode || exit 0 + select_destination_dir || exit 0 + + get_vm_metadata || { + dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ + --msgbox "$(translate "Could not read VM configuration.")" 8 60 + exit 1 + } + + collect_vm_disks || { + dialog --backtitle "ProxMenux" --title "$(translate "No exportable disks")" \ + --msgbox "$(translate "No exportable VM disks were found (CD-ROM/cloud-init are excluded).")" 9 80 + exit 1 + } + + check_destination_space || exit 0 + + if ! dialog --backtitle "ProxMenux" --title "$(translate "Confirm export")" --yesno \ + "$(translate "VM:") $VMID ($VM_NAME)\n$(translate "Disks to export:") $DISK_COUNT\n$(translate "Format:") ${EXPORT_MODE^^}\n$(translate "Destination:") $DEST_DIR\n\n$(translate "Continue?")" 13 80; then + exit 0 + fi + + if run_export; then + echo "" + msg_success "$(translate "Press Enter to return...")" + read -r + exit 0 + else + echo "" + msg_error "$(translate "Export failed.")" + msg_info2 "$(translate "Temporary working directory (if present):") $WORK_DIR" + msg_success "$(translate "Press Enter to return...")" + read -r + exit 1 + fi +} + +main "$@" diff --git a/scripts/utilities/import_vm_ova_ovf.sh b/scripts/utilities/import_vm_ova_ovf.sh new file mode 100755 index 00000000..b41f6ab6 --- /dev/null +++ b/scripts/utilities/import_vm_ova_ovf.sh @@ -0,0 +1,612 @@ +#!/bin/bash +# ========================================================== +# ProxMenux - Import VM from OVA or OVF +# ========================================================== +# Author : MacRimi +# Copyright : (c) 2024 MacRimi +# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) +# Version : 1.0 +# Last Updated: 10/04/2026 +# ========================================================== +# Description: +# Imports a virtual machine from an OVA or OVF package into Proxmox VE. +# Compatible with exports from VMware ESXi, VMware Workstation/Fusion, +# VirtualBox, and Proxmox itself (via export_vm_ova_ovf). +# +# What is imported: +# - Disk images (VMDK converted to the target storage format) +# - CPU and memory settings +# - Number of network interfaces +# - VM name and OS type hint +# +# What requires manual review after import: +# - Network bridge assignment (vmbr0 assigned by default) +# - NIC model (e1000 by default — change to VirtIO if guest supports it) +# - Firmware (BIOS/UEFI — must match what the original VM used) +# - VirtIO/qemu-guest-agent installation inside the guest (especially from ESXi) +# - PCI passthrough, TPM, cloud-init, snapshots — not portable in OVF/OVA +# ========================================================== + +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$BASE_DIR/utils.sh" + +[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE" +load_language +initialize_cache + +BACKTITLE="ProxMenux" +UI_MENU_H=20 +UI_MENU_W=84 +UI_MENU_LIST_H=10 + +# Globals populated during the flow +SOURCE_FILE="" +OVF_FILE="" +OVF_DIR="" +WORK_DIR="" + +OVF_VM_NAME="" +OVF_VCPUS=1 +OVF_MEMORY_MB=1024 +OVF_DISK_FILES=() +OVF_DISK_CAPACITIES=() +OVF_NET_COUNT=0 +OVF_OS_TYPE="other" + +NEW_VMID="" +NEW_VM_NAME="" +STORAGE="" +BRIDGE="vmbr0" + + +# ------------------------------------------------------- +# HELPERS +# ------------------------------------------------------- + +human_bytes() { + local bytes="$1" + local units=("B" "KB" "MB" "GB" "TB") + local idx=0 value="$bytes" + [[ -z "$value" || ! "$value" =~ ^[0-9]+$ ]] && { echo "N/A"; return; } + while [[ "$value" -ge 1024 && "$idx" -lt 4 ]]; do + value=$((value / 1024)) + idx=$((idx + 1)) + done + echo "${value}${units[$idx]}" +} + + +# ------------------------------------------------------- +# SELECT SOURCE FILE +# ------------------------------------------------------- + +select_source_file() { + local dump_dir="/var/lib/vz/dump" + local iso_dir="/var/lib/vz/template/iso" + local options=( + "1" "$dump_dir" + "2" "$iso_dir" + "M" "$(translate "Manual path entry")" + ) + + while true; do + local choice + choice=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Import VM from OVA or OVF")" \ + --menu "$(translate "Where is the OVA/OVF file located?")" \ + 14 82 4 "${options[@]}" 3>&1 1>&2 2>&3) + [[ -n "$choice" ]] || return 1 + + local search_dir="" + case "$choice" in + 1) search_dir="$dump_dir" ;; + 2) search_dir="$iso_dir" ;; + M) + search_dir=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Custom Path")" \ + --inputbox "\n$(translate "Enter directory containing OVA/OVF files:")" \ + 10 82 "/var/lib/vz/dump" 3>&1 1>&2 2>&3) + [[ -n "$search_dir" ]] || continue + ;; + esac + + if [[ ! -d "$search_dir" ]]; then + dialog --backtitle "$BACKTITLE" --title "$(translate "Not found")" \ + --msgbox "$(translate "Directory does not exist:")\n$search_dir" 8 74 + continue + fi + + local file_opts=() + while IFS= read -r f; do + local fname size_h + fname=$(basename "$f") + size_h=$(du -sh "$f" 2>/dev/null | awk '{print $1}') + file_opts+=("$f" "$fname [$size_h]") + done < <(find "$search_dir" -maxdepth 2 \( -name "*.ova" -o -name "*.ovf" \) 2>/dev/null | sort) + + if [[ ${#file_opts[@]} -eq 0 ]]; then + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "No files found")" \ + --msgbox "$(translate "No .ova or .ovf files found in:")\n\n$search_dir" 10 74 + continue + fi + + local selected + selected=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select OVA/OVF file")" \ + --menu "$(translate "Select the file to import:")" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${file_opts[@]}" 3>&1 1>&2 2>&3) + + [[ -n "$selected" ]] || continue + + SOURCE_FILE="$selected" + return 0 + done +} + + +# ------------------------------------------------------- +# EXTRACT OVA / LOCATE OVF +# ------------------------------------------------------- + +prepare_ovf() { + local src="$SOURCE_FILE" + local ext="${src##*.}" + ext="${ext,,}" + + if [[ "$ext" == "ova" ]]; then + WORK_DIR=$(mktemp -d "/tmp/.proxmenux-import-XXXXXX") + trap 'rm -rf "$WORK_DIR" 2>/dev/null' EXIT + + msg_info "$(translate "Extracting OVA archive...")" + if ! tar xf "$src" -C "$WORK_DIR" 2>/dev/null; then + msg_error "$(translate "Failed to extract OVA file:") $src" + return 1 + fi + msg_ok "$(translate "Archive extracted.")" + + OVF_FILE=$(find "$WORK_DIR" -maxdepth 2 -name "*.ovf" | head -1) + if [[ -z "$OVF_FILE" ]]; then + msg_error "$(translate "No .ovf descriptor found inside OVA.")" + return 1 + fi + OVF_DIR=$(dirname "$OVF_FILE") + + elif [[ "$ext" == "ovf" ]]; then + OVF_FILE="$src" + OVF_DIR=$(dirname "$src") + WORK_DIR="" + + else + msg_error "$(translate "Unsupported format. Only .ova and .ovf files are supported.")" + return 1 + fi + + return 0 +} + + +# ------------------------------------------------------- +# PARSE OVF XML +# ------------------------------------------------------- + +parse_ovf() { + local ovf_file="$1" + + local result + result=$(awk ' + BEGIN { + in_item=0; rt=""; qty="" + file_count=0; cap_count=0; net_count=0 + name=""; vcpu="1"; mem="1024"; os="" + } + + /<[Nn]ame>/ { + match($0, /<[Nn]ame>([^<]+) 0) caps[cap_count++] = a[1] + } + + /|/ { + if (in_item) { + if (rt=="3" && qty ~ /^[0-9]+$/) vcpu=qty + if (rt=="4" && qty ~ /^[0-9]+$/) mem=qty + if (rt=="10") net_count++ + } + in_item=0 + } + /ResourceType>/ { + match($0, /ResourceType>([0-9]+)/ { + match($0, /VirtualQuantity>([0-9]+)/dev/null || echo "100") + + while true; do + NEW_VMID=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "VM ID")" \ + --inputbox "\n$(translate "Enter the VMID for the new VM:") ($(translate "suggested:") $suggested_vmid)" \ + 10 72 "$suggested_vmid" 3>&1 1>&2 2>&3) + [[ -n "$NEW_VMID" ]] || return 1 + + if ! [[ "$NEW_VMID" =~ ^[0-9]+$ ]]; then + dialog --backtitle "$BACKTITLE" --title "$(translate "Invalid VMID")" \ + --msgbox "$(translate "VMID must be a number.")" 8 50 + continue + fi + + if qm status "$NEW_VMID" &>/dev/null; then + dialog --backtitle "$BACKTITLE" --title "$(translate "VMID in use")" \ + --msgbox "$(translate "VMID $NEW_VMID is already in use. Please choose another.")" 8 60 + continue + fi + break + done + + # VM Name + NEW_VM_NAME=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "VM Name")" \ + --inputbox "\n$(translate "Enter name for the imported VM:")" \ + 10 72 "$OVF_VM_NAME" 3>&1 1>&2 2>&3) + [[ -n "$NEW_VM_NAME" ]] || return 1 + + # Storage + local storage_list storage_opts=() + storage_list=$(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1}') + if [[ -z "$storage_list" ]]; then + dialog --backtitle "$BACKTITLE" --title "$(translate "No storage")" \ + --msgbox "$(translate "No storage volumes available for VM images.")" 8 60 + return 1 + fi + while IFS= read -r s; do + storage_opts+=("$s" "") + done <<< "$storage_list" + + STORAGE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Select Storage")" \ + --menu "$(translate "Select storage for imported disk(s):")" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${storage_opts[@]}" 3>&1 1>&2 2>&3) + [[ -n "$STORAGE" ]] || return 1 + + # Network bridge + local bridge_opts=() + while IFS= read -r br; do + [[ -n "$br" ]] && bridge_opts+=("$br" "") + done < <(ip link show type bridge 2>/dev/null | awk -F': ' '/^[0-9]+:/{print $2}' | sed 's/@.*//') + + if [[ ${#bridge_opts[@]} -gt 1 ]]; then + BRIDGE=$(dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Network Bridge")" \ + --menu "$(translate "Select bridge for network interface(s):")" \ + $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ + "${bridge_opts[@]}" 3>&1 1>&2 2>&3) + [[ -n "$BRIDGE" ]] || return 1 + elif [[ ${#bridge_opts[@]} -eq 1 ]]; then + BRIDGE="${bridge_opts[0]}" + fi + + return 0 +} + + +# ------------------------------------------------------- +# CONFIRM BEFORE IMPORT (dialog) +# ------------------------------------------------------- + +confirm_import() { + local disk_count="${#OVF_DISK_FILES[@]}" + local disk_info="" i + + for i in "${!OVF_DISK_FILES[@]}"; do + local cap="${OVF_DISK_CAPACITIES[$i]:-0}" + disk_info+="\n disk$((i+1)): ${OVF_DISK_FILES[$i]} ($(human_bytes "$cap"))" + done + + local msg + msg="$(translate "New VM:") $NEW_VMID ($NEW_VM_NAME)\n" + msg+="$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "OS type:") $OVF_OS_TYPE\n" + msg+="$(translate "NICs:") $OVF_NET_COUNT $(translate "Bridge:") $BRIDGE\n" + msg+="$(translate "Storage:") $STORAGE\n" + msg+="$(translate "Disks to import:") $disk_count${disk_info}\n\n" + msg+="$(translate "Continue?")" + + dialog --backtitle "$BACKTITLE" \ + --title "$(translate "Confirm Import")" \ + --yesno "$msg" 18 84 3>&1 1>&2 2>&3 +} + + +# ------------------------------------------------------- +# RUN IMPORT (terminal output only — no dialogs) +# ------------------------------------------------------- + +run_import() { + show_proxmenux_logo + msg_title "$(translate "Import VM from OVA or OVF")" + + msg_ok "$(translate "VM:") $NEW_VMID ($NEW_VM_NAME)" + msg_ok "$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "OS:") $OVF_OS_TYPE" + msg_ok "$(translate "Storage:") $STORAGE $(translate "Bridge:") $BRIDGE $(translate "NICs:") $OVF_NET_COUNT" + echo "" + + # 1. Create VM shell + msg_info "$(translate "Creating VM...")" + if ! qm create "$NEW_VMID" \ + --name "$NEW_VM_NAME" \ + --memory "$OVF_MEMORY_MB" \ + --cores "$OVF_VCPUS" \ + --ostype "$OVF_OS_TYPE" \ + --scsihw lsi \ + --net0 "e1000,bridge=$BRIDGE" \ + &>/dev/null; then + msg_error "$(translate "Failed to create VM") $NEW_VMID" + return 1 + fi + msg_ok "$(translate "VM shell created:") $NEW_VMID" + + # Add extra NICs (net0 already created above) + local n + for n in $(seq 1 $((OVF_NET_COUNT - 1))); do + qm set "$NEW_VMID" "--net${n}" "e1000,bridge=$BRIDGE" &>/dev/null || true + done + [[ "$OVF_NET_COUNT" -gt 1 ]] && msg_ok "$(translate "Network interfaces added:") $OVF_NET_COUNT" + + # 2. Import disks + local disk_count="${#OVF_DISK_FILES[@]}" + local i disk_file src_path + local TEMP_STATUS_FILE TEMP_DISK_FILE + + for i in "${!OVF_DISK_FILES[@]}"; do + disk_file="${OVF_DISK_FILES[$i]}" + src_path="$OVF_DIR/$disk_file" + + if [[ ! -f "$src_path" ]]; then + msg_error "$(translate "Disk file not found:") $src_path" + return 1 + fi + + echo "" + msg_info "$(translate "Importing disk") $((i + 1))/$disk_count: $disk_file" + msg_info2 "$(translate "Source:") $src_path" + + TEMP_STATUS_FILE=$(mktemp) + TEMP_DISK_FILE=$(mktemp) + + ( + qm importdisk "$NEW_VMID" "$src_path" "$STORAGE" 2>&1 + echo $? > "$TEMP_STATUS_FILE" + ) | while IFS= read -r line; do + if [[ "$line" =~ transferred ]]; then + local pct + pct=$(echo "$line" | grep -oP "\d+\.\d+(?=%)") + [[ -n "$pct" ]] && echo -ne "\r${TAB}${BL}- $(translate "Importing:") $disk_file -${CL} ${pct}%" + elif [[ "$line" =~ successfully\ imported\ disk ]]; then + echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE" + fi + done + echo -ne "\n" + + local import_status + import_status=$(cat "$TEMP_STATUS_FILE" 2>/dev/null) + rm -f "$TEMP_STATUS_FILE" + [[ -z "$import_status" ]] && import_status=1 + + if [[ "$import_status" -ne 0 ]]; then + msg_error "$(translate "Import failed for:") $disk_file" + rm -f "$TEMP_DISK_FILE" + return 1 + fi + + # Locate the unused disk entry in VM config + local unused_id unused_disk + unused_id=$(qm config "$NEW_VMID" | grep -E '^unused[0-9]+:' | tail -1 | cut -d: -f1) + unused_disk=$(qm config "$NEW_VMID" | grep -E '^unused[0-9]+:' | tail -1 | cut -d: -f2- | xargs) + rm -f "$TEMP_DISK_FILE" + + if [[ -z "$unused_disk" ]]; then + msg_error "$(translate "Could not locate imported disk in VM config.")" + return 1 + fi + + # Attach to scsi slot i + if ! qm set "$NEW_VMID" "--scsi${i}" "$unused_disk" &>/dev/null; then + msg_error "$(translate "Failed to attach disk as scsi$i.")" + return 1 + fi + + # Remove the unused marker + [[ -n "$unused_id" ]] && qm set "$NEW_VMID" --delete "$unused_id" &>/dev/null || true + + msg_ok "$(translate "Disk attached as:") scsi${i} (${disk_file})" + done + + # 3. Set boot disk + echo "" + msg_info "$(translate "Configuring boot order...")" + if qm set "$NEW_VMID" --boot c --bootdisk "scsi0" &>/dev/null; then + msg_ok "$(translate "Boot disk:") scsi0" + fi + + return 0 +} + + +# ------------------------------------------------------- +# PRINT FINAL RESULT +# ------------------------------------------------------- + +print_import_result() { + local disk_count="${#OVF_DISK_FILES[@]}" + + echo "" + msg_title "$(translate "Import Summary")" + + msg_ok "$(translate "VM imported successfully")" + msg_ok "$(translate "VM ID:") $NEW_VMID $(translate "Name:") $NEW_VM_NAME" + msg_ok "$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "Disks:") $disk_count" + msg_ok "$(translate "Storage:") $STORAGE $(translate "Bridge:") $BRIDGE $(translate "NICs:") $OVF_NET_COUNT" + echo "" + + msg_ok "$(translate "To start the VM:") qm start $NEW_VMID" + echo "" + + msg_title "$(translate "Manual steps recommended after import")" + msg_info2 "$(translate "Network :") $(translate "Verify bridge assignment and NIC model — change to VirtIO if guest drivers are available")" + msg_info2 "$(translate "Firmware :") $(translate "Check BIOS/UEFI in Hardware > BIOS — must match what the original VM used")" + msg_info2 "$(translate "Drivers :") $(translate "If imported from ESXi: install qemu-guest-agent inside the guest OS")" + msg_info2 "$(translate "Display :") $(translate "Set Display > Graphic card (VGA, SPICE or VirtIO) to match the guest")" + msg_info2 "$(translate "OS type :") $(translate "Verify Options > OS Type — currently set to:") $OVF_OS_TYPE" + echo "" + msg_info2 "$(translate "Not imported:") $(translate "PCI passthrough, TPM state, cloud-init, snapshots, Proxmox-specific hooks")" + echo "" +} + + +# ------------------------------------------------------- +# MAIN +# ------------------------------------------------------- + +main() { + if ! command -v pveversion >/dev/null 2>&1; then + dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \ + --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 + exit 1 + fi + + for cmd in dialog qm pvesm qemu-img tar; do + if ! command -v "$cmd" >/dev/null 2>&1; then + dialog --backtitle "$BACKTITLE" --title "$(translate "Missing dependency")" \ + --msgbox "$(translate "Required command not found:") $cmd" 8 60 + exit 1 + fi + done + + # Step 1: pick the OVA/OVF file (dialog) + select_source_file || exit 0 + + # Step 2: extract + parse (terminal output) + show_proxmenux_logo + msg_title "$(translate "Import VM from OVA or OVF")" + + msg_ok "$(translate "Source:") $SOURCE_FILE" + echo "" + + prepare_ovf || { + echo "" + msg_success "$(translate "Press Enter to return...")" + read -r + exit 1 + } + + msg_info "$(translate "Parsing OVF descriptor...")" + if ! parse_ovf "$OVF_FILE"; then + msg_error "$(translate "Could not parse OVF file, or no disk image references found.")" + echo "" + msg_success "$(translate "Press Enter to return...")" + read -r + exit 1 + fi + msg_ok "$(translate "OVF parsed:")" + msg_info2 " $(translate "Name:") $OVF_VM_NAME $(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB" + msg_info2 " $(translate "Disks:") ${#OVF_DISK_FILES[@]} $(translate "NICs:") $OVF_NET_COUNT $(translate "OS hint:") $OVF_OS_TYPE" + + # Clean screen before returning to dialogs + show_proxmenux_logo + + # Step 3: configure the new VM (dialogs) + select_import_options || exit 0 + + # Step 4: confirm (dialog) + confirm_import || exit 0 + + # Step 5: do the import (terminal output only) + if run_import; then + print_import_result + msg_success "$(translate "Press Enter to return to menu...")" + read -r + exit 0 + else + echo "" + msg_error "$(translate "Import failed. VM $NEW_VMID may be in partial state.")" + msg_info2 "$(translate "To remove partial VM:") qm destroy $NEW_VMID --destroy-unreferenced-disks 1" + echo "" + msg_success "$(translate "Press Enter to return...")" + read -r + exit 1 + fi +} + +main "$@" diff --git a/scripts/vm/disk_selector.sh b/scripts/vm/disk_selector.sh index 3e072be9..6816ea2f 100644 --- a/scripts/vm/disk_selector.sh +++ b/scripts/vm/disk_selector.sh @@ -80,21 +80,21 @@ function select_disk_type() { while true; do local choice choice=$(whiptail --backtitle "ProxMenux" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \ - "1" "$(translate "Add virtual disk")" \ - "2" "$(translate "Add import disk")" \ - "3" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ + "a" "$(translate "Add virtual disk")" \ + "b" "$(translate "Add import disk")" \ + "c" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ "r" "$(translate "Reset current storage selection")" \ - "d" "$(translate "[ Finish and continue ]")" \ + "d" "$(translate "──── [ Finish and continue ] ────")" \ --ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || return 1 case "$choice" in - 1) + a) select_virtual_disk ;; - 2) + b) select_import_disk ;; - 3) + c) select_controller_nvme ;; r) diff --git a/scripts/vm/select_linux_iso.sh b/scripts/vm/select_linux_iso.sh index 850ae4e7..fee21c9d 100644 --- a/scripts/vm/select_linux_iso.sh +++ b/scripts/vm/select_linux_iso.sh @@ -58,7 +58,7 @@ function select_linux_iso() { --backtitle "ProxMenux" \ --title "Opciones de instalación de Linux" \ --menu "\nSeleccione el tipo de instalación de Linux:\n\n$header" \ - 18 72 10 \ + 20 70 10 \ 1 "$(printf '%-35s│ %s' 'Instalar con metodo tradicional' 'Desde ISO oficial')" \ 2 "$(printf '%-35s│ %s' 'Instalar con script Cloud-Init' 'Helper Scripts')" \ 3 "$(printf '%-35s│ %s' 'Instalar con ISO personal' 'Almacenamiento local')" \ @@ -140,7 +140,7 @@ function select_linux_iso_official() { CHOICE=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Official Linux Distributions")" \ - --menu "$(translate "Select the Linux distribution to install:")\n\n$HEADER_TEXT" 20 80 12 \ + --menu "$(translate "Select the Linux distribution to install:")\n\n$HEADER_TEXT" 20 70 12 \ "${MENU_OPTIONS[@]}" \ 3>&1 1>&2 2>&3) @@ -269,7 +269,7 @@ local OTHER_OPTIONS=( local choice choice=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Other Prebuilt Linux VMs")" \ - --menu "\n$(translate "Select one of the ready-to-run Linux VMs:")" 18 70 10 \ + --menu "\n$(translate "Select one of the ready-to-run Linux VMs:")" 20 70 10 \ "${OTHER_OPTIONS[@]}" 3>&1 1>&2 2>&3) if [[ $? -ne 0 || "$choice" == "4" ]]; then diff --git a/scripts/vm/select_windows_iso.sh b/scripts/vm/select_windows_iso.sh index 2e872827..719378ba 100644 --- a/scripts/vm/select_windows_iso.sh +++ b/scripts/vm/select_windows_iso.sh @@ -51,7 +51,7 @@ function select_windows_iso() { --backtitle "ProxMenux" \ --title "Opciones de instalación de Windows" \ --menu "\nSeleccione el tipo de instalación de Windows:\n\n$header" \ - 18 70 10 \ + 20 70 10 \ 1 "$(printf '%-34s│ %s' 'Instalar con ISO UUP Dump' 'UUP Dump ISO creator')" \ 2 "$(printf '%-34s│ %s' 'Instalar con ISO personal' 'Almacenamiento local')" \ 3 "Volver al menú principal" \ diff --git a/scripts/vm/synology.sh b/scripts/vm/synology.sh index d40ce2e4..19984774 100644 --- a/scripts/vm/synology.sh +++ b/scripts/vm/synology.sh @@ -51,6 +51,11 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" fi +if [[ -f "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh" +fi if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then @@ -475,24 +480,24 @@ function select_disk_type() { while true; do local choice choice=$(whiptail --backtitle "ProxMenuX" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \ - "1" "$(translate "Add virtual disk")" \ - "2" "$(translate "Add import disk")" \ - "3" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ + "a" "$(translate "Add virtual disk")" \ + "b" "$(translate "Add import disk")" \ + "c" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ "r" "$(translate "Reset current storage selection")" \ - "d" "$(translate "[ Finish and continue ]")" \ + "d" "$(translate "──── [ Finish and continue ] ────")" \ --ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || { msg_warn "$(translate "Storage plan selection cancelled.")" return 1 } case "$choice" in - 1) + a) select_virtual_disk ;; - 2) + b) select_import_disk ;; - 3) + c) select_controller_nvme ;; r) @@ -575,50 +580,6 @@ function select_virtual_disk() { VIRTUAL_DISKS+=("${STORAGE}:${DISK_SIZE}") } -function select_import_disk() { - msg_info "$(translate "Detecting available disks...")" - _refresh_host_storage_cache - - local FREE_DISKS=() - local DISK INFO MODEL SIZE LABEL DESCRIPTION - while read -r DISK; do - [[ "$DISK" =~ /dev/zd ]] && continue - _disk_is_host_system_used "$DISK" && continue - - INFO=($(lsblk -dn -o MODEL,SIZE "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" - LABEL="" - if _disk_used_in_guest_configs "$DISK"; then - LABEL+=" [⚠ $(translate "In use by VM/LXC config")]" - fi - DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL") - if _array_contains "$DISK" "${IMPORT_DISKS[@]}"; then - FREE_DISKS+=("$DISK" "$DESCRIPTION" "ON") - else - FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF") - fi - done < <(lsblk -dn -e 7,11 -o PATH) - - stop_spinner - if [[ ${#FREE_DISKS[@]} -eq 0 ]]; then - whiptail --title "Error" --msgbox "$(translate "No importable disks available. System disks and protected disks are hidden.")" 9 70 - return 1 - fi - - local selected - selected=$(whiptail --title "Select Import Disks" --checklist \ - "$(translate "Select the disks you want to import (use spacebar to toggle):")" 20 78 10 \ - "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) || return 1 - - IMPORT_DISKS=() - local item - for item in $(echo "$selected" | tr -d '"'); do - IMPORT_DISKS+=("$item") - done - export IMPORT_DISKS -} - function select_controller_nvme() { local VM_STORAGE_IOMMU_REBOOT_POLICY="defer" @@ -747,7 +708,7 @@ function prompt_controller_conflict_policy() { shift local -a source_vms=("$@") local msg vmid vm_name st ob - msg="$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" + msg="\n$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" for vmid in "${source_vms[@]}"; do vm_name=$(_vm_name_by_id "$vmid") st="stopped"; _vm_status_is_running "$vmid" && st="running" @@ -757,7 +718,7 @@ function prompt_controller_conflict_policy() { msg+="\n$(translate "Choose action for this controller/NVMe:")" local choice - choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ + choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 20 80 10 \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \ "3" "$(translate "Skip this device")" \ @@ -1486,18 +1447,26 @@ if [[ "$GPU_WIZARD_APPLIED" == "yes" ]]; then echo -e "${TAB}• $(translate "Then change the VM display to none (vga: none) when the system is stable.")" fi local HOST_REBOOT_REQUIRED="no" +local REBOOT_REASONS="" if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then HOST_REBOOT_REQUIRED="yes" - msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" + msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")" + REBOOT_REASONS+="$(translate "IOMMU has been enabled on this system.")\n" fi if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then HOST_REBOOT_REQUIRED="yes" + REBOOT_REASONS+="$(translate "GPU passthrough changes require a host reboot.")\n" fi if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then - if whiptail --title "$(translate "Reboot Recommended")" --yesno \ -"$(translate "A host reboot is required to apply passthrough changes before starting the VM.")\n\n$(translate "Do you want to reboot now?")" 11 78; then + echo "" + if whiptail --title "$(translate "Reboot Required")" --yesno \ +"\n${REBOOT_REASONS}\n$(translate "A host reboot is required before starting the VM. Reboot now?")" 13 78; then msg_warn "$(translate "Rebooting the system...")" reboot + else + echo "" + msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")" + msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")" fi fi echo -e diff --git a/scripts/vm/vm_creator.sh b/scripts/vm/vm_creator.sh index aaecfd58..da035e65 100644 --- a/scripts/vm/vm_creator.sh +++ b/scripts/vm/vm_creator.sh @@ -118,7 +118,7 @@ function prompt_controller_conflict_policy() { shift local -a source_vms=("$@") local msg vmid vm_name st ob - msg="$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" + msg="\n$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" for vmid in "${source_vms[@]}"; do vm_name=$(_vm_name_by_id "$vmid") st="stopped"; _vm_status_is_running "$vmid" && st="running" @@ -128,7 +128,7 @@ function prompt_controller_conflict_policy() { msg+="\n$(translate "Choose action for this controller/NVMe:")" local choice - choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ + choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 20 80 10 \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \ "3" "$(translate "Skip this device")" \ @@ -554,6 +554,7 @@ fi if qm set "$VMID" --hostpci${hostpci_idx} "${pci},pcie=1" >/dev/null 2>&1; then msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx} → ${pci})" DISK_INFO+="

Controller/NVMe: ${pci}

" + BOOT_ORDER="${BOOT_ORDER:+$BOOT_ORDER;}hostpci${hostpci_idx}" hostpci_idx=$((hostpci_idx + 1)) else msg_error "$(translate "Failed to assign Controller/NVMe") (${pci})" @@ -769,18 +770,26 @@ if [[ "${WIZARD_ADD_GPU:-no}" == "yes" ]]; then echo -e fi local HOST_REBOOT_REQUIRED="no" + local REBOOT_REASONS="" if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then HOST_REBOOT_REQUIRED="yes" - msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" + msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")" + REBOOT_REASONS+="$(translate "IOMMU has been enabled on this system.")\n" fi if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then HOST_REBOOT_REQUIRED="yes" + REBOOT_REASONS+="$(translate "GPU passthrough changes require a host reboot.")\n" fi if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then - if whiptail --title "$(translate "Reboot Recommended")" --yesno \ -"$(translate "A host reboot is required to apply passthrough changes before starting the VM.")\n\n$(translate "Do you want to reboot now?")" 11 78; then + echo "" + if whiptail --title "$(translate "Reboot Required")" --yesno \ +"\n${REBOOT_REASONS}\n$(translate "A host reboot is required before starting the VM. Reboot now?")" 13 78; then msg_warn "$(translate "Rebooting the system...")" reboot + else + echo "" + msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")" + msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")" fi fi msg_success "$(translate "Press Enter to return to the main menu...")" @@ -807,10 +816,6 @@ elif [[ "$OS_TYPE" == "3" ]]; then echo -e fi -if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then - msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" -fi - msg_success "$(translate "Press Enter to return to the main menu...")" read -r bash "$LOCAL_SCRIPTS/menus/create_vm_menu.sh" diff --git a/scripts/vm/zimaos.sh b/scripts/vm/zimaos.sh index 0ee8c12b..7ec02c54 100644 --- a/scripts/vm/zimaos.sh +++ b/scripts/vm/zimaos.sh @@ -44,6 +44,11 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/vm_storage_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" fi +if [[ -f "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" ]]; then + source "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" +elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh" ]]; then + source "$LOCAL_SCRIPTS_DEFAULT/vm/disk_selector.sh" +fi if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then @@ -490,24 +495,24 @@ function select_disk_type() { while true; do local choice choice=$(whiptail --backtitle "ProxMenuX" --title "STORAGE PLAN" --menu "$(_build_storage_plan_summary)" 18 78 5 \ - "1" "$(translate "Add virtual disk")" \ - "2" "$(translate "Add import disk")" \ - "3" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ + "a" "$(translate "Add virtual disk")" \ + "b" "$(translate "Add import disk")" \ + "c" "$(translate "Add Controller or NVMe (PCI passthrough)")" \ "r" "$(translate "Reset current storage selection")" \ - "d" "$(translate "[ Finish and continue ]")" \ + "d" "$(translate "──── [ Finish and continue ] ────")" \ --ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3) || { msg_warn "$(translate "Storage plan selection cancelled.")" return 1 } case "$choice" in - 1) + a) select_virtual_disk ;; - 2) + b) select_import_disk ;; - 3) + c) select_controller_nvme ;; r) @@ -590,49 +595,6 @@ function select_virtual_disk() { } -function select_import_disk() { - msg_info "$(translate "Detecting available disks...")" - _refresh_host_storage_cache - - local FREE_DISKS=() - local DISK INFO MODEL SIZE LABEL DESCRIPTION - while read -r DISK; do - [[ "$DISK" =~ /dev/zd ]] && continue - _disk_is_host_system_used "$DISK" && continue - - INFO=($(lsblk -dn -o MODEL,SIZE "$DISK")) - MODEL="${INFO[@]::${#INFO[@]}-1}" - SIZE="${INFO[-1]}" - LABEL="" - if _disk_used_in_guest_configs "$DISK"; then - LABEL+=" [⚠ $(translate "In use by VM/LXC config")]" - fi - DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL") - if _array_contains "$DISK" "${IMPORT_DISKS[@]}"; then - FREE_DISKS+=("$DISK" "$DESCRIPTION" "ON") - else - FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF") - fi - done < <(lsblk -dn -e 7,11 -o PATH) - stop_spinner - if [[ ${#FREE_DISKS[@]} -eq 0 ]]; then - whiptail --title "Error" --msgbox "$(translate "No importable disks available. System disks and protected disks are hidden.")" 9 70 - return 1 - fi - - local selected - selected=$(whiptail --title "Select Import Disks" --checklist \ - "$(translate "Select the disks you want to import (use spacebar to toggle):")" 20 78 10 \ - "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3) || return 1 - - IMPORT_DISKS=() - local item - for item in $(echo "$selected" | tr -d '"'); do - IMPORT_DISKS+=("$item") - done - export IMPORT_DISKS -} - function select_controller_nvme() { local VM_STORAGE_IOMMU_REBOOT_POLICY="defer" @@ -761,7 +723,7 @@ function prompt_controller_conflict_policy() { shift local -a source_vms=("$@") local msg vmid vm_name st ob - msg="$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" + msg="\n$(translate "Selected controller/NVMe is already assigned to other VM(s):")\n\n" for vmid in "${source_vms[@]}"; do vm_name=$(_vm_name_by_id "$vmid") st="stopped"; _vm_status_is_running "$vmid" && st="running" @@ -771,7 +733,7 @@ function prompt_controller_conflict_policy() { msg+="\n$(translate "Choose action for this controller/NVMe:")" local choice - choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 22 96 10 \ + choice=$(whiptail --title "$(translate "Controller/NVMe Conflict Policy")" --menu "$msg" 20 80 10 \ "1" "$(translate "Keep in source VM(s) + disable onboot + add to target VM")" \ "2" "$(translate "Move to target VM (remove from source VM config)")" \ "3" "$(translate "Skip this device")" \ @@ -1398,6 +1360,7 @@ function create_vm() { msg_ok "Configured controller/NVMe as hostpci${HOSTPCI_INDEX}: ${PCI_DEV}" DISK_INFO="${DISK_INFO}

Controller/NVMe: ${PCI_DEV}

" CONSOLE_DISK_INFO="${CONSOLE_DISK_INFO}- Controller/NVMe: ${PCI_DEV} (hostpci${HOSTPCI_INDEX})\n" + BOOT_ORDER_LIST+=("hostpci${HOSTPCI_INDEX}") HOSTPCI_INDEX=$((HOSTPCI_INDEX + 1)) else msg_error "Failed to configure controller/NVMe: ${PCI_DEV}" @@ -1511,18 +1474,26 @@ else echo -e "${TAB}• $(translate "Then change the VM display to none (vga: none) when the system is stable.")" fi local HOST_REBOOT_REQUIRED="no" + local REBOOT_REASONS="" if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then HOST_REBOOT_REQUIRED="yes" - msg_warn "$(translate "IOMMU was enabled during this wizard. Reboot the host to apply it.")" + msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")" + REBOOT_REASONS+="$(translate "IOMMU has been enabled on this system.")\n" fi if [[ "$GPU_WIZARD_REBOOT_REQUIRED" == "yes" ]]; then HOST_REBOOT_REQUIRED="yes" + REBOOT_REASONS+="$(translate "GPU passthrough changes require a host reboot.")\n" fi if [[ "$HOST_REBOOT_REQUIRED" == "yes" ]]; then - if whiptail --title "$(translate "Reboot Recommended")" --yesno \ -"$(translate "A host reboot is required to apply passthrough changes before starting the VM.")\n\n$(translate "Do you want to reboot now?")" 11 78; then + echo "" + if whiptail --title "$(translate "Reboot Required")" --yesno \ +"\n${REBOOT_REASONS}\n$(translate "A host reboot is required before starting the VM. Reboot now?")" 13 78; then msg_warn "$(translate "Rebooting the system...")" reboot + else + echo "" + msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")" + msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")" fi fi echo -e