cb0c5b4614
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
267 lines
12 KiB
PowerShell
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"
|
|
}
|