diff --git a/src/app/_components/LXCSettingsModal.tsx b/src/app/_components/LXCSettingsModal.tsx index 8999ba3..5d1b6f4 100644 --- a/src/app/_components/LXCSettingsModal.tsx +++ b/src/app/_components/LXCSettingsModal.tsx @@ -43,10 +43,13 @@ interface LXCSettingsModalProps { export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSettingsModalProps) { const [activeTab, setActiveTab] = useState('common'); const [showConfirmation, setShowConfirmation] = useState(false); + const [showResultModal, setShowResultModal] = useState(false); + const [resultType, setResultType] = useState<'success' | 'error' | null>(null); + const [resultMessage, setResultMessage] = useState(null); const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); const [hasChanges, setHasChanges] = useState(false); const [forceSync] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [formData, setFormData] = useState({ arch: '', @@ -76,27 +79,41 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting }); // tRPC hooks - const { data: configData, isLoading } = api.installedScripts.getLXCConfig.useQuery( + const { data: configData, isLoading, refetch } = api.installedScripts.getLXCConfig.useQuery( { scriptId: script?.id ?? 0, forceSync }, { enabled: !!script && isOpen } ); const saveMutation = api.installedScripts.saveLXCConfig.useMutation({ - onSuccess: () => { - setSuccessMessage('LXC configuration saved successfully'); - setHasChanges(false); + onSuccess: (data) => { + console.log('Save mutation success data:', data); + setIsSaving(false); setShowConfirmation(false); - onSave(); + + if (data.success) { + setResultType('success'); + setResultMessage(data.message ?? 'LXC configuration saved successfully'); + setHasChanges(false); + } else { + console.log('Backend returned error:', data.error); + setResultType('error'); + setResultMessage(data.error ?? 'Failed to save configuration'); + } + setShowResultModal(true); }, onError: (err) => { - setError(`Failed to save configuration: ${err.message}`); + console.log('Save mutation error:', err); + setIsSaving(false); + setShowConfirmation(false); + setResultType('error'); + setResultMessage(`Failed to save configuration: ${err.message}`); + setShowResultModal(true); } }); const syncMutation = api.installedScripts.syncLXCConfig.useMutation({ onSuccess: (result) => { populateFormData(result); - setSuccessMessage('Configuration synced from server successfully'); setHasChanges(false); }, onError: (err) => { @@ -158,13 +175,61 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting syncMutation.mutate({ scriptId: script.id }); }; + const validateForm = () => { + // Check required fields + if (!formData.arch?.trim()) { + setError('Architecture is required'); + return false; + } + if (!formData.cores || formData.cores < 1) { + setError('Cores must be at least 1'); + return false; + } + if (!formData.memory || formData.memory < 128) { + setError('Memory must be at least 128 MB'); + return false; + } + if (!formData.hostname?.trim()) { + setError('Hostname is required'); + return false; + } + if (!formData.ostype?.trim()) { + setError('OS Type is required'); + return false; + } + if (!formData.rootfs_storage?.trim()) { + setError('Root filesystem storage is required'); + return false; + } + + // Check if trying to decrease disk size + const currentSize = configData?.config?.rootfs_size ?? '0G'; + const newSize = formData.rootfs_size ?? '0G'; + const currentSizeGB = parseFloat(String(currentSize)); + const newSizeGB = parseFloat(String(newSize)); + + if (newSizeGB < currentSizeGB) { + setError('Disk size cannot be decreased. Only increases are allowed for safety.'); + return false; + } + + return true; + }; + const handleSave = () => { - setShowConfirmation(true); + setError(null); + + // Validate form - only show confirmation modal if no errors + if (validateForm()) { + setShowConfirmation(true); + } }; const handleConfirmSave = () => { if (!script) return; setError(null); + setIsSaving(true); + setShowConfirmation(false); saveMutation.mutate({ scriptId: script.id, @@ -179,6 +244,14 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting }); }; + const handleResultModalClose = () => { + setShowResultModal(false); + setResultType(null); + setResultMessage(null); + // Refresh the data to show updated values + void refetch(); + }; + if (!isOpen || !script) return null; return ( @@ -229,23 +302,6 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting )} - {/* Success Message */} - {successMessage && ( -
-
- -
-

{successMessage}

-
- -
-
- )} {/* Error Message */} {error && ( @@ -485,13 +541,19 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting />
- + handleInputChange('rootfs_size', e.target.value)} placeholder="4G" /> +

+ Disk size can only be increased for safety. Format: 4G, 8G, 16G, etc. +

@@ -590,10 +652,10 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting @@ -608,17 +670,55 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting }} onConfirm={handleConfirmSave} title="Confirm LXC Configuration Changes" - message="Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding. The container may need to be restarted for changes to take effect." + message={`Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding.${ + formData.rootfs_size && configData?.config?.rootfs_size && + parseFloat(String(formData.rootfs_size)) > parseFloat(String(configData.config.rootfs_size ?? '0')) + ? `\n\n⚠️ DISK RESIZE DETECTED: The disk size will be increased from ${configData.config.rootfs_size} to ${formData.rootfs_size}. This operation will automatically resize the underlying storage and filesystem.` + : '' + }\n\nThe container may need to be restarted for changes to take effect.`} variant="danger" confirmText={script.container_id ?? ''} - confirmButtonText="Save Configuration" + confirmButtonText={formData.rootfs_size && configData?.config?.rootfs_size && + parseFloat(String(formData.rootfs_size)) > parseFloat(String(configData.config.rootfs_size ?? '0')) + ? "Save & Resize Disk" : "Save Configuration"} /> {/* Loading Modal */} + + {/* Result Modal */} + {showResultModal && resultType && resultMessage && ( +
+
+
+
+ {resultType === 'success' ? ( + + ) : ( + + )} +

+ {resultType === 'success' ? 'Success' : 'Error'} +

+
+

+ {resultMessage} +

+
+ +
+
+
+
+ )} ); } diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 7f047bd..3ec2347 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -142,6 +142,246 @@ function calculateConfigHash(rawConfig: string): string { return createHash('md5').update(rawConfig).digest('hex'); } +// Helper function to parse rootfs_storage and extract storage pool and disk identifier +function parseRootfsStorage(rootfs_storage: string): { storagePool: string; diskId: string } | null { + // Format: "PROX2-STORAGE2:vm-113-disk-0" + const regex = /^([^:]+):(.+)$/; + const match = regex.exec(rootfs_storage); + if (!match?.[1] || !match?.[2]) return null; + + return { + storagePool: match[1], + diskId: match[2] + }; +} + +// Helper function to extract size in GB from size string +function extractSizeInGB(sizeString: string): number { + if (!sizeString) return 0; + + const regex = /^(\d+(?:\.\d+)?)\s*([GMK]?)$/i; + const match = regex.exec(sizeString); + if (!match?.[1]) return 0; + + const value = parseFloat(match[1]); + const unit = (match[2] ?? '').toUpperCase(); + + switch (unit) { + case 'T': return value * 1024; + case 'G': return value; + case 'M': return value / 1024; + case 'K': return value / (1024 * 1024); + case '': return value; // Assume GB if no unit + default: return 0; + } +} + + +// Helper function to resize disk +async function resizeDisk( + server: Server, + containerId: string, + storageInfo: { storagePool: string; diskId: string }, + oldSizeGB: number, + newSizeGB: number +): Promise<{ success: boolean; message: string; error?: string }> { + const { default: SSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshExecutionService = new SSHExecutionService(); + + try { + // First, try using pct resize (works for most storage types) + const pctCommand = `pct resize ${containerId} rootfs ${newSizeGB}G`; + + return new Promise((resolve) => { + let errorOutput = ''; + let dataOutput = ''; + + console.log(`Executing pct resize command: ${pctCommand}`); + + void sshExecutionService.executeCommand( + server, + pctCommand, + (data: string) => { + dataOutput += data; + console.log('pct resize data:', data); + }, + (error: string) => { + errorOutput += error; + console.log('pct resize error:', error); + }, + (exitCode: number) => { + console.log(`pct resize exit code: ${exitCode}`); + console.log(`pct resize error output: "${errorOutput}"`); + console.log(`pct resize data output: "${dataOutput}"`); + + // Check for error messages in both stderr and stdout + const hasError = errorOutput.trim() || dataOutput.toLowerCase().includes('error') || dataOutput.toLowerCase().includes('insufficient'); + + // Check both exit code and error output for failure + if (exitCode === 0 && !hasError) { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using pct resize` + }); + } else { + // If pct resize fails (either non-zero exit code or error output), try LVM-specific commands + const errorMessage = errorOutput.trim() || dataOutput.trim(); + const combinedError = errorMessage ? `pct resize error: ${errorMessage}` : `pct resize failed with exit code ${exitCode}`; + void tryLVMResize(server, containerId, storageInfo, newSizeGB, oldSizeGB, resolve, combinedError); + } + } + ); + }); + } catch (error) { + return { + success: false, + message: 'Resize failed', + error: error instanceof Error ? error.message : 'Unknown error during resize' + }; + } +} + +// Helper function to try LVM-specific resize +async function tryLVMResize( + server: Server, + containerId: string, + storageInfo: { storagePool: string; diskId: string }, + newSizeGB: number, + oldSizeGB: number, + resolve: (result: { success: boolean; message: string; error?: string }) => void, + previousError?: string +) { + const { default: SSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshExecutionService = new SSHExecutionService(); + + // Try LVM resize commands + const lvPath = `/dev/${storageInfo.storagePool}/${storageInfo.diskId}`; + const lvresizeCommand = `lvresize -L ${newSizeGB}G ${lvPath}`; + + void sshExecutionService.executeCommand( + server, + lvresizeCommand, + (_data: string) => { + // Now resize the filesystem + const resize2fsCommand = `resize2fs ${lvPath}`; + + void sshExecutionService.executeCommand( + server, + resize2fsCommand, + (_fsData: string) => { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands` + }); + }, + (fsError: string) => { + // Try xfs_growfs as fallback + const xfsCommand = `xfs_growfs ${lvPath}`; + + void sshExecutionService.executeCommand( + server, + xfsCommand, + (_xfsData: string) => { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands` + }); + }, + (xfsError: string) => { + resolve({ + success: false, + message: 'Filesystem resize failed', + error: `LVM resize succeeded but filesystem resize failed: ${fsError}, XFS fallback also failed: ${xfsError}` + }); + }, + (xfsExitCode: number) => { + if (xfsExitCode === 0) { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands` + }); + } else { + resolve({ + success: false, + message: 'Filesystem resize failed', + error: `LVM resize succeeded but filesystem resize failed: ${fsError}, XFS fallback also failed with exit code ${xfsExitCode}` + }); + } + } + ); + }, + (fsExitCode: number) => { + if (fsExitCode === 0) { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands` + }); + } else { + // Try xfs_growfs as fallback + const xfsCommand = `xfs_growfs ${lvPath}`; + + void sshExecutionService.executeCommand( + server, + xfsCommand, + (_xfsData: string) => { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands` + }); + }, + (xfsError: string) => { + resolve({ + success: false, + message: 'Filesystem resize failed', + error: `LVM resize succeeded but filesystem resize failed with exit code ${fsExitCode}, XFS fallback also failed: ${xfsError}` + }); + }, + (xfsExitCode: number) => { + if (xfsExitCode === 0) { + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands` + }); + } else { + resolve({ + success: false, + message: 'Filesystem resize failed', + error: `LVM resize succeeded but filesystem resize failed with exit code ${fsExitCode}, XFS fallback also failed with exit code ${xfsExitCode}` + }); + } + } + ); + } + } + ); + }, + (error: string) => { + const combinedError = previousError ? `${previousError} LVM error: ${error}` : `LVM resize failed: ${error}`; + resolve({ + success: false, + message: 'Resize failed', + error: `Both pct resize and LVM resize failed. ${combinedError}` + }); + }, + (exitCode: number) => { + if (exitCode === 0) { + // This shouldn't happen as we're in the error callback, but handle it + resolve({ + success: true, + message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands` + }); + } else { + const combinedError = previousError ? `${previousError} LVM command failed with exit code ${exitCode}` : `LVM command failed with exit code ${exitCode}`; + resolve({ + success: false, + message: 'Resize failed', + error: `Both pct resize and LVM resize failed. ${combinedError}` + }); + } + } + ); +} + export const installedScriptsRouter = createTRPCRouter({ // Get all installed scripts @@ -1536,6 +1776,19 @@ export const installedScriptsRouter = createTRPCRouter({ }; } + // Get current config for comparison + const currentConfig = await db.getLXCConfigByScriptId(input.scriptId); + const oldSizeGB = currentConfig ? extractSizeInGB(String(currentConfig.rootfs_size ?? '0G')) : 0; + const newSizeGB = extractSizeInGB(String(input.config.rootfs_size ?? '0G')); + + // Validate size change - only allow increases + if (newSizeGB < oldSizeGB) { + return { + success: false, + error: `Disk size cannot be decreased. Current size: ${oldSizeGB}G, requested size: ${newSizeGB}G. Only increases are allowed for safety.` + }; + } + // Write config file using heredoc for safe escaping const configPath = `/etc/pve/lxc/${script.container_id}.conf`; const writeCommand = `cat > "${configPath}" << 'EOFCONFIG' @@ -1562,6 +1815,104 @@ EOFCONFIG`; ); }); + // Check if disk size increased and needs resizing + let resizeResult: { success: boolean; message: string; error?: string } | null = null; + if (newSizeGB > oldSizeGB) { + // Parse storage information + const storageInfo = parseRootfsStorage(String(input.config.rootfs_storage)); + if (!storageInfo) { + // Rollback config file + const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG' +${reconstructConfig(currentConfig ?? {})} +EOFCONFIG`; + + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + server as Server, + rollbackCommand, + () => resolve(), + (error: string) => reject(new Error(error)), + (exitCode: number) => { + if (exitCode === 0) resolve(); + else reject(new Error(`Rollback failed with exit code ${exitCode}`)); + } + ); + }); + + return { + success: false, + error: 'Invalid rootfs_storage format. Configuration rolled back.' + }; + } + + // Attempt disk resize + try { + console.log(`Attempting to resize disk from ${oldSizeGB}G to ${newSizeGB}G for container ${script.container_id}`); + resizeResult = await resizeDisk(server as Server, script.container_id, storageInfo, oldSizeGB, newSizeGB); + console.log('Resize result:', resizeResult); + + if (!resizeResult.success) { + console.log('Resize failed, attempting rollback...'); + // Rollback config file on resize failure + const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG' +${reconstructConfig(currentConfig ?? {})} +EOFCONFIG`; + + try { + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + server as Server, + rollbackCommand, + () => resolve(), + (error: string) => reject(new Error(error)), + (exitCode: number) => { + if (exitCode === 0) resolve(); + else reject(new Error(`Rollback failed with exit code ${exitCode}`)); + } + ); + }); + console.log('Rollback successful'); + } catch (rollbackError) { + console.error('Rollback failed:', rollbackError); + } + + return { + success: false, + error: `Configuration rolled back. Disk resize failed: ${resizeResult.error}` + }; + } + } catch (error) { + console.error('Resize operation threw error:', error); + // Rollback config file on resize error + const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG' +${reconstructConfig(currentConfig ?? {})} +EOFCONFIG`; + + try { + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + server as Server, + rollbackCommand, + () => resolve(), + (error: string) => reject(new Error(error)), + (exitCode: number) => { + if (exitCode === 0) resolve(); + else reject(new Error(`Rollback failed with exit code ${exitCode}`)); + } + ); + }); + console.log('Rollback successful after error'); + } catch (rollbackError) { + console.error('Rollback failed after error:', rollbackError); + } + + return { + success: false, + error: `Configuration rolled back. Disk resize error: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + // Update database cache const configData = { ...input.config, @@ -1571,9 +1922,14 @@ EOFCONFIG`; await db.updateLXCConfig(input.scriptId, configData); + // Return success message with resize info if applicable + const message = resizeResult + ? `LXC configuration saved successfully. ${resizeResult.message}` + : 'LXC configuration saved successfully'; + return { success: true, - message: 'LXC configuration saved successfully' + message }; } catch (error) { console.error('Error in saveLXCConfig:', error);