f2a979f047
The repo is public and files are fetched by raw URL, so a reader who lands on one runbook never sees the README -- the repo's context does not travel with the file. Each .md now carries two lines under the title, each .ps1 the equivalent at the end of its .NOTES block. Deliberately two lines, not a paragraph. These files are read through `| more` on a client console mid-incident, and the top of the file is where the procedure-specific warnings live -- never a live chart, stop the service before copying, confirm authorization before acting. A legal preamble above those competes with them and trains people to skip past. Wording aims at a stranger who found the repo, not at the quality of the procedure: these double as documented-procedure evidence for E&O, and language implying the content is unreliable works against that. MIT rather than no license: the warranty and liability disclaimer is the part that does the work, and leaving it unlicensed makes reuse ambiguous rather than disclaimed. Also fixes 5 stale ops/rb URLs in scripts/*.ps1 that the previous commit missed -- it only swept the .md files. 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"
|
|
}
|