Files
rb/scripts/od-db-backup.ps1
T
igodwin cb0c5b4614 Move runbooks into domain directories
od-db-backup.md becomes od/db-backup.md -- the hyphen becomes a slash, so
the fetch command is exactly as long to type as before. That mattered: the
length of a hand-typed command is the constraint this repo is organized
around, and a reorganization that lengthened it would have been a net loss.

Scripts deliberately stay flat in scripts/ with their domain prefix.
Everything executable in one directory is the set worth reading before it
runs, and nesting five files by domain would add characters without adding
clarity.

Updates every reference: README Contents (now grouped by directory), the
layout section, both fetch examples, inter-runbook links, and the .NOTES
headers in all five scripts. Verified every markdown link resolves on disk
and that Contents and the filesystem agree in both directions.

Records the naming rule in CONTRIBUTING so the next file lands correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwcG1jLs1T425QRMxtjxP7
2026-09-02 23:15:48 -07:00

267 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 rb.godwinsystems.com/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.
Provided as-is, without warranty. This runs in your session via `iex` —
read it before you run it. You are responsible for the systems you run
it on. See LICENSE.
#>
$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"
}