<# .SYNOPSIS ROA2WEB Unified Console - Ultrathin Monolith Management .DESCRIPTION Unified deployment and management console for ROA2WEB Ultrathin Monolith. Single Windows service with multiple modules (Reports, Data Entry, Telegram). Features: - Deploy backend/frontend updates - Manage unified service (start/stop/restart) - View logs and service status - Backup before deployment - Module control via .env flags .PARAMETER NonInteractive Run in non-interactive mode with specific action .PARAMETER Action Action to perform: - DeployBackend: Deploy backend files only - DeployFrontend: Deploy frontend files only - DeployAll: Deploy both backend and frontend - StartService: Start ROA2WEB-Backend service - StopService: Stop ROA2WEB-Backend service - RestartService: Restart ROA2WEB-Backend service - Status: Show service status and health - ViewLogs: Display recent log entries .PARAMETER PackagePath Path to deployment package (for Deploy actions) .EXAMPLE .\ROA2WEB-Console.ps1 Launch interactive menu .EXAMPLE .\ROA2WEB-Console.ps1 -NonInteractive -Action DeployAll -PackagePath "C:\Temp\deploy-20250129-120000" Deploy full package non-interactively .EXAMPLE .\ROA2WEB-Console.ps1 -NonInteractive -Action RestartService Restart the unified backend service .NOTES Author: ROA2WEB Team Version: 2.0 (Ultrathin Monolith) Requires: Administrator privileges, PowerShell 5.1+ #> [CmdletBinding()] param( [switch]$NonInteractive, [ValidateSet("DeployBackend", "DeployFrontend", "DeployAll", "StartService", "StopService", "RestartService", "Status", "ViewLogs", "CheckOCR", "InstallOCR")] [string]$Action = "", [string]$PackagePath = "" ) $ErrorActionPreference = "Stop" # Require Administrator if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "[ERROR] This script requires Administrator privileges" -ForegroundColor Red Write-Host "Please run PowerShell as Administrator and try again." -ForegroundColor Yellow exit 1 } # ============================================================================= # CONFIGURATION # ============================================================================= $script:Config = @{ # Service Configuration ServiceName = "ROA2WEB-Backend" ServiceDisplayName = "ROA2WEB Unified Backend Service" ServicePort = 8000 HealthUrl = "http://localhost:8000/health" HealthTimeout = 10 # Installation Paths InstallRoot = "C:\inetpub\wwwroot\roa2web" BackendPath = "C:\inetpub\wwwroot\roa2web\backend" # IMPORTANT: venv is OUTSIDE roa2web to survive deployments! VenvPath = "C:\inetpub\wwwroot\roa2web-venv" FrontendPath = "C:\inetpub\wwwroot\roa2web\frontend" SharedPath = "C:\inetpub\wwwroot\roa2web\shared" ConfigPath = "C:\inetpub\wwwroot\roa2web\config" DataPath = "C:\inetpub\wwwroot\roa2web\data" # Logs LogsPath = "C:\inetpub\wwwroot\roa2web\logs" BackendStdoutLog = "C:\inetpub\wwwroot\roa2web\logs\backend-stdout.log" BackendStderrLog = "C:\inetpub\wwwroot\roa2web\logs\backend-stderr.log" # Backups BackupPath = "C:\inetpub\wwwroot\roa2web\backups" MaxBackups = 5 # IIS Configuration IISSiteName = "Default Web Site" IISAppName = "roa2web" } # ============================================================================= # HELPER FUNCTIONS # ============================================================================= function Write-Step { param([string]$Message) Write-Host "`n[*] $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) Write-Host " [OK] $Message" -ForegroundColor Green } function Write-Error { param([string]$Message) Write-Host " [ERROR] $Message" -ForegroundColor Red } function Write-Warning { param([string]$Message) Write-Host " [WARN] $Message" -ForegroundColor Yellow } function Write-Info { param([string]$Message) Write-Host " [*] $Message" -ForegroundColor Gray } function Get-PythonPaths { <# .SYNOPSIS Returns Python and pip paths, preferring venv if available #> $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe" $venvPip = Join-Path $Config.VenvPath "Scripts\pip.exe" if (Test-Path $venvPython) { return @{ Python = $venvPython Pip = $venvPip IsVenv = $true } } else { # Fallback to global Python $globalPython = (Get-Command python -ErrorAction SilentlyContinue).Source $globalPip = (Get-Command pip -ErrorAction SilentlyContinue).Source return @{ Python = $globalPython Pip = $globalPip IsVenv = $false } } } function Initialize-Venv { <# .SYNOPSIS Creates virtual environment at external location if it doesn't exist. External location (C:\inetpub\wwwroot\roa2web-venv) survives deployments. #> $venvPath = $Config.VenvPath $venvPython = Join-Path $venvPath "Scripts\python.exe" $venvPip = Join-Path $venvPath "Scripts\pip.exe" # If venv exists and is valid (pip works), we're good if (Test-Path $venvPython) { # Verify pip is functional (not broken by move) try { $pipTest = & $venvPip --version 2>&1 if ($LASTEXITCODE -eq 0) { Write-Success "Virtual environment already exists at $venvPath" return $true } else { Write-Warning "Venv exists but pip is broken, recreating..." Remove-Item -Path $venvPath -Recurse -Force -ErrorAction SilentlyContinue } } catch { Write-Warning "Venv exists but pip test failed, recreating..." Remove-Item -Path $venvPath -Recurse -Force -ErrorAction SilentlyContinue } } Write-Step "Creating virtual environment at external location..." Write-Info "Path: $venvPath (survives deployments)" try { $globalPython = (Get-Command python -ErrorAction Stop).Source & $globalPython -m venv $venvPath if (Test-Path $venvPython) { Write-Success "Virtual environment created" # Upgrade pip Write-Step "Upgrading pip in venv..." & $venvPython -m pip install --upgrade pip 2>&1 | Out-Null Write-Success "Pip upgraded" return $true } else { Write-Error "Failed to create virtual environment" return $false } } catch { Write-Error "Failed to create venv: $_" return $false } } function Update-ServiceToUseVenv { <# .SYNOPSIS Updates NSSM service to use wrapper script for SSH tunnel auto-start .DESCRIPTION Configures the service to use start-backend-service.ps1 wrapper which: 1. Starts SSH tunnels before backend 2. Waits for tunnel ports to be accessible 3. Starts uvicorn with correct settings This ensures SSH tunnels are always running when the backend starts. #> $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe" if (-not (Test-Path $venvPython)) { Write-Warning "Venv Python not found: $venvPython" return $false } # Check if nssm is available $nssmPath = Get-Command nssm -ErrorAction SilentlyContinue if (-not $nssmPath) { Write-Warning "NSSM not found in PATH" return $false } # Find wrapper script $wrapperScript = Join-Path $Config.InstallRoot "scripts\start-backend-service.ps1" if (-not (Test-Path $wrapperScript)) { # Fallback: try deployment location $wrapperScript = Join-Path $PSScriptRoot "start-backend-service.ps1" } $useWrapper = Test-Path $wrapperScript try { # Get current application $currentApp = & nssm get $Config.ServiceName Application 2>&1 if ($useWrapper) { # Check if already using wrapper if ($currentApp -like "*powershell*") { $currentArgs = & nssm get $Config.ServiceName AppParameters 2>&1 if ($currentArgs -like "*start-backend-service.ps1*") { Write-Success "Service already configured to use wrapper script" return $true } } Write-Step "Updating service to use wrapper script (SSH tunnel auto-start)..." # Stop service first Stop-ROAService | Out-Null # Update service to use PowerShell wrapper & nssm set $Config.ServiceName Application "powershell.exe" & nssm set $Config.ServiceName AppParameters "-ExecutionPolicy Bypass -File `"$wrapperScript`"" Write-Success "Service updated to use wrapper: $wrapperScript" Write-Info "SSH tunnels will auto-start when service starts" } else { # Fallback: use venv Python directly (old behavior) if ($currentApp -eq $venvPython) { Write-Success "Service already configured to use venv Python" return $true } Write-Step "Updating service to use venv Python (no wrapper available)..." # Stop service first Stop-ROAService | Out-Null # Update service application & nssm set $Config.ServiceName Application $venvPython & nssm set $Config.ServiceName AppParameters "-m uvicorn main:app --host 127.0.0.1 --port $($Config.ServicePort) --workers 1" Write-Success "Service updated to use: $venvPython" Write-Warning "Wrapper script not found - SSH tunnels must be started manually" } return $true } catch { Write-Error "Failed to update service: $_" return $false } } function Wait-ForKeyPress { Write-Host "`nPress any key to continue..." -ForegroundColor Gray $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } # ============================================================================= # SERVICE MANAGEMENT # ============================================================================= function Get-ServiceSafe { param([string]$ServiceName) try { return Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch { return $null } } function Test-ServiceInstalled { $service = Get-ServiceSafe -ServiceName $Config.ServiceName return ($null -ne $service) } function Start-ROAService { Write-Step "Starting $($Config.ServiceDisplayName)..." $service = Get-ServiceSafe -ServiceName $Config.ServiceName if (-not $service) { Write-Error "Service not found: $($Config.ServiceName)" Write-Info "Run Install-ROA2WEB.ps1 first to install the service" return $false } if ($service.Status -eq 'Running') { Write-Success "Service is already running" return $true } try { Start-Service -Name $Config.ServiceName Start-Sleep -Seconds 3 # Wait for service to start (max 30 seconds) $maxWait = 30 $waited = 0 while ($waited -lt $maxWait) { $service = Get-Service -Name $Config.ServiceName if ($service.Status -eq 'Running') { Write-Success "Service started successfully" # Wait a bit more for backend to initialize Write-Info "Waiting for backend initialization..." Start-Sleep -Seconds 5 # Test health endpoint Test-ServiceHealth | Out-Null return $true } Start-Sleep -Seconds 2 $waited += 2 } Write-Warning "Service started but status unclear" return $false } catch { Write-Error "Failed to start service: $_" return $false } } function Stop-ROAService { Write-Step "Stopping $($Config.ServiceDisplayName)..." $service = Get-ServiceSafe -ServiceName $Config.ServiceName if (-not $service) { Write-Warning "Service not found: $($Config.ServiceName)" return $true } if ($service.Status -eq 'Stopped') { Write-Success "Service is already stopped" return $true } try { Stop-Service -Name $Config.ServiceName -Force # Wait for service to stop (max 30 seconds) $maxWait = 30 $waited = 0 while ($waited -lt $maxWait) { $service = Get-Service -Name $Config.ServiceName if ($service.Status -eq 'Stopped') { Write-Success "Service stopped successfully" return $true } Start-Sleep -Seconds 2 $waited += 2 } Write-Warning "Service stop timeout - may still be running" return $false } catch { Write-Error "Failed to stop service: $_" return $false } } function Restart-ROAService { Write-Step "Restarting $($Config.ServiceDisplayName)..." if (Stop-ROAService) { Start-Sleep -Seconds 2 return Start-ROAService } return $false } function Test-ServiceHealth { Write-Step "Checking service health..." $service = Get-ServiceSafe -ServiceName $Config.ServiceName if (-not $service) { Write-Warning "Service not installed" return $false } Write-Info "Service Status: $($service.Status)" if ($service.Status -ne 'Running') { Write-Warning "Service is not running" return $false } # Test health endpoint try { Write-Info "Testing health endpoint: $($Config.HealthUrl)" $response = Invoke-WebRequest -Uri $Config.HealthUrl -TimeoutSec $Config.HealthTimeout -UseBasicParsing if ($response.StatusCode -eq 200) { Write-Success "Health check PASSED (HTTP 200)" # Parse response for module status try { $health = $response.Content | ConvertFrom-Json Write-Info "Modules Status:" if ($health.modules) { foreach ($module in $health.modules.PSObject.Properties) { $status = if ($module.Value) { "[ON] Enabled" } else { "[OFF] Disabled" } Write-Info " - $($module.Name): $status" } } } catch { # If can't parse JSON, just show raw response Write-Info "Response: $($response.Content)" } return $true } else { Write-Warning "Health check returned HTTP $($response.StatusCode)" return $false } } catch { Write-Warning "Health check FAILED: $_" Write-Info "This may indicate the backend is still initializing or there's a configuration issue" return $false } } # ============================================================================= # OCR DEPENDENCY CHECK # ============================================================================= function Test-OCRDependencies { param( [switch]$AutoInstall, [switch]$Silent ) if (-not $Silent) { Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host " OCR Dependencies Check$(if ($AutoInstall) { ' (Auto-Install Enabled)' })" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan } $allOk = $true # Get Python paths (prefer venv) $pyPaths = Get-PythonPaths # Check Python/venv if (-not $Silent) { Write-Step "Checking Python installation..." } if ($pyPaths.IsVenv) { if (-not $Silent) { $venvPythonVersion = & $pyPaths.Python --version 2>&1 Write-Success "Virtual environment: $($Config.VenvPath)" Write-Success "Python (venv): $venvPythonVersion" } # Ensure service uses venv Python if ($AutoInstall) { # Install requirements.txt if exists $requirementsFile = Join-Path $Config.BackendPath "requirements.txt" if (Test-Path $requirementsFile) { if (-not $Silent) { Write-Step "Installing base requirements in venv..." } try { & $pyPaths.Pip install -r $requirementsFile 2>&1 | Out-Null if (-not $Silent) { Write-Success "Base requirements installed" } } catch { if (-not $Silent) { Write-Warning "Failed to install requirements: $_" } } } if (-not $Silent) { Write-Step "Ensuring service uses venv Python..." } $serviceUpdated = Update-ServiceToUseVenv if ($serviceUpdated -and -not $Silent) { Write-Success "Service configured to use venv" } } } else { # No venv - create one if AutoInstall if ($AutoInstall) { if (-not $Silent) { Write-Warning "Virtual environment not found - creating..." } $venvCreated = Initialize-Venv if ($venvCreated) { # Refresh paths $pyPaths = Get-PythonPaths # Update service to use venv Python if (-not $Silent) { Write-Step "Updating service to use virtual environment..." } Update-ServiceToUseVenv | Out-Null } else { if (-not $Silent) { Write-Error "Could not create virtual environment" } return $false } } else { # Check global Python if ($pyPaths.Python) { $pythonVersion = cmd /c "python --version 2>&1" if (-not $Silent) { Write-Warning "Using global Python (venv recommended)" Write-Success "Python: $pythonVersion" } } else { if (-not $Silent) { Write-Error "Python not found" } return $false } } } # Determine pip executable to use $pipExe = if ($pyPaths.Pip -and (Test-Path $pyPaths.Pip)) { "`"$($pyPaths.Pip)`"" } else { "pip" } if (-not $Silent -and $pyPaths.IsVenv) { Write-Info "Using pip from venv: $pipExe" } # Check and optionally install Python packages if (-not $Silent) { Write-Step "Checking Python OCR packages..." } $packages = @( @{ Name = "torch"; Package = "torch"; Required = $true; Description = "PyTorch (for docTR)" }, @{ Name = "torchvision"; Package = "torchvision"; Required = $true; Description = "TorchVision (for docTR)" }, @{ Name = "python-doctr"; Package = "python-doctr"; Required = $true; Description = "docTR OCR engine" }, @{ Name = "pytesseract"; Package = "pytesseract"; Required = $true; Description = "Tesseract Python wrapper" }, @{ Name = "paddleocr"; Package = "paddleocr"; Required = $true; Description = "PaddleOCR engine" } ) foreach ($pkg in $packages) { # Use venv pip to check packages $pipOutput = cmd /c "$pipExe show $($pkg.Package) 2>&1" $isInstalled = $pipOutput -match "Version:" if ($isInstalled) { $versionLine = $pipOutput | Where-Object { $_ -match "^Version:" } $version = if ($versionLine) { ($versionLine -split ":")[1].Trim() } else { "unknown" } if (-not $Silent) { Write-Success "$($pkg.Package): $version" } } else { if ($pkg.Required) { if ($AutoInstall) { if (-not $Silent) { Write-Warning "$($pkg.Package): NOT INSTALLED - Installing..." } try { # Use venv pip to install $installCmd = "$pipExe install `"$($pkg.Name)`"" if (-not $Silent) { Write-Info " Running: $installCmd" } $installResult = Invoke-Expression "cmd /c $installCmd 2>&1" # Verify installation $verifyOutput = cmd /c "$pipExe show $($pkg.Package) 2>&1" if ($verifyOutput -match "Version:") { if (-not $Silent) { Write-Success "$($pkg.Package): Installed successfully" } } else { if (-not $Silent) { Write-Error "$($pkg.Package): Installation FAILED" # Show last few lines of pip output for debugging $errorLines = ($installResult | Select-Object -Last 5) -join "`n" if ($errorLines) { Write-Host " Pip output:" -ForegroundColor Gray Write-Host " $errorLines" -ForegroundColor Gray } Write-Info " Try manually: $pipExe install `"$($pkg.Name)`"" } $allOk = $false } } catch { if (-not $Silent) { Write-Error "$($pkg.Package): Installation error - $_" } $allOk = $false } } else { if (-not $Silent) { Write-Error "$($pkg.Package): NOT INSTALLED - $($pkg.Description)" Write-Info " Install with: pip install $($pkg.Name)" } $allOk = $false } } else { if (-not $Silent) { Write-Warning "$($pkg.Package): Not installed (optional)" } } } } # Check external tools if (-not $Silent) { Write-Step "Checking external OCR tools..." } # Check for Chocolatey (used for auto-install) $chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue) # Install Chocolatey if needed and AutoInstall is enabled if ($AutoInstall -and -not $chocoAvailable) { if (-not $Silent) { Write-Warning "Chocolatey: NOT FOUND - Installing..." } try { Set-ExecutionPolicy Bypass -Scope Process -Force [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) # Refresh environment $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") $chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue) if ($chocoAvailable) { if (-not $Silent) { Write-Success "Chocolatey: Installed successfully" } } else { if (-not $Silent) { Write-Warning "Chocolatey: Installed but not in PATH - restart PowerShell" } } } catch { if (-not $Silent) { Write-Error "Chocolatey: Installation failed - $_" } } } # Tesseract $tesseractPath = Get-Command tesseract -ErrorAction SilentlyContinue if ($tesseractPath) { $tessVersion = cmd /c "tesseract --version 2>&1" | Select-Object -First 1 if (-not $Silent) { Write-Success "Tesseract: $tessVersion" Write-Info " Path: $($tesseractPath.Source)" } } else { if ($AutoInstall) { if ($chocoAvailable) { if (-not $Silent) { Write-Warning "Tesseract: NOT FOUND - Installing via Chocolatey..." } try { $result = cmd /c "choco install tesseract -y 2>&1" # Refresh PATH $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") $tesseractPath = Get-Command tesseract -ErrorAction SilentlyContinue if ($tesseractPath) { if (-not $Silent) { Write-Success "Tesseract: Installed successfully" } } else { if (-not $Silent) { Write-Error "Tesseract: Installation completed but not in PATH - restart PowerShell" } $allOk = $false } } catch { if (-not $Silent) { Write-Error "Tesseract: Chocolatey install failed - $_" } $allOk = $false } } else { if (-not $Silent) { Write-Error "Tesseract: NOT FOUND - Chocolatey not available for auto-install" Write-Info " Install Chocolatey first: https://chocolatey.org/install" Write-Info " Then run: choco install tesseract -y" } $allOk = $false } } else { if (-not $Silent) { Write-Error "Tesseract: NOT FOUND in PATH" Write-Info " Install with: choco install tesseract -y" Write-Info " Or download from: https://github.com/UB-Mannheim/tesseract/wiki" } $allOk = $false } } # Poppler (for PDF support) $popplerPath = Get-Command pdftoppm -ErrorAction SilentlyContinue if ($popplerPath) { if (-not $Silent) { Write-Success "Poppler: Found (pdftoppm)" Write-Info " Path: $($popplerPath.Source)" } } else { if ($AutoInstall -and $chocoAvailable) { if (-not $Silent) { Write-Warning "Poppler: NOT FOUND - Installing via Chocolatey..." } try { $result = cmd /c "choco install poppler -y 2>&1" # Refresh PATH $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") $popplerPath = Get-Command pdftoppm -ErrorAction SilentlyContinue if ($popplerPath) { if (-not $Silent) { Write-Success "Poppler: Installed successfully" } } else { if (-not $Silent) { Write-Warning "Poppler: Installation completed but not in PATH - restart PowerShell" } } } catch { if (-not $Silent) { Write-Warning "Poppler: Chocolatey install failed - $_" } } } else { if (-not $Silent) { Write-Warning "Poppler: NOT FOUND in PATH (required for PDF OCR)" Write-Info " Install with: choco install poppler -y" } } } # Check .env OCR settings if (-not $Silent) { Write-Step "Checking OCR configuration in .env..." $envPath = Join-Path $Config.BackendPath ".env" if (Test-Path $envPath) { $envContent = Get-Content $envPath -Raw $ocrSettings = @( "OCR_ENABLE_PADDLEOCR", "OCR_ENABLE_TESSERACT", "OCR_DEFAULT_ENGINE", "OCR_WORKERS" ) foreach ($setting in $ocrSettings) { if ($envContent -match "$setting\s*=\s*(.+)") { Write-Info " $setting = $($Matches[1].Trim())" } else { Write-Warning " ${setting}: NOT CONFIGURED" } } } else { Write-Warning ".env file not found at: $envPath" } Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan if ($allOk) { Write-Host " Result: All required OCR dependencies are installed" -ForegroundColor Green } else { Write-Host " Result: Some required dependencies are MISSING" -ForegroundColor Red Write-Host " Run with -AutoInstall to install missing packages" -ForegroundColor Yellow } Write-Host ("=" * 70) -ForegroundColor Cyan } return $allOk } # ============================================================================= # BACKUP FUNCTIONS # ============================================================================= function New-Backup { param([string]$Component) Write-Step "Creating backup before deployment..." if (-not (Test-Path $Config.BackupPath)) { New-Item -ItemType Directory -Path $Config.BackupPath -Force | Out-Null } $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $backupName = "backup-${Component}-${timestamp}" $backupDest = Join-Path $Config.BackupPath $backupName try { New-Item -ItemType Directory -Path $backupDest -Force | Out-Null if ($Component -eq "Backend" -or $Component -eq "All") { if (Test-Path $Config.BackendPath) { Write-Info "Backing up backend..." Copy-Item -Path $Config.BackendPath -Destination (Join-Path $backupDest "backend") -Recurse -Force } if (Test-Path $Config.SharedPath) { Write-Info "Backing up shared modules..." Copy-Item -Path $Config.SharedPath -Destination (Join-Path $backupDest "shared") -Recurse -Force } } if ($Component -eq "Frontend" -or $Component -eq "All") { if (Test-Path $Config.FrontendPath) { Write-Info "Backing up frontend..." Copy-Item -Path $Config.FrontendPath -Destination (Join-Path $backupDest "frontend") -Recurse -Force } } Write-Success "Backup created: $backupName" # Clean old backups (keep last N) $allBackups = Get-ChildItem -Path $Config.BackupPath -Directory | Sort-Object CreationTime -Descending if ($allBackups.Count -gt $Config.MaxBackups) { $toDelete = $allBackups | Select-Object -Skip $Config.MaxBackups foreach ($old in $toDelete) { Write-Info "Removing old backup: $($old.Name)" Remove-Item -Path $old.FullName -Recurse -Force } } return $true } catch { Write-Error "Backup failed: $_" return $false } } # ============================================================================= # DEPLOYMENT FUNCTIONS # ============================================================================= function Deploy-Scripts { <# .SYNOPSIS Deploys PowerShell scripts to installation directory .DESCRIPTION Copies deployment scripts from package to install root's scripts folder. This includes the wrapper script needed for SSH tunnel auto-start. #> param([string]$SourcePath) Write-Step "Deploying scripts..." $sourceScripts = Join-Path $SourcePath "scripts" $destScripts = Join-Path $Config.InstallRoot "scripts" # Scripts to deploy (essential for operation) $requiredScripts = @( "ssh-tunnel.ps1", "start-backend-service.ps1", "ROA2WEB-Console.ps1" ) try { # Create scripts directory if needed if (-not (Test-Path $destScripts)) { New-Item -ItemType Directory -Path $destScripts -Force | Out-Null Write-Info "Created scripts directory: $destScripts" } $deployedCount = 0 # Copy scripts from package if (Test-Path $sourceScripts) { foreach ($script in $requiredScripts) { $srcFile = Join-Path $sourceScripts $script $destFile = Join-Path $destScripts $script if (Test-Path $srcFile) { Copy-Item -Path $srcFile -Destination $destFile -Force Write-Info "Deployed: $script" $deployedCount++ } } } # Also copy from current script location (fallback) foreach ($script in $requiredScripts) { $srcFile = Join-Path $PSScriptRoot $script $destFile = Join-Path $destScripts $script if ((Test-Path $srcFile) -and (-not (Test-Path $destFile))) { Copy-Item -Path $srcFile -Destination $destFile -Force Write-Info "Deployed (from PSScriptRoot): $script" $deployedCount++ } } if ($deployedCount -gt 0) { Write-Success "Scripts deployed ($deployedCount files)" } else { Write-Warning "No scripts to deploy" } # Verify essential wrapper script $wrapperPath = Join-Path $destScripts "start-backend-service.ps1" if (Test-Path $wrapperPath) { Write-Success "Service wrapper script ready: $wrapperPath" } else { Write-Warning "Service wrapper script not found - SSH tunnel auto-start may not work" } return $true } catch { Write-Error "Scripts deployment failed: $_" return $false } } function Deploy-Backend { param([string]$SourcePath) Write-Step "Deploying backend..." $sourceBe = Join-Path $SourcePath "backend" $sourceShared = Join-Path $SourcePath "shared" if (-not (Test-Path $sourceBe)) { Write-Error "Backend not found in package: $sourceBe" return $false } try { # Stop service if (-not (Stop-ROAService)) { Write-Warning "Failed to stop service, continuing anyway..." } # Backup if (-not (New-Backup -Component "Backend")) { Write-Warning "Backup failed, but continuing with deployment" } # Deploy backend Write-Info "Copying backend files..." if (Test-Path $Config.BackendPath) { # Preserve .env file $envFile = Join-Path $Config.BackendPath ".env" $envBackup = $null if (Test-Path $envFile) { $envBackup = Get-Content $envFile -Raw Write-Info "Preserving .env file" } # Preserve data directory (contains SQLite databases with production data!) $dataDir = Join-Path $Config.BackendPath "data" $dataTempPath = Join-Path $env:TEMP "roa2web-data-backup-$(Get-Date -Format 'yyyyMMddHHmmss')" $dataBackup = $null if (Test-Path $dataDir) { Write-Info "Preserving data directory: receipts.db, telegram.db, cache, uploads" Write-Info " - receipts/: Data Entry SQLite database (receipts, approvals)" Write-Info " - telegram/: Telegram bot auth/session database" Write-Info " - cache/: Reports L2 cache database" Copy-Item -Path $dataDir -Destination $dataTempPath -Recurse -Force $dataBackup = $dataTempPath } # Delete old venv inside backend if it exists (it has hardcoded paths and can't be moved) $oldVenvPath = Join-Path $Config.BackendPath "venv" if (Test-Path $oldVenvPath) { Write-Info "Removing old venv from backend directory (will use external venv)" Remove-Item -Path $oldVenvPath -Recurse -Force -ErrorAction SilentlyContinue } Remove-Item -Path $Config.BackendPath -Recurse -Force Copy-Item -Path $sourceBe -Destination $Config.BackendPath -Recurse -Force # Restore .env if ($envBackup) { Set-Content -Path $envFile -Value $envBackup -Force Write-Success ".env file restored" } # Restore data directory if ($dataBackup -and (Test-Path $dataBackup)) { # Remove the empty data dir from package and restore the preserved one $newDataDir = Join-Path $Config.BackendPath "data" if (Test-Path $newDataDir) { Remove-Item -Path $newDataDir -Recurse -Force } Copy-Item -Path $dataBackup -Destination $newDataDir -Recurse -Force Remove-Item -Path $dataBackup -Recurse -Force -ErrorAction SilentlyContinue Write-Success "Data directory restored (SQLite databases preserved)" } } else { Copy-Item -Path $sourceBe -Destination $Config.BackendPath -Recurse -Force } Write-Success "Backend files deployed" # Deploy shared modules if present if (Test-Path $sourceShared) { Write-Info "Copying shared modules..." if (Test-Path $Config.SharedPath) { Remove-Item -Path $Config.SharedPath -Recurse -Force } Copy-Item -Path $sourceShared -Destination $Config.SharedPath -Recurse -Force Write-Success "Shared modules deployed" } # Setup virtual environment Write-Step "Setting up Python virtual environment..." $venvCreated = Initialize-Venv if (-not $venvCreated) { Write-Warning "Could not create/verify virtual environment" } # Install requirements.txt $requirementsFile = Join-Path $Config.BackendPath "requirements.txt" if (Test-Path $requirementsFile) { $pyPaths = Get-PythonPaths if ($pyPaths.IsVenv) { Write-Step "Installing Python dependencies in venv..." $pipExe = $pyPaths.Pip # Verify pip is functional before installing $pipVersion = & $pipExe --version 2>&1 if ($LASTEXITCODE -ne 0) { Write-Error "Pip is not functional: $pipVersion" Write-Info "Try: Remove-Item -Recurse $($Config.VenvPath); then redeploy" } else { Write-Info "Using pip: $pipVersion" # Install requirements (ignore warnings, check exit code only) $oldErrorAction = $ErrorActionPreference $ErrorActionPreference = "Continue" $pipOutput = & $pipExe install -r $requirementsFile 2>&1 $pipExitCode = $LASTEXITCODE $ErrorActionPreference = $oldErrorAction # Log warnings but don't fail on them $pipOutput | ForEach-Object { if ($_ -match "WARNING:") { Write-Warning $_ } } if ($pipExitCode -eq 0) { # Verify uvicorn installed (critical dependency) $uvicornCheck = & $pipExe show uvicorn 2>&1 if ($LASTEXITCODE -eq 0) { Write-Success "Python dependencies installed" } else { Write-Error "Dependencies install failed - uvicorn not found" Write-Info "Manual fix: $pipExe install -r $requirementsFile" } } else { Write-Error "Pip install failed with exit code $pipExitCode" Write-Info "Manual fix: $pipExe install -r $requirementsFile" } } } else { Write-Warning "No venv found - skipping requirements.txt installation" } } # Update service to use venv Python $pyPaths = Get-PythonPaths if ($pyPaths.IsVenv) { Update-ServiceToUseVenv | Out-Null } # Start service Start-Sleep -Seconds 2 if (Start-ROAService) { Write-Success "Backend deployment completed successfully" # Check and auto-install OCR dependencies after deployment Write-Step "Checking and installing OCR dependencies..." $ocrOk = Test-OCRDependencies -AutoInstall if (-not $ocrOk) { Write-Warning "Some OCR dependencies could not be installed automatically" Write-Info "Manual installation may be required for external tools (Tesseract, Poppler)" } return $true } else { Write-Warning "Backend deployed but service start failed" Write-Info "Check logs: $($Config.BackendStderrLog)" return $false } } catch { Write-Error "Backend deployment failed: $_" return $false } } function Deploy-Frontend { param([string]$SourcePath) Write-Step "Deploying frontend..." $sourceFe = Join-Path $SourcePath "frontend" if (-not (Test-Path $sourceFe)) { Write-Error "Frontend not found in package: $sourceFe" return $false } try { # Backup if (-not (New-Backup -Component "Frontend")) { Write-Warning "Backup failed, but continuing with deployment" } # Deploy frontend Write-Info "Copying frontend files to IIS..." if (Test-Path $Config.FrontendPath) { Remove-Item -Path $Config.FrontendPath -Recurse -Force } Copy-Item -Path $sourceFe -Destination $Config.FrontendPath -Recurse -Force Write-Success "Frontend files deployed" # Verify web.config was deployed (should be in frontend/ from dist/) $deployedWebConfig = Join-Path $Config.FrontendPath "web.config" if (Test-Path $deployedWebConfig) { Write-Success "web.config deployed successfully" # Verify it has correct configuration for /roa2web/ path $configContent = Get-Content $deployedWebConfig -Raw if ($configContent -match 'url="[^"]*roa2web/api') { Write-Success "web.config contains correct /roa2web/api proxy rules" } else { Write-Warning "web.config may not have correct /roa2web/api proxy configuration" } } else { Write-Warning "web.config not found in deployed frontend: $deployedWebConfig" Write-Warning "IIS reverse proxy will not work without web.config" Write-Warning "Ensure 'public/web.config' exists in source and rebuild frontend" } Write-Success "Frontend deployment completed successfully" return $true } catch { Write-Error "Frontend deployment failed: $_" return $false } } function Deploy-All { param([string]$SourcePath) Write-Step "Deploying complete package (Backend + Frontend)..." Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host " FULL DEPLOYMENT" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan # Backup everything if (-not (New-Backup -Component "All")) { Write-Warning "Backup failed, but continuing with deployment" } # Deploy scripts first (needed for service wrapper) $scriptsOk = Deploy-Scripts -SourcePath $SourcePath # Deploy backend (includes service restart) $backendOk = Deploy-Backend -SourcePath $SourcePath # Deploy frontend $frontendOk = Deploy-Frontend -SourcePath $SourcePath Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host " DEPLOYMENT SUMMARY" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " Scripts: " -NoNewline if ($scriptsOk) { Write-Host "[OK] Success" -ForegroundColor Green } else { Write-Host "[X] Failed" -ForegroundColor Red } Write-Host " Backend: " -NoNewline if ($backendOk) { Write-Host "[OK] Success" -ForegroundColor Green } else { Write-Host "[X] Failed" -ForegroundColor Red } Write-Host " Frontend: " -NoNewline if ($frontendOk) { Write-Host "[OK] Success" -ForegroundColor Green } else { Write-Host "[X] Failed" -ForegroundColor Red } Write-Host ("=" * 70) -ForegroundColor Cyan return ($scriptsOk -and $backendOk -and $frontendOk) } # ============================================================================= # LOG VIEWING # ============================================================================= function Show-Logs { Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host " Service Logs" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan # Backend stdout if (Test-Path $Config.BackendStdoutLog) { Write-Host "`n--- Backend STDOUT (last 30 lines) ---" -ForegroundColor Yellow Get-Content $Config.BackendStdoutLog -Tail 30 | ForEach-Object { Write-Host $_ -ForegroundColor Gray } } else { Write-Warning "Backend stdout log not found: $($Config.BackendStdoutLog)" } # Backend stderr if (Test-Path $Config.BackendStderrLog) { Write-Host "`n--- Backend STDERR (last 20 lines) ---" -ForegroundColor Yellow Get-Content $Config.BackendStderrLog -Tail 20 | ForEach-Object { Write-Host $_ -ForegroundColor Red } } else { Write-Info "Backend stderr log not found (this is OK if no errors)" } Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan } # ============================================================================= # STATUS DISPLAY # ============================================================================= function Show-Status { Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host " ROA2WEB Ultrathin Monolith - System Status" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan # Service Status $service = Get-ServiceSafe -ServiceName $Config.ServiceName Write-Host "`n Service: $($Config.ServiceDisplayName)" -ForegroundColor Yellow if ($service) { Write-Host " Status: " -NoNewline if ($service.Status -eq 'Running') { Write-Host "$($service.Status)" -ForegroundColor Green } else { Write-Host "$($service.Status)" -ForegroundColor Red } Write-Host " Name: $($Config.ServiceName)" -ForegroundColor Gray Write-Host " Port: $($Config.ServicePort)" -ForegroundColor Gray } else { Write-Host " Status: " -NoNewline Write-Host "Not Installed" -ForegroundColor Red Write-Host " Run Install-ROA2WEB.ps1 to install the service" -ForegroundColor Gray } # Health Check if ($service -and $service.Status -eq 'Running') { Write-Host "" Test-ServiceHealth | Out-Null } # Paths Write-Host "`n Installation Paths:" -ForegroundColor Yellow Write-Host " Root: $($Config.InstallRoot)" -ForegroundColor Gray Write-Host " Backend: $($Config.BackendPath)" -ForegroundColor Gray Write-Host " Frontend: $($Config.FrontendPath)" -ForegroundColor Gray Write-Host " Shared: $($Config.SharedPath)" -ForegroundColor Gray Write-Host " Logs: $($Config.LogsPath)" -ForegroundColor Gray # Module Configuration $envFile = Join-Path $Config.BackendPath ".env" if (Test-Path $envFile) { Write-Host "`n Module Configuration (.env):" -ForegroundColor Yellow $envContent = Get-Content $envFile $modules = $envContent | Where-Object { $_ -match "^MODULE_.*_ENABLED=" } foreach ($line in $modules) { if ($line -match "MODULE_(.+)_ENABLED=(.+)") { $moduleName = $matches[1] $enabled = $matches[2] -eq "true" Write-Host " $moduleName`: " -NoNewline -ForegroundColor Gray if ($enabled) { Write-Host "Enabled" -ForegroundColor Green } else { Write-Host "Disabled" -ForegroundColor Red } } } } # Endpoints Write-Host "`n API Endpoints:" -ForegroundColor Yellow Write-Host " Health: http://localhost:$($Config.ServicePort)/health" -ForegroundColor Gray Write-Host " Docs: http://localhost:$($Config.ServicePort)/docs" -ForegroundColor Gray Write-Host " Frontend: http://localhost/ (via IIS)" -ForegroundColor Gray Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan } # ============================================================================= # INTERACTIVE MENU # ============================================================================= function Show-MainMenu { Clear-Host Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host " ROA2WEB Unified Console - Ultrathin Monolith" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan $service = Get-ServiceSafe -ServiceName $Config.ServiceName if ($service) { Write-Host "`n Service Status: " -NoNewline if ($service.Status -eq 'Running') { Write-Host "RUNNING" -ForegroundColor Green } else { Write-Host "$($service.Status)" -ForegroundColor Yellow } } else { Write-Host "`n Service Status: " -NoNewline Write-Host "NOT INSTALLED" -ForegroundColor Red } Write-Host "`n Main Menu:" -ForegroundColor Yellow Write-Host "" Write-Host " === Deployment ===" -ForegroundColor Cyan Write-Host " [1] Deploy Backend" -ForegroundColor White Write-Host " [2] Deploy Frontend" -ForegroundColor White Write-Host " [3] Deploy All (Backend + Frontend)" -ForegroundColor White Write-Host "" Write-Host " === Service Management ===" -ForegroundColor Cyan Write-Host " [4] Start Service" -ForegroundColor White Write-Host " [5] Stop Service" -ForegroundColor White Write-Host " [6] Restart Service" -ForegroundColor White Write-Host "" Write-Host " === Monitoring ===" -ForegroundColor Cyan Write-Host " [7] View Status" -ForegroundColor White Write-Host " [8] View Logs" -ForegroundColor White Write-Host " [9] Check/Install OCR Dependencies" -ForegroundColor White Write-Host "" Write-Host " [Q] Quit" -ForegroundColor Red Write-Host "" Write-Host ("=" * 70) -ForegroundColor Cyan do { Write-Host "`nYour choice: " -ForegroundColor Yellow -NoNewline $choice = Read-Host switch ($choice.ToUpper()) { "1" { Write-Host "`nEnter deployment package path: " -NoNewline $pkgPath = Read-Host if (Test-Path $pkgPath) { Deploy-Backend -SourcePath $pkgPath | Out-Null } else { Write-Error "Package path not found: $pkgPath" } Wait-ForKeyPress return "Continue" } "2" { Write-Host "`nEnter deployment package path: " -NoNewline $pkgPath = Read-Host if (Test-Path $pkgPath) { Deploy-Frontend -SourcePath $pkgPath | Out-Null } else { Write-Error "Package path not found: $pkgPath" } Wait-ForKeyPress return "Continue" } "3" { Write-Host "`nEnter deployment package path: " -NoNewline $pkgPath = Read-Host if (Test-Path $pkgPath) { Deploy-All -SourcePath $pkgPath | Out-Null } else { Write-Error "Package path not found: $pkgPath" } Wait-ForKeyPress return "Continue" } "4" { Start-ROAService | Out-Null Wait-ForKeyPress return "Continue" } "5" { Stop-ROAService | Out-Null Wait-ForKeyPress return "Continue" } "6" { Restart-ROAService | Out-Null Wait-ForKeyPress return "Continue" } "7" { Show-Status Wait-ForKeyPress return "Continue" } "8" { Show-Logs Wait-ForKeyPress return "Continue" } "9" { $ocrOk = Test-OCRDependencies if (-not $ocrOk) { Write-Host "" Write-Host " Install missing dependencies? (Y/N): " -ForegroundColor Yellow -NoNewline $installChoice = Read-Host if ($installChoice -eq "Y" -or $installChoice -eq "y") { Write-Host "" Test-OCRDependencies -AutoInstall | Out-Null } } Wait-ForKeyPress return "Continue" } "Q" { return "Quit" } default { Write-Host "Invalid choice. Please select 1-9 or Q." -ForegroundColor Red } } } while ($true) } # ============================================================================= # MAIN EXECUTION # ============================================================================= function Main { # Check if service installed if (-not (Test-ServiceInstalled) -and -not $NonInteractive) { Write-Warning "ROA2WEB service not found" Write-Info "Run Install-ROA2WEB.ps1 first to install the service" Write-Host "" Wait-ForKeyPress } # Non-interactive mode if ($NonInteractive -and $Action) { switch ($Action) { "DeployBackend" { if (-not $PackagePath) { Write-Error "PackagePath parameter required for DeployBackend" exit 1 } $success = Deploy-Backend -SourcePath $PackagePath exit $(if ($success) { 0 } else { 1 }) } "DeployFrontend" { if (-not $PackagePath) { Write-Error "PackagePath parameter required for DeployFrontend" exit 1 } $success = Deploy-Frontend -SourcePath $PackagePath exit $(if ($success) { 0 } else { 1 }) } "DeployAll" { if (-not $PackagePath) { Write-Error "PackagePath parameter required for DeployAll" exit 1 } $success = Deploy-All -SourcePath $PackagePath exit $(if ($success) { 0 } else { 1 }) } "StartService" { $success = Start-ROAService exit $(if ($success) { 0 } else { 1 }) } "StopService" { $success = Stop-ROAService exit $(if ($success) { 0 } else { 1 }) } "RestartService" { $success = Restart-ROAService exit $(if ($success) { 0 } else { 1 }) } "Status" { Show-Status exit 0 } "ViewLogs" { Show-Logs exit 0 } "CheckOCR" { $success = Test-OCRDependencies exit $(if ($success) { 0 } else { 1 }) } "InstallOCR" { $success = Test-OCRDependencies -AutoInstall exit $(if ($success) { 0 } else { 1 }) } } } # Interactive mode do { $result = Show-MainMenu } while ($result -eq "Continue") Write-Host "`nGoodbye!`n" -ForegroundColor Cyan } # Run main Main