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>
210 lines
8.3 KiB
PowerShell
210 lines
8.3 KiB
PowerShell
#requires -Version 5.1
|
|
[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:\Program Files\rclone\rclone.exe'
|
|
$rcloneCfg = "$base\rclone.conf"
|
|
$remoteBase = "kreknin:NetBackup/ruvds-iis"
|
|
$remoteToday = "$remoteBase/$today"
|
|
|
|
$HostLabel = 'RUVDS'
|
|
$SourceDisplay = "RUVDS ($env:COMPUTERNAME / 80.64.31.36)"
|
|
$DestDisplay = "kreknin:/volume1/NetBackup/ruvds-iis/$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 "=== RUVDS backup $today started at $start ==="
|
|
|
|
Write-Host "`n--- 1. IIS config snapshot ---"
|
|
Import-Module WebAdministration
|
|
$iisBackupName = "daily-$today"
|
|
$iisBackupDir = "$env:SystemRoot\System32\inetsrv\backup\$iisBackupName"
|
|
if (Get-WebConfigurationBackup -Name $iisBackupName -ErrorAction SilentlyContinue) {
|
|
Remove-WebConfigurationBackup -Name $iisBackupName
|
|
}
|
|
if (Test-Path $iisBackupDir) { Remove-Item $iisBackupDir -Recurse -Force }
|
|
Backup-WebConfiguration -Name $iisBackupName | Out-Null
|
|
Write-Host " $iisBackupDir created"
|
|
|
|
Write-Host "`n--- 2. Cert store export ---"
|
|
$certDir = "$base\certs-$today"
|
|
if (Test-Path $certDir) { Remove-Item $certDir -Recurse -Force }
|
|
New-Item -ItemType Directory -Path $certDir -Force | Out-Null
|
|
$pfxPass = ConvertTo-SecureString 'ruvds-backup-pfx' -AsPlainText -Force
|
|
$exported = 0
|
|
Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.HasPrivateKey } | ForEach-Object {
|
|
$cn = ($_.Subject -split ',')[0] -replace 'CN=','' -replace '[^A-Za-z0-9.-]','_'
|
|
if ($cn.Length -gt 60) { $cn = $cn.Substring(0,60) }
|
|
try {
|
|
Export-PfxCertificate -Cert $_ -FilePath "$certDir\$cn-$($_.Thumbprint.Substring(0,8)).pfx" -Password $pfxPass -ErrorAction SilentlyContinue | Out-Null
|
|
$exported++
|
|
} catch {}
|
|
}
|
|
Write-Host " exported $exported certs"
|
|
|
|
Write-Host "`n--- 3. rclone sync ---"
|
|
$rcCommon = @('--config', $rcloneCfg, '--transfers', '4', '--checkers', '8', '--stats=0')
|
|
|
|
Invoke-Rclone sync C:\sites\snolla "$remoteToday/sites/snolla/" @rcCommon | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "rclone sync snolla failed (exit $LASTEXITCODE)" }
|
|
Write-Host " snolla 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 (exit $LASTEXITCODE)" }
|
|
Write-Host " applicationHost.config copied"
|
|
|
|
Invoke-Rclone sync $iisBackupDir "$remoteToday/iis-backup-webconfiguration/" @rcCommon | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "rclone sync iis-backup-webconfiguration failed (exit $LASTEXITCODE)" }
|
|
Write-Host " iis-backup-webconfiguration synced"
|
|
|
|
Invoke-Rclone sync $certDir "$remoteToday/certs/" @rcCommon | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "rclone sync certs failed (exit $LASTEXITCODE)" }
|
|
Write-Host " certs synced"
|
|
|
|
Invoke-Rclone sync C:\ProgramData\ssh "$remoteToday/ssh-config/" @rcCommon | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "rclone sync ssh-config failed (exit $LASTEXITCODE)" }
|
|
Write-Host " ssh-config 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 $certDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
|
|
$durationSec = [int](New-TimeSpan -Start $start -End (Get-Date)).TotalSeconds
|
|
$durationHuman = Format-Duration $durationSec
|
|
$srcBytes = (Get-ChildItem 'C:\sites\snolla' -Recurse -File -EA SilentlyContinue | Measure-Object Length -Sum).Sum
|
|
$sizeStr = '{0:N2} GB' -f ($srcBytes / 1GB)
|
|
$snapshotCount = if ($existing) { @($existing).Count } else { 1 }
|
|
|
|
# ntfy push (single line)
|
|
$ntfyBody = "$durationHuman, size=$sizeStr, snapshots=$snapshotCount, dest=$DestDisplay, certs=$exported"
|
|
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:
|
|
- sites/snolla
|
|
- iis-config (applicationHost.config)
|
|
- iis-backup-webconfiguration
|
|
- certs ($exported PFX exports)
|
|
- ssh-config
|
|
|
|
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 {}
|
|
}
|
|
|