Files
admin/scripts/windows-host-fallback-backup-daily/run.ps1
vitya 73ad6dd06a scripts(backup-notifications): unify push + email format across VDS/RUVDS/windows-host
3 host-pipelines (VDS bash, RUVDS+windows-host ps1) had drifted formats:
ntfy title `VDS backup OK $D` vs `RUVDS backup OK ($D)`, tags
`white_check_mark` vs `green_circle`, email subject `[VDS] backup OK` vs
`RUVDS backup -- SUCCESS`. Phone-side фильтрация и desktop reading
ломались за счёт inconsistency.

Unified to:
- ntfy push: title `<HOST> backup OK <date>`, body
  `<duration_human>, size=<>, snapshots=<>, dest=kreknin:<>`,
  tags `green_circle` (OK) / `red_circle` (FAILED).
- email: subject `[<HOST>] backup <STATUS> <date>` (STATUS=OK|FAILED),
  body — structured Date/Duration/Size/Snapshots/Source/Dest/Components/Log.
  Failure body extends with `Tail (last 40 lines)`.

Also imports VDS `run.sh` into repo as `scripts/vds-backup-rsync-kreknin/`
— closes drift из общего `scripts/<slug>/` pattern (RUVDS+windows-host
уже жили там; VDS жил только на /opt/stacks/backup/scripts/).

Deploy status:
- VDS: deployed via scp + sudo install, sha256=27b09ca272bb, smoke ntfy+email ✓
- RUVDS: deployed via scp + Move-Item, sha256=f3bb57a86af5, smoke ntfy+email ✓
- windows-host: deploy.ps1 + smoke-notify.ps1 готовы в scripts/, **pending
  elevated PS у user'а** (ACL=SYSTEM+Administrators, не пишется без UAC).

Spec + decisions + completed: .tasks/unify-backup-notifications.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:19:15 +03:00

237 lines
10 KiB
PowerShell

#requires -Version 5.1
<#
.SYNOPSIS
Windows-host fallback backup → kreknin (rclone SFTP).
Run via ScheduledTask 'WindowsHost-Backup-Daily' daily 03:00 MSK as SYSTEM.
.DESCRIPTION
Components (5):
1. IIS sites: C:\sites\
2. MSSQL container: docker exec BACKUP DATABASE x5 -> docker cp -> sync
3. MinIO container data: C:\Users\vitya\projects\docker\diskstation\minio\data\
4. Traefik config + acme.json: C:\Users\vitya\projects\docker\diskstation\traefik\
5. IIS config: applicationHost.config + Backup-WebConfiguration export
Retention: keep 7 daily snapshots on kreknin (rclone purge step).
Notifications: ntfy (vds-backup topic) + email (Yandex SMTP 587 STARTTLS).
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$today = Get-Date -Format 'yyyy-MM-dd'
$start = Get-Date
$base = 'C:\ProgramData\backup'
$logDir = "$base\logs"
$logFile = "$logDir\$today.log"
$rcloneExe = 'C:\ProgramData\backup\rclone.exe'
$rcloneCfg = "$base\rclone.conf"
$remoteBase = "kreknin:NetBackup/windows-host"
$remoteToday = "$remoteBase/$today"
$HostLabel = 'windows-host'
$SourceDisplay = "windows-host ($env:COMPUTERNAME / 94.19.247.14)"
$DestDisplay = "kreknin:/volume1/NetBackup/windows-host/$today/"
function Format-Duration([int]$sec) {
'{0}m{1:D2}s' -f ([int]($sec / 60)), ($sec % 60)
}
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
Start-Transcript -Path $logFile -Append -Force | Out-Null
$cfg = @{}
if (Test-Path "$base\config.env") {
Get-Content "$base\config.env" | Where-Object { $_ -match '^[A-Z_]+=' } | ForEach-Object {
$kv = $_ -split '=', 2
$cfg[$kv[0]] = $kv[1]
}
}
function Notify-Ntfy($title, $msg, $priority='default', $tags='') {
try {
if (-not $cfg.NTFY_URL -or -not $cfg.NTFY_USER -or -not $cfg.NTFY_PASS) { return }
$pair = "$($cfg.NTFY_USER):$($cfg.NTFY_PASS)"
$auth = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair))
$topic = if ($cfg.NTFY_TOPIC) { $cfg.NTFY_TOPIC } else { 'vds-backup' }
Invoke-RestMethod -Uri "$($cfg.NTFY_URL)/$topic" -Method POST `
-Headers @{ Authorization=$auth; Title=$title; Priority=$priority; Tags=$tags } `
-Body $msg -ContentType 'text/plain' -ErrorAction SilentlyContinue | Out-Null
} catch { Write-Host " ntfy WARNING: $($_.Exception.Message)" -ForegroundColor Yellow }
}
function Notify-Email($subject, $body) {
try {
if (-not $cfg.SMTP_HOST -or -not $cfg.SMTP_USER -or -not $cfg.SMTP_PASS -or -not $cfg.OPS_NOTIFY_EMAIL) { return }
$secpass = ConvertTo-SecureString $cfg.SMTP_PASS -AsPlainText -Force
$mailCred = New-Object PSCredential($cfg.SMTP_USER, $secpass)
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
Send-MailMessage -SmtpServer $cfg.SMTP_HOST -Port ([int]$cfg.SMTP_PORT) -UseSsl `
-Credential $mailCred `
-From $cfg.SMTP_FROM -To $cfg.OPS_NOTIFY_EMAIL `
-Subject $subject -Body $body -Encoding UTF8 `
-WarningAction SilentlyContinue
} finally { $ErrorActionPreference = $prevEAP }
} catch { Write-Host " email WARNING: $($_.Exception.Message)" -ForegroundColor Yellow }
}
function Invoke-Rclone {
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Args)
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try { return & $rcloneExe @Args 2>&1 }
finally { $ErrorActionPreference = $prevEAP }
}
try {
Write-Host "=== windows-host backup $today started at $start ==="
Write-Host "`n--- 1. IIS config snapshot ---"
Import-Module WebAdministration
$iisBackupName = "daily-$today"
if (Get-WebConfigurationBackup -Name $iisBackupName -ErrorAction SilentlyContinue) {
Remove-WebConfigurationBackup -Name $iisBackupName
}
$iisBackupDir = "$env:SystemRoot\System32\inetsrv\backup\$iisBackupName"
if (Test-Path $iisBackupDir) { Remove-Item $iisBackupDir -Recurse -Force }
Backup-WebConfiguration -Name $iisBackupName | Out-Null
Write-Host " $iisBackupDir created"
Write-Host "`n--- 2. MSSQL container backups ---"
$mssqlLocalDir = "$base\mssql-$today"
if (Test-Path $mssqlLocalDir) { Remove-Item $mssqlLocalDir -Recurse -Force }
New-Item -ItemType Directory -Path $mssqlLocalDir -Force | Out-Null
$saPass = $cfg.MSSQL_SA_PASS
if (-not $saPass) { throw "MSSQL_SA_PASS not in config.env" }
$dbs = @('MoreThenCms','StayerCalculator','StayerPrice','stostayer','TireService')
foreach ($db in $dbs) {
Write-Host " BACKUP DATABASE [$db]"
$bak = "$db-$today.bak"
$sql = "BACKUP DATABASE [$db] TO DISK = N'/var/opt/mssql/backup/$bak' WITH COMPRESSION, INIT, FORMAT, NAME = '$db daily $today'"
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$out = & docker exec mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P $saPass -C -Q $sql -b 2>&1
$rc = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($rc -ne 0) { throw "BACKUP $db failed (exit $rc): $out" }
& docker cp "mssql:/var/opt/mssql/backup/$bak" "$mssqlLocalDir\$bak" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw "docker cp $bak failed" }
& docker exec mssql rm -f "/var/opt/mssql/backup/$bak" 2>&1 | Out-Null
$sz = [math]::Round((Get-Item "$mssqlLocalDir\$bak").Length / 1MB, 1)
Write-Host " -> $bak ($sz MB)"
}
Write-Host "`n--- 3. rclone sync (5 components) ---"
$rcCommon = @('--config', $rcloneCfg, '--transfers', '4', '--checkers', '8', '--stats=0')
Invoke-Rclone sync $mssqlLocalDir "$remoteToday/mssql/" @rcCommon | Out-Null
if ($LASTEXITCODE -ne 0) { throw "rclone sync mssql failed (exit $LASTEXITCODE)" }
Write-Host " mssql backups synced"
Invoke-Rclone sync 'C:\sites' "$remoteToday/sites/" @rcCommon | Out-Null
if ($LASTEXITCODE -ne 0) { throw "rclone sync sites failed (exit $LASTEXITCODE)" }
Write-Host " sites synced"
Invoke-Rclone sync 'C:\Users\vitya\projects\docker\diskstation\minio\data' "$remoteToday/minio/" @rcCommon | Out-Null
if ($LASTEXITCODE -ne 0) { throw "rclone sync minio failed (exit $LASTEXITCODE)" }
Write-Host " minio synced"
Invoke-Rclone sync 'C:\Users\vitya\projects\docker\diskstation\traefik' "$remoteToday/traefik/" @rcCommon | Out-Null
if ($LASTEXITCODE -ne 0) { throw "rclone sync traefik failed (exit $LASTEXITCODE)" }
Write-Host " traefik synced"
Invoke-Rclone copy 'C:\Windows\System32\inetsrv\config\applicationHost.config' "$remoteToday/iis-config/" @rcCommon | Out-Null
if ($LASTEXITCODE -ne 0) { throw "rclone copy applicationHost.config failed" }
Invoke-Rclone sync $iisBackupDir "$remoteToday/iis-backup-webconfiguration/" @rcCommon | Out-Null
if ($LASTEXITCODE -ne 0) { throw "rclone sync iis-backup-webconfiguration failed" }
Write-Host " iis-config + iis-backup-webconfiguration synced"
Write-Host "`n--- 4. Retention prune (keep last 7) ---"
try {
$lsdOut = Invoke-Rclone lsd $remoteBase --config $rcloneCfg
$existing = $lsdOut | ForEach-Object {
$line = "$_".Trim()
if ($line -match '\s(\d{4}-\d{2}-\d{2})\s*$') { $Matches[1] }
} | Sort-Object -Unique
$toPrune = @($existing | Select-Object -SkipLast 7)
foreach ($d in $toPrune) {
Write-Host " pruning $d"
Invoke-Rclone purge "$remoteBase/$d" --config $rcloneCfg | Out-Null
}
Write-Host " kept $([math]::Min(@($existing).Count, 7)) snapshots; pruned $(@($toPrune).Count)"
} catch {
Write-Host " retention prune WARNING: $($_.Exception.Message)" -ForegroundColor Yellow
}
Remove-Item $mssqlLocalDir -Recurse -Force -ErrorAction SilentlyContinue
$durationSec = [int](New-TimeSpan -Start $start -End (Get-Date)).TotalSeconds
$durationHuman = Format-Duration $durationSec
$sitesBytes = (Get-ChildItem 'C:\sites' -Recurse -File -EA SilentlyContinue | Measure-Object Length -Sum).Sum
$minioBytes = (Get-ChildItem 'C:\Users\vitya\projects\docker\diskstation\minio\data' -Recurse -File -EA SilentlyContinue | Measure-Object Length -Sum).Sum
$sizeStr = '{0:N2} GB' -f (($sitesBytes + $minioBytes) / 1GB)
$snapshotCount = if ($existing) { @($existing).Count } else { 1 }
# ntfy push (single line)
$ntfyBody = "$durationHuman, size=$sizeStr, snapshots=$snapshotCount, dest=$DestDisplay"
Notify-Ntfy "$HostLabel backup OK $today" $ntfyBody 'default' 'green_circle'
# email (structured)
$emailBody = @"
$HostLabel daily backup completed successfully.
Date: $today
Duration: $durationHuman
Size: $sizeStr
Snapshots: $snapshotCount
Source: $SourceDisplay
Dest: $DestDisplay
Components:
- mssql (5 DBs: MoreThenCms, StayerCalculator, StayerPrice, stostayer, TireService)
- sites (C:\sites\)
- minio (data dir)
- traefik (config + acme.json)
- iis-config (applicationHost.config) + iis-backup-webconfiguration
Log: $logFile
"@
Notify-Email "[$HostLabel] backup OK $today" $emailBody
Write-Host "`n=== DONE in $durationHuman ==="
} catch {
$err = $_.Exception.Message
Write-Host "`n=== FAILED: $err ===" -ForegroundColor Red
Write-Host $_.ScriptStackTrace
$durationSec = [int](New-TimeSpan -Start $start -End (Get-Date)).TotalSeconds
$durationHuman = Format-Duration $durationSec
$tailLog = ''
try { $tailLog = (Get-Content $logFile -Tail 40 -EA SilentlyContinue) -join "`n" } catch {}
# ntfy push (single line)
Notify-Ntfy "$HostLabel backup FAILED $today" "After $durationHuman`: $err. See $logFile" 'high' 'red_circle'
# email (structured)
$emailBody = @"
$HostLabel daily backup FAILED.
Date: $today
Duration: $durationHuman
Error: $err
Source: $SourceDisplay
Log: $logFile
Tail (last 40 lines):
$tailLog
"@
Notify-Email "[$HostLabel] backup FAILED $today" $emailBody
Stop-Transcript | Out-Null
exit 1
} finally {
try { Stop-Transcript | Out-Null } catch {}
}