25a5127d53
Per Open Dental docs a complete backup is the database + A-to-Z images; the program itself is not backed up but reinstalled at the matching version on restore. Extend the script to capture two recovery aids best-effort (outside the downtime window): a copy of FreeDentalConfig.xml and the exact Open Dental + MySQL/MariaDB versions, recorded in MANIFEST.txt. Runbook now spells out what is/isn't backed up and notes the config file holds the obfuscated DB password on direct-connect setups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
12 KiB
PowerShell
263 lines
12 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Rock-solid cold backup of an Open Dental server: stop MySQL/MariaDB, copy the
|
|
entire data directory and the A-to-Z (OpenDentImages) folder, then restart the
|
|
service. Guarantees the service is restarted even if the copy fails.
|
|
|
|
.DESCRIPTION
|
|
The only fully consistent, engine-agnostic file backup of a MySQL/MariaDB
|
|
database is a COLD copy: the service is cleanly stopped (which flushes InnoDB),
|
|
the data directory is copied, then the service is restarted. This works for
|
|
both MyISAM and InnoDB, unlike a hot file copy or Open Dental's built-in Backup
|
|
tool (which cannot restore InnoDB).
|
|
|
|
Two correctness points this script gets right:
|
|
- It copies the ENTIRE data directory, not just the `opendental` subfolder.
|
|
InnoDB's shared tablespace and redo logs (ibdata1, ib_logfile*) live at the
|
|
datadir root; copying only `opendental` yields an unrestorable backup.
|
|
- The service is stopped and VERIFIED stopped before any copy begins. A copy
|
|
of a running InnoDB datadir is corrupt. If the service will not stop, the
|
|
script aborts without copying.
|
|
|
|
The service restart runs in a finally block, so an interrupted or failed copy
|
|
never leaves the practice's database down.
|
|
|
|
This is the primary "gold" backup. Pair it with an off-hours mysqldump for a
|
|
portable logical copy and with off-site/immutable retention — see
|
|
od-db-backup.md.
|
|
|
|
.NOTES
|
|
Run ON THE DATABASE SERVER, elevated, when no one is using Open Dental:
|
|
irm https://gitea.ivangodwin.com/ops/rb/raw/branch/main/scripts/od-db-backup.ps1 | iex
|
|
|
|
Because `irm | iex` runs in the caller's session: no param() block (prompts via
|
|
Read-Host), and #Requires is not enforced (admin is checked manually).
|
|
|
|
Causes DOWNTIME for the duration of the copy — Open Dental is unavailable on
|
|
every workstation while the service is stopped. Run it off-hours.
|
|
|
|
PUBLIC REPO: no client specifics hard-coded. All paths are auto-detected or
|
|
prompted at run time.
|
|
#>
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# --- Admin check (do not rely on #Requires under iex) ---
|
|
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
|
[Security.Principal.WindowsIdentity]::GetCurrent()
|
|
).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
|
|
if (-not $isAdmin) {
|
|
Write-Warning 'This script needs an elevated PowerShell session. Re-run as Administrator.'
|
|
return
|
|
}
|
|
|
|
Write-Host '== Open Dental: cold database + images backup ==' -ForegroundColor Cyan
|
|
Write-Host 'Run this ON THE DB SERVER, off-hours. It stops MySQL/MariaDB (downtime).' -ForegroundColor Yellow
|
|
Write-Host ''
|
|
|
|
# --- 1. Resolve the MySQL/MariaDB service ---
|
|
$dbServices = @(Get-Service | Where-Object { $_.Name -match 'mysql|mariadb' -or $_.DisplayName -match 'mysql|mariadb' })
|
|
if ($dbServices.Count -eq 1) {
|
|
$svcName = $dbServices[0].Name
|
|
Write-Host "Detected database service: $svcName ($($dbServices[0].DisplayName)) [$($dbServices[0].Status)]" -ForegroundColor Green
|
|
if ((Read-Host "Use this service? (Y/n)") -eq 'n') { $svcName = $null }
|
|
}
|
|
elseif ($dbServices.Count -gt 1) {
|
|
Write-Host 'Multiple database services found:' -ForegroundColor Yellow
|
|
$dbServices | ForEach-Object { Write-Host " - $($_.Name) ($($_.DisplayName)) [$($_.Status)]" }
|
|
$svcName = $null
|
|
}
|
|
else {
|
|
Write-Warning 'No service matching mysql/mariadb found. Enter the name manually.'
|
|
$svcName = $null
|
|
}
|
|
if (-not $svcName) {
|
|
$svcName = Read-Host 'MySQL/MariaDB service name (e.g. MySQL, MySQL57, MariaDB)'
|
|
}
|
|
$svc = Get-Service -Name $svcName # throws if it doesn't exist
|
|
|
|
# --- 2. Resolve the data directory (copy the WHOLE dir, not just opendental) ---
|
|
$dataGuesses = @('C:\mysql\data', 'C:\Program Files\MySQL\MySQL Server*\data',
|
|
'C:\ProgramData\MySQL\MySQL Server*\Data', 'C:\Program Files\MariaDB*\data') |
|
|
ForEach-Object { Get-Item $_ -ErrorAction SilentlyContinue } |
|
|
Where-Object { $_ } | Select-Object -ExpandProperty FullName
|
|
$dataDir = $dataGuesses | Where-Object { Test-Path (Join-Path $_ 'opendental') } | Select-Object -First 1
|
|
if ($dataDir) {
|
|
Write-Host "Detected data directory: $dataDir" -ForegroundColor Green
|
|
if ((Read-Host "Use this data directory? (Y/n)") -eq 'n') { $dataDir = $null }
|
|
}
|
|
if (-not $dataDir) {
|
|
$dataDir = Read-Host 'MySQL data directory (the folder CONTAINING the opendental subfolder, e.g. C:\mysql\data)'
|
|
}
|
|
if (-not (Test-Path (Join-Path $dataDir 'opendental'))) {
|
|
Write-Warning "No 'opendental' subfolder under: $dataDir"
|
|
if ((Read-Host 'Continue anyway? (y/N)') -ne 'y') { return }
|
|
}
|
|
|
|
# --- 3. Resolve the A-to-Z / OpenDentImages folder (optional) ---
|
|
$imgGuess = @('C:\OpenDentImages', 'D:\OpenDentImages') | Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
$imgDir = Read-Host "OpenDentImages (A-to-Z) folder [blank to skip]$(if($imgGuess){" (detected: $imgGuess)"})"
|
|
if (-not $imgDir -and $imgGuess) { $imgDir = $imgGuess }
|
|
if ($imgDir -and -not (Test-Path $imgDir)) {
|
|
Write-Warning "Images path not found: $imgDir — it will be skipped."
|
|
$imgDir = $null
|
|
}
|
|
|
|
# --- 4. Backup destination ---
|
|
$destRoot = Read-Host 'Backup destination root (external/second disk or UNC path, e.g. E:\OD-Backups)'
|
|
if (-not (Test-Path $destRoot)) {
|
|
if ((Read-Host "Destination does not exist. Create it? (Y/n)") -eq 'n') { return }
|
|
New-Item -ItemType Directory -Path $destRoot -Force | Out-Null
|
|
}
|
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
$backupDir = Join-Path $destRoot "od-backup-$stamp"
|
|
|
|
# --- 5. Size / free-space sanity check ---
|
|
function Get-DirSizeGB($path) {
|
|
try {
|
|
$bytes = (Get-ChildItem -LiteralPath $path -Recurse -Force -File -ErrorAction SilentlyContinue |
|
|
Measure-Object -Property Length -Sum).Sum
|
|
[math]::Round(($bytes / 1GB), 2)
|
|
} catch { $null }
|
|
}
|
|
Write-Host 'Measuring source size (may take a moment for large image folders)...'
|
|
$dataGB = Get-DirSizeGB $dataDir
|
|
$imgGB = if ($imgDir) { Get-DirSizeGB $imgDir } else { 0 }
|
|
$needGB = [math]::Round(($dataGB + $imgGB), 2)
|
|
try {
|
|
$destQualifier = (Split-Path -Qualifier $backupDir)
|
|
$freeGB = [math]::Round(((Get-PSDrive ($destQualifier.TrimEnd(':'))).Free / 1GB), 2)
|
|
} catch { $freeGB = $null }
|
|
|
|
Write-Host ''
|
|
Write-Host 'Plan:' -ForegroundColor Cyan
|
|
Write-Host " Service to cycle : $svcName [$($svc.Status)]"
|
|
Write-Host " Data directory : $dataDir (~$dataGB GB)"
|
|
Write-Host " Images folder : $(if($imgDir){"$imgDir (~$imgGB GB)"}else{'(skipped)'})"
|
|
Write-Host " Destination : $backupDir"
|
|
Write-Host " Approx needed : ~$needGB GB$(if($freeGB){" (free at dest: $freeGB GB)"})"
|
|
if ($freeGB -and $needGB -gt $freeGB) {
|
|
Write-Warning 'Destination may not have enough free space for this backup.'
|
|
}
|
|
Write-Host ''
|
|
Write-Host 'This STOPS the database (Open Dental goes down on all workstations) for the copy.' -ForegroundColor Yellow
|
|
if ((Read-Host 'Proceed? (y/N)') -ne 'y') { Write-Host 'Aborted. No changes made.'; return }
|
|
|
|
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
|
|
$logPath = Join-Path $backupDir 'backup.log'
|
|
function Log($msg) {
|
|
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg"
|
|
Write-Host $line
|
|
Add-Content -LiteralPath $logPath -Value $line
|
|
}
|
|
|
|
# robocopy: /E all subdirs incl empty, /COPY:DAT+/DCOPY:DAT data/attrs/timestamps,
|
|
# /R:2 /W:5 short retries, /NFL /NDL /NP quieter. Exit code >= 8 = failure.
|
|
function Copy-Tree($src, $dst, $label) {
|
|
Log "Copying $label : $src -> $dst"
|
|
robocopy $src $dst /E /COPY:DAT /DCOPY:DAT /R:2 /W:5 /NFL /NDL /NP /NJH /NJS | Out-Null
|
|
$code = $LASTEXITCODE
|
|
Log "robocopy ($label) exit code $code"
|
|
if ($code -ge 8) { throw "robocopy failed copying $label (exit $code)." }
|
|
}
|
|
|
|
$wasRunning = ($svc.Status -eq 'Running')
|
|
$ok = $false
|
|
try {
|
|
# --- 6. Stop the service and VERIFY it stopped before copying ---
|
|
if ($svc.Status -ne 'Stopped') {
|
|
Log "Stopping service $svcName ..."
|
|
Stop-Service -Name $svcName -Force
|
|
$svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(120)) # throws on timeout
|
|
}
|
|
(Get-Service -Name $svcName).Refresh()
|
|
if ((Get-Service -Name $svcName).Status -ne 'Stopped') {
|
|
throw "Service $svcName did not reach Stopped — aborting before copy."
|
|
}
|
|
Log "Service $svcName confirmed Stopped. Beginning cold copy."
|
|
|
|
# --- 7. Copy the whole data directory, then images ---
|
|
Copy-Tree $dataDir (Join-Path $backupDir 'data') 'data directory'
|
|
if ($imgDir) { Copy-Tree $imgDir (Join-Path $backupDir 'OpenDentImages') 'OpenDentImages' }
|
|
|
|
$ok = $true
|
|
Log 'Cold copy completed successfully.'
|
|
}
|
|
catch {
|
|
Log "ERROR: $($_.Exception.Message)"
|
|
Write-Warning "Backup failed: $($_.Exception.Message)"
|
|
}
|
|
finally {
|
|
# --- 8. ALWAYS restart the service if it was running (never leave DB down) ---
|
|
if ($wasRunning) {
|
|
try {
|
|
Log "Restarting service $svcName ..."
|
|
Start-Service -Name $svcName
|
|
(Get-Service -Name $svcName).WaitForStatus('Running', [TimeSpan]::FromSeconds(120))
|
|
Log "Service $svcName is Running again."
|
|
}
|
|
catch {
|
|
Write-Warning "CRITICAL: could not restart $svcName. Start it manually NOW: Start-Service $svcName"
|
|
Log "CRITICAL: restart failed: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
else {
|
|
Log "Service was not running at start; left as-found (Stopped)."
|
|
}
|
|
}
|
|
|
|
# --- 9. Capture recovery aids: FreeDentalConfig.xml + version stamps (best-effort) ---
|
|
# The Open Dental program is NOT backed up (reinstall the matching version on
|
|
# restore). But grabbing the connection config and recording the exact versions
|
|
# makes a restore fast and version-correct. Best-effort: never fails the backup.
|
|
if ($ok) {
|
|
$odInstall = @(${env:ProgramFiles(x86)}, $env:ProgramFiles) | Where-Object { $_ } |
|
|
ForEach-Object { Join-Path $_ 'Open Dental' } |
|
|
Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
$odVersion = 'unknown'
|
|
if ($odInstall) {
|
|
$cfg = Join-Path $odInstall 'FreeDentalConfig.xml'
|
|
if (Test-Path $cfg) {
|
|
try { Copy-Item -LiteralPath $cfg -Destination (Join-Path $backupDir 'FreeDentalConfig.xml') -Force
|
|
Log 'Captured FreeDentalConfig.xml (connection/AtoZ config).' }
|
|
catch { Log "Could not copy FreeDentalConfig.xml: $($_.Exception.Message)" }
|
|
}
|
|
$exe = Join-Path $odInstall 'OpenDental.exe'
|
|
if (Test-Path $exe) { try { $odVersion = (Get-Item $exe).VersionInfo.FileVersion } catch {} }
|
|
}
|
|
# DB engine version from the service's backing executable
|
|
$dbVersion = 'unknown'
|
|
try {
|
|
$imgPath = (Get-CimInstance Win32_Service -Filter "Name='$svcName'" -ErrorAction Stop).PathName
|
|
$exePath = ([regex]::Match($imgPath, '([A-Za-z]:\\[^"]*?\.exe)')).Groups[1].Value
|
|
if ($exePath -and (Test-Path $exePath)) { $dbVersion = (Get-Item $exePath).VersionInfo.ProductVersion }
|
|
} catch {}
|
|
}
|
|
|
|
# --- 10. Write a manifest and report ---
|
|
if ($ok) {
|
|
$manifest = @"
|
|
Open Dental cold backup
|
|
Created : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
|
Host : $env:COMPUTERNAME
|
|
Service : $svcName
|
|
Open Dental ver : $odVersion (reinstall THIS version to restore)
|
|
DB engine ver : $dbVersion (restore onto a matching MySQL/MariaDB)
|
|
Data directory : $dataDir (~$dataGB GB)
|
|
Images folder : $(if($imgDir){"$imgDir (~$imgGB GB)"}else{'(skipped)'})
|
|
FreeDentalConfig : $(if($odInstall -and (Test-Path (Join-Path $backupDir 'FreeDentalConfig.xml'))){'captured'}else{'(not found)'})
|
|
Backup folder : $backupDir
|
|
Method : cold copy (service stopped + verified), whole data dir incl InnoDB shared tablespace/logs
|
|
"@
|
|
Set-Content -LiteralPath (Join-Path $backupDir 'MANIFEST.txt') -Value $manifest
|
|
Write-Host ''
|
|
Write-Host "Backup complete: $backupDir" -ForegroundColor Green
|
|
Write-Host "Open Dental $odVersion / DB $dbVersion — reinstall matching versions to restore." -ForegroundColor Green
|
|
Write-Host 'Next: copy this folder OFF-SITE, and TEST-RESTORE it periodically on an isolated machine.' -ForegroundColor Green
|
|
Write-Host 'Never restore over a live production database.' -ForegroundColor Yellow
|
|
}
|
|
else {
|
|
Write-Warning "Backup did NOT complete. See log: $logPath"
|
|
Write-Warning "Confirm the database service is running: Get-Service $svcName"
|
|
}
|