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
152 lines
6.8 KiB
PowerShell
152 lines
6.8 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Read-only health check of the latest Open Dental cold backup: freshness,
|
|
completeness, and size sanity. Optionally pings a dead-man's-switch heartbeat
|
|
URL on success so an external monitor alerts when a backup silently stops.
|
|
|
|
.DESCRIPTION
|
|
Points at the backup destination root (<DEST>) produced by od-db-backup.ps1,
|
|
finds the newest od-backup-<timestamp> folder, and verifies it is:
|
|
- FRESH : written within the max-age window (default 24h)
|
|
- COMPLETE : has data\, (optionally) OpenDentImages\, MANIFEST.txt, and a
|
|
backup.log that ends in success with no ERROR/CRITICAL lines
|
|
- SANE SIZE : data\ isn't near-empty, and isn't a fraction of the prior run
|
|
Prints a clear PASS/FAIL summary, appends a line to <DEST>\backup-check.log,
|
|
and — if healthy and a heartbeat URL was given — pings it. Pinging ONLY on
|
|
success is deliberate: if this check fails, crashes, or the whole server is
|
|
off, no ping arrives and the external monitor raises the alarm. "No news" is
|
|
never mistaken for good news.
|
|
|
|
Fully READ-ONLY: it does not touch the database, the service, or the backup
|
|
files. Safe to schedule and safe to run ad hoc.
|
|
|
|
.NOTES
|
|
Manual spot-check:
|
|
irm rb.godwinsystems.com/scripts/od-backup-check.ps1 | iex
|
|
|
|
For unattended monitoring, schedule a filled-in LOCAL copy (with <DEST> and the
|
|
heartbeat URL baked in) — keep that copy in the private tier, not here. See
|
|
od/backup-schedule.md.
|
|
|
|
iex-safe: uses `return`, never `exit` (which would close an interactive
|
|
session). PUBLIC REPO: no client specifics hard-coded; prompt 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'
|
|
|
|
Write-Host '== Open Dental: backup health check (read-only) ==' -ForegroundColor Cyan
|
|
|
|
# --- Prompts (no param block; iex-safe) ---
|
|
$destRoot = Read-Host 'Backup destination root (<DEST>, e.g. E:\OD-Backups)'
|
|
if (-not (Test-Path $destRoot)) {
|
|
Write-Warning "Destination not found: $destRoot"
|
|
return
|
|
}
|
|
$maxAgeInput = Read-Host 'Max age (hours) for a healthy backup [24]'
|
|
$maxAgeHrs = if ($maxAgeInput -match '^\d+$') { [int]$maxAgeInput } else { 24 }
|
|
$expectImages = (Read-Host 'Should each backup include an OpenDentImages folder? (Y/n)') -ne 'n'
|
|
$heartbeat = Read-Host 'Heartbeat/ping URL to hit on SUCCESS (<HEARTBEAT_URL>) [blank to skip]'
|
|
|
|
$problems = New-Object System.Collections.Generic.List[string]
|
|
function Fail($msg) { $problems.Add($msg) }
|
|
|
|
function Get-DirSizeBytes($path) {
|
|
if (-not (Test-Path $path)) { return $null }
|
|
(Get-ChildItem -LiteralPath $path -Recurse -Force -File -ErrorAction SilentlyContinue |
|
|
Measure-Object -Property Length -Sum).Sum
|
|
}
|
|
|
|
# --- Find the newest backup folder ---
|
|
$folders = @(Get-ChildItem -LiteralPath $destRoot -Directory -Filter 'od-backup-*' -ErrorAction SilentlyContinue |
|
|
Sort-Object LastWriteTime -Descending)
|
|
if ($folders.Count -eq 0) {
|
|
Fail "No od-backup-* folders found under $destRoot."
|
|
}
|
|
else {
|
|
$latest = $folders[0]
|
|
Write-Host "Latest backup: $($latest.Name) ($($latest.LastWriteTime))" -ForegroundColor Green
|
|
|
|
# 1) Freshness
|
|
$ageHrs = [math]::Round(((Get-Date) - $latest.LastWriteTime).TotalHours, 1)
|
|
if ($ageHrs -gt $maxAgeHrs) {
|
|
Fail "Stale: newest backup is $ageHrs h old (limit $maxAgeHrs h). Backup may have stopped running."
|
|
}
|
|
if (((Get-Date) - $latest.LastWriteTime).TotalMinutes -lt 5) {
|
|
Write-Warning 'Newest folder was written <5 min ago — a backup may be in progress. Re-check after it finishes.'
|
|
}
|
|
|
|
# 2) Completeness
|
|
$dataDir = Join-Path $latest.FullName 'data'
|
|
$imgDir = Join-Path $latest.FullName 'OpenDentImages'
|
|
$manifest = Join-Path $latest.FullName 'MANIFEST.txt'
|
|
$logFile = Join-Path $latest.FullName 'backup.log'
|
|
|
|
if (-not (Test-Path $dataDir)) { Fail "Missing data\ directory in $($latest.Name)." }
|
|
if (-not (Test-Path $manifest)) { Fail "Missing MANIFEST.txt (backup may not have completed) in $($latest.Name)." }
|
|
if ($expectImages -and -not (Test-Path $imgDir)) {
|
|
Fail "Missing OpenDentImages\ in $($latest.Name) — a DB-only backup loses all documents."
|
|
}
|
|
if (Test-Path $logFile) {
|
|
$tail = Get-Content -LiteralPath $logFile -Tail 40 -ErrorAction SilentlyContinue
|
|
if ($tail -match 'ERROR|CRITICAL') { Fail "backup.log contains ERROR/CRITICAL lines in $($latest.Name)." }
|
|
if (-not ($tail -match 'completed successfully')) {
|
|
Fail "backup.log does not show a successful completion in $($latest.Name)."
|
|
}
|
|
} else {
|
|
Fail "Missing backup.log in $($latest.Name)."
|
|
}
|
|
|
|
# 3) Size sanity
|
|
$dataBytes = Get-DirSizeBytes $dataDir
|
|
if ($null -ne $dataBytes) {
|
|
$dataMB = [math]::Round($dataBytes / 1MB, 1)
|
|
Write-Host " data\ size: $dataMB MB"
|
|
if ($dataBytes -lt 1MB) { Fail "data\ is only $dataMB MB — implausibly small; likely a broken/partial backup." }
|
|
if ($folders.Count -gt 1) {
|
|
$prevData = Get-DirSizeBytes (Join-Path $folders[1].FullName 'data')
|
|
if ($prevData -and $dataBytes -lt ($prevData * 0.5)) {
|
|
Fail ("data\ shrank to {0}% of the previous run ({1} MB -> {2} MB) — possible truncation." -f `
|
|
[math]::Round($dataBytes / $prevData * 100), [math]::Round($prevData/1MB,1), $dataMB)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Report ---
|
|
$healthy = ($problems.Count -eq 0)
|
|
$stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
Write-Host ''
|
|
if ($healthy) {
|
|
Write-Host "PASS Backup is healthy as of $stamp." -ForegroundColor Green
|
|
} else {
|
|
Write-Host "FAIL Backup health problems:" -ForegroundColor Red
|
|
$problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
|
}
|
|
|
|
# On-disk trail at the destination
|
|
try {
|
|
$line = "$stamp $(if($healthy){'PASS'}else{'FAIL'}) $(if($healthy){'healthy'}else{$problems -join ' | '})"
|
|
Add-Content -LiteralPath (Join-Path $destRoot 'backup-check.log') -Value $line
|
|
} catch { Write-Warning "Could not write backup-check.log: $($_.Exception.Message)" }
|
|
|
|
# --- Dead-man's-switch heartbeat: ping ONLY on success ---
|
|
if ($heartbeat) {
|
|
if ($healthy) {
|
|
try {
|
|
Invoke-WebRequest -Uri $heartbeat -UseBasicParsing -TimeoutSec 20 -Method Get | Out-Null
|
|
Write-Host 'Heartbeat ping sent.' -ForegroundColor Green
|
|
} catch {
|
|
Write-Warning "Heartbeat ping failed: $($_.Exception.Message) (monitor will alarm on the missing check-in)."
|
|
}
|
|
} else {
|
|
Write-Host 'Unhealthy — NOT pinging heartbeat. The external monitor should alert on the missing ping.' -ForegroundColor Yellow
|
|
Write-Host '(healthchecks.io users: append /fail to the URL for an immediate alert.)'
|
|
}
|
|
}
|
|
|
|
if (-not $healthy) { Write-Warning 'Investigate now — see od/backup-schedule.md triage.' }
|