Add offsite backup sync and scheduler
This commit is contained in:
parent
6dcd37fe31
commit
69958db930
8 changed files with 630 additions and 4 deletions
|
|
@ -50,3 +50,11 @@ INDEXED_CHANNEL_IDS=
|
|||
EXCLUDED_CHANNEL_IDS=
|
||||
EXCLUDED_CHANNEL_NAMES=
|
||||
WORKER_POLL_INTERVAL_MS=5000
|
||||
OFFSITE_BACKUP_SSH_TARGET=
|
||||
OFFSITE_BACKUP_REMOTE_WINDOWS_DIR=C:\Meta\voidbot-backups
|
||||
OFFSITE_BACKUP_REMOTE_SFTP_DIR=/C:/Meta/voidbot-backups
|
||||
OFFSITE_BACKUP_LOCAL_KEEP_LATEST=7
|
||||
OFFSITE_BACKUP_REMOTE_KEEP_LATEST=14
|
||||
OFFSITE_BACKUP_LABEL=offsite-auto
|
||||
OFFSITE_BACKUP_TASK_NAME=VoidBot Offsite Backup
|
||||
OFFSITE_BACKUP_TASK_TIME=04:30
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -418,6 +418,41 @@ npm run state:restore -- -BackupPath E:\Projects\VoidBot\.voidbot\backups\<times
|
|||
|
||||
The backup and restore scripts stop the local bot/worker first and restart them afterward if they were already running. Because the local Qdrant node is shared, the backup path snapshots every collection on that node, not just the two VoidBot ones.
|
||||
|
||||
For the next rung up from "local recovery only", VoidBot can also push nightly backup archives to an SSH-reachable offsite box:
|
||||
|
||||
```bash
|
||||
npm run state:sync-offsite
|
||||
```
|
||||
|
||||
That flow:
|
||||
|
||||
- runs the normal local backup first
|
||||
- compresses the resulting backup directory into a zip archive
|
||||
- uploads it over `sftp`
|
||||
- keeps a bounded number of scheduled local backup directories
|
||||
- prunes older remote zip archives after upload
|
||||
- writes status to `.voidbot/status/offsite-backup.json`
|
||||
- appends logs to `.voidbot/logs/offsite-backup.log`
|
||||
|
||||
Install the daily scheduled task:
|
||||
|
||||
```bash
|
||||
npm run state:install-offsite-task
|
||||
```
|
||||
|
||||
Defaults come from `.env` / `.env.example`:
|
||||
|
||||
- `OFFSITE_BACKUP_SSH_TARGET`
|
||||
- `OFFSITE_BACKUP_REMOTE_WINDOWS_DIR`
|
||||
- `OFFSITE_BACKUP_REMOTE_SFTP_DIR`
|
||||
- `OFFSITE_BACKUP_LOCAL_KEEP_LATEST`
|
||||
- `OFFSITE_BACKUP_REMOTE_KEEP_LATEST`
|
||||
- `OFFSITE_BACKUP_LABEL`
|
||||
- `OFFSITE_BACKUP_TASK_NAME`
|
||||
- `OFFSITE_BACKUP_TASK_TIME`
|
||||
|
||||
The scheduled task uses `Interactive` logon on Windows so it can see the current user's SSH key material. That means the backup job is intended for a workstation session that stays logged in; it is not pretending to be a hardened headless backup appliance.
|
||||
|
||||
## Commands
|
||||
|
||||
Current slash commands:
|
||||
|
|
@ -462,7 +497,7 @@ Useful local state and docs:
|
|||
Reasonable next steps from here:
|
||||
|
||||
1. Add health checks, backups, and operational guidance around Postgres and Qdrant.
|
||||
2. Automate recurring and off-box retention for Postgres, Qdrant, and archive backups instead of relying on manual local runs.
|
||||
2. Add periodic restore drills and remote retention monitoring so the offsite backup path stays real instead of ceremonial.
|
||||
3. Expand worker processing into richer provider run records and moderation hooks.
|
||||
4. Replace the remaining scaffolded provider paths with funded, production-grade implementations.
|
||||
5. Add real constrained sandbox execution instead of policy-only dry runs.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Discord Gateway
|
|||
|
||||
## Upgrade path
|
||||
|
||||
1. Automate recurring and off-box retention for the new Postgres/Qdrant/manual-archive backup path.
|
||||
1. Add restore drills and retention monitoring around the new local-plus-offsite backup path.
|
||||
2. Add moderation, budgeting, and rate limits before enabling `openai_api` for member traffic.
|
||||
3. Expand worker-side run records and admin tooling around the interaction memory/event stores.
|
||||
4. Expand sandbox execution from dry-run policy checks to real constrained runners.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"stack:start": "powershell -ExecutionPolicy Bypass -File scripts/start-voidbot-stack.ps1",
|
||||
"state:backup": "powershell -ExecutionPolicy Bypass -File scripts/backup-voidbot-state.ps1",
|
||||
"state:restore": "powershell -ExecutionPolicy Bypass -File scripts/restore-voidbot-state.ps1",
|
||||
"state:sync-offsite": "powershell -ExecutionPolicy Bypass -File scripts/sync-voidbot-backup-offsite.ps1",
|
||||
"state:install-offsite-task": "powershell -ExecutionPolicy Bypass -File scripts/install-offsite-backup-task.ps1",
|
||||
"postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d",
|
||||
"codex:mcp:install": "tsx scripts/install-codex-mcp.ts",
|
||||
"codex:mcp:remove": "tsx scripts/remove-codex-mcp.ts",
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ function Invoke-CurlJson {
|
|||
[string[]] $Arguments
|
||||
)
|
||||
|
||||
$response = & curl.exe @Arguments
|
||||
$response = & curl.exe --connect-timeout 5 --max-time 30 @Arguments
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "curl.exe failed: $($Arguments -join ' ')"
|
||||
|
|
|
|||
86
scripts/install-offsite-backup-task.ps1
Normal file
86
scripts/install-offsite-backup-task.ps1
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
param(
|
||||
[string] $TaskName,
|
||||
[string] $TaskTime,
|
||||
[switch] $RunNow
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$envFile = Join-Path $repoRoot ".env"
|
||||
$syncScript = Join-Path $PSScriptRoot "sync-voidbot-backup-offsite.ps1"
|
||||
|
||||
function Read-DotEnv {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
throw "Missing .env at $Path"
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
|
||||
foreach ($line in Get-Content -Path $Path) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($line.TrimStart().StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$separatorIndex = $line.IndexOf("=")
|
||||
|
||||
if ($separatorIndex -lt 1) {
|
||||
continue
|
||||
}
|
||||
|
||||
$key = $line.Substring(0, $separatorIndex).Trim()
|
||||
$value = $line.Substring($separatorIndex + 1)
|
||||
$values[$key] = $value
|
||||
}
|
||||
|
||||
return $values
|
||||
}
|
||||
|
||||
$config = Read-DotEnv -Path $envFile
|
||||
$taskNameValue = if (-not [string]::IsNullOrWhiteSpace($TaskName)) {
|
||||
$TaskName
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_TASK_NAME") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_TASK_NAME"])) {
|
||||
$config["OFFSITE_BACKUP_TASK_NAME"]
|
||||
} else {
|
||||
"VoidBot Offsite Backup"
|
||||
}
|
||||
$taskTimeValue = if (-not [string]::IsNullOrWhiteSpace($TaskTime)) {
|
||||
$TaskTime
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_TASK_TIME") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_TASK_TIME"])) {
|
||||
$config["OFFSITE_BACKUP_TASK_TIME"]
|
||||
} else {
|
||||
"04:30"
|
||||
}
|
||||
|
||||
if (-not ($config.ContainsKey("OFFSITE_BACKUP_SSH_TARGET")) -or [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_SSH_TARGET"])) {
|
||||
throw "OFFSITE_BACKUP_SSH_TARGET must be configured in .env before installing the scheduled task."
|
||||
}
|
||||
|
||||
$runAt = [DateTime]::ParseExact($taskTimeValue, "HH:mm", [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$syncScript`""
|
||||
$trigger = New-ScheduledTaskTrigger -Daily -At $runAt
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
||||
$description = "Runs VoidBot offsite backup sync to the Qwen box. Interactive logon required so the SSH key material is available."
|
||||
|
||||
Register-ScheduledTask -TaskName $taskNameValue -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description $description -Force | Out-Null
|
||||
|
||||
Write-Host "Installed scheduled task: $taskNameValue"
|
||||
Write-Host "Schedule: daily at $taskTimeValue"
|
||||
Write-Host "Task runs as: $($env:USERDOMAIN)\$($env:USERNAME)"
|
||||
Write-Host "Logon mode: Interactive"
|
||||
|
||||
if ($RunNow) {
|
||||
Start-ScheduledTask -TaskName $taskNameValue
|
||||
Write-Host "Started task immediately."
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ function Invoke-CurlJson {
|
|||
[string[]] $Arguments
|
||||
)
|
||||
|
||||
$response = & curl.exe @Arguments
|
||||
$response = & curl.exe --connect-timeout 5 --max-time 30 @Arguments
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "curl.exe failed: $($Arguments -join ' ')"
|
||||
|
|
|
|||
495
scripts/sync-voidbot-backup-offsite.ps1
Normal file
495
scripts/sync-voidbot-backup-offsite.ps1
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
param(
|
||||
[string] $SshTarget,
|
||||
[string] $RemoteWindowsDir,
|
||||
[string] $RemoteSftpDir,
|
||||
[int] $LocalKeepLatest,
|
||||
[int] $RemoteKeepLatest,
|
||||
[string] $BackupLabel,
|
||||
[switch] $NoRestart
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$envFile = Join-Path $repoRoot ".env"
|
||||
$backupScript = Join-Path $PSScriptRoot "backup-voidbot-state.ps1"
|
||||
|
||||
function Read-DotEnv {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
throw "Missing .env at $Path"
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
|
||||
foreach ($line in Get-Content -Path $Path) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($line.TrimStart().StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$separatorIndex = $line.IndexOf("=")
|
||||
|
||||
if ($separatorIndex -lt 1) {
|
||||
continue
|
||||
}
|
||||
|
||||
$key = $line.Substring(0, $separatorIndex).Trim()
|
||||
$value = $line.Substring($separatorIndex + 1)
|
||||
$values[$key] = $value
|
||||
}
|
||||
|
||||
return $values
|
||||
}
|
||||
|
||||
function Resolve-ConfigPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $RepoRoot,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyString()]
|
||||
[string] $Value,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Fallback
|
||||
)
|
||||
|
||||
$raw = if ([string]::IsNullOrWhiteSpace($Value)) { $Fallback } else { $Value }
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $raw))
|
||||
}
|
||||
|
||||
function Write-StatusFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable] $Status
|
||||
)
|
||||
|
||||
$directory = Split-Path -Parent $Path
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
$Status | ConvertTo-Json -Depth 8 | Set-Content -Path $Path -Encoding utf8
|
||||
}
|
||||
|
||||
function Start-LogCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $LogPath
|
||||
)
|
||||
|
||||
$directory = Split-Path -Parent $LogPath
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
|
||||
try {
|
||||
Start-Transcript -Path $LogPath -Append | Out-Null
|
||||
return $true
|
||||
} catch {
|
||||
Write-Warning "Failed to start transcript logging at ${LogPath}: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-LogCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool] $Started
|
||||
)
|
||||
|
||||
if (-not $Started) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Stop-Transcript | Out-Null
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-WindowsPathToSftpPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $WindowsPath
|
||||
)
|
||||
|
||||
if ($WindowsPath -notmatch "^[A-Za-z]:\\") {
|
||||
throw "Remote Windows path must start with a drive letter: $WindowsPath"
|
||||
}
|
||||
|
||||
$drive = $WindowsPath.Substring(0, 1).ToUpperInvariant()
|
||||
$remainder = $WindowsPath.Substring(2).Replace("\", "/").TrimStart("/")
|
||||
return "/$($drive):/$remainder"
|
||||
}
|
||||
|
||||
function Get-LatestBackupDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BackupRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Label
|
||||
)
|
||||
|
||||
$pattern = "*-$Label"
|
||||
$latest = Get-ChildItem -Path $BackupRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like $pattern } |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $latest) {
|
||||
throw "No backup directories matching $pattern were found under $BackupRoot."
|
||||
}
|
||||
|
||||
return $latest
|
||||
}
|
||||
|
||||
function Invoke-RemotePowerShell {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Target,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Script
|
||||
)
|
||||
|
||||
$bytes = [System.Text.Encoding]::Unicode.GetBytes($Script)
|
||||
$encoded = [Convert]::ToBase64String($bytes)
|
||||
$output = & ssh $Target powershell -NoProfile -NonInteractive -EncodedCommand $encoded
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Remote PowerShell command failed on $Target."
|
||||
}
|
||||
|
||||
return ($output -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Ensure-RemoteDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Target,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $RemoteWindowsDir
|
||||
)
|
||||
|
||||
$escapedDir = $RemoteWindowsDir.Replace("'", "''")
|
||||
$script = @'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
New-Item -ItemType Directory -Force -Path '__REMOTE_DIR__' | Out-Null
|
||||
'@.Replace("__REMOTE_DIR__", $escapedDir)
|
||||
[void](Invoke-RemotePowerShell -Target $Target -Script $script)
|
||||
}
|
||||
|
||||
function Get-RemoteFileInfo {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Target,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $RemoteWindowsPath
|
||||
)
|
||||
|
||||
$escapedPath = $RemoteWindowsPath.Replace("'", "''")
|
||||
$script = @'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$item = Get-Item -LiteralPath '__REMOTE_PATH__'
|
||||
[pscustomobject]@{
|
||||
name = $item.Name
|
||||
size = $item.Length
|
||||
lastWriteTimeUtc = $item.LastWriteTimeUtc.ToString('o')
|
||||
} | ConvertTo-Json -Compress
|
||||
'@.Replace("__REMOTE_PATH__", $escapedPath)
|
||||
$output = Invoke-RemotePowerShell -Target $Target -Script $script
|
||||
return $output | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Upload-WithSftp {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Target,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $LocalFile,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $RemoteSftpPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BatchFilePath
|
||||
)
|
||||
|
||||
$localForSftp = $LocalFile.Replace("\", "/")
|
||||
$content = @(
|
||||
"put `"$localForSftp`" $RemoteSftpPath",
|
||||
"bye"
|
||||
)
|
||||
Set-Content -Path $BatchFilePath -Value $content -Encoding ascii
|
||||
|
||||
try {
|
||||
& sftp -b $BatchFilePath $Target
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "sftp upload failed for $LocalFile."
|
||||
}
|
||||
} finally {
|
||||
Remove-Item $BatchFilePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Prune-RemoteBackups {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Target,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $RemoteWindowsDir,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $KeepLatest
|
||||
)
|
||||
|
||||
if ($KeepLatest -lt 0) {
|
||||
throw "Remote keep-latest count cannot be negative."
|
||||
}
|
||||
|
||||
$escapedDir = $RemoteWindowsDir.Replace("'", "''")
|
||||
$script = @'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$files = Get-ChildItem -Path '__REMOTE_DIR__' -File -Filter '*.zip' | Sort-Object LastWriteTimeUtc -Descending
|
||||
$toDelete = @($files | Select-Object -Skip __KEEP_LATEST__)
|
||||
foreach ($file in $toDelete) {
|
||||
Remove-Item -LiteralPath $file.FullName -Force
|
||||
Write-Output $file.Name
|
||||
}
|
||||
'@.Replace("__REMOTE_DIR__", $escapedDir).Replace("__KEEP_LATEST__", [string]$KeepLatest)
|
||||
$output = Invoke-RemotePowerShell -Target $Target -Script $script
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($output)) {
|
||||
return @()
|
||||
}
|
||||
|
||||
return @($output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Prune-LocalBackupDirectories {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BackupRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BackupLabel,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $KeepLatest
|
||||
)
|
||||
|
||||
if ($KeepLatest -lt 0) {
|
||||
throw "Local keep-latest count cannot be negative."
|
||||
}
|
||||
|
||||
$pattern = "*-$BackupLabel"
|
||||
$toDelete = Get-ChildItem -Path $BackupRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like $pattern } |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -Skip $KeepLatest
|
||||
|
||||
$deleted = @()
|
||||
|
||||
foreach ($item in $toDelete) {
|
||||
Remove-Item -LiteralPath $item.FullName -Recurse -Force
|
||||
$deleted += $item.Name
|
||||
}
|
||||
|
||||
return $deleted
|
||||
}
|
||||
|
||||
function Prune-LocalBackupArchives {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BackupRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BackupLabel,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int] $KeepLatest
|
||||
)
|
||||
|
||||
if ($KeepLatest -lt 0) {
|
||||
throw "Local keep-latest count cannot be negative."
|
||||
}
|
||||
|
||||
$pattern = "*-$BackupLabel.zip"
|
||||
$toDelete = Get-ChildItem -Path $BackupRoot -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like $pattern } |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -Skip $KeepLatest
|
||||
|
||||
$deleted = @()
|
||||
|
||||
foreach ($item in $toDelete) {
|
||||
Remove-Item -LiteralPath $item.FullName -Force
|
||||
$deleted += $item.Name
|
||||
}
|
||||
|
||||
return $deleted
|
||||
}
|
||||
|
||||
$config = Read-DotEnv -Path $envFile
|
||||
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
|
||||
$backupRoot = Join-Path $storageRoot "backups"
|
||||
$statusPath = Join-Path $storageRoot "status\offsite-backup.json"
|
||||
$logPath = Join-Path $storageRoot "logs\offsite-backup.log"
|
||||
$sshTargetValue = if (-not [string]::IsNullOrWhiteSpace($SshTarget)) {
|
||||
$SshTarget
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_SSH_TARGET")) {
|
||||
$config["OFFSITE_BACKUP_SSH_TARGET"]
|
||||
} else {
|
||||
""
|
||||
}
|
||||
$remoteWindowsDirValue = if (-not [string]::IsNullOrWhiteSpace($RemoteWindowsDir)) {
|
||||
$RemoteWindowsDir
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_WINDOWS_DIR")) {
|
||||
$config["OFFSITE_BACKUP_REMOTE_WINDOWS_DIR"]
|
||||
} else {
|
||||
"C:\Meta\voidbot-backups"
|
||||
}
|
||||
$remoteSftpDirValue = if (-not [string]::IsNullOrWhiteSpace($RemoteSftpDir)) {
|
||||
$RemoteSftpDir
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_SFTP_DIR") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_REMOTE_SFTP_DIR"])) {
|
||||
$config["OFFSITE_BACKUP_REMOTE_SFTP_DIR"]
|
||||
} else {
|
||||
Convert-WindowsPathToSftpPath -WindowsPath $remoteWindowsDirValue
|
||||
}
|
||||
$localKeepLatestValue = if ($PSBoundParameters.ContainsKey("LocalKeepLatest")) {
|
||||
$LocalKeepLatest
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_LOCAL_KEEP_LATEST")) {
|
||||
[int]$config["OFFSITE_BACKUP_LOCAL_KEEP_LATEST"]
|
||||
} else {
|
||||
7
|
||||
}
|
||||
$remoteKeepLatestValue = if ($PSBoundParameters.ContainsKey("RemoteKeepLatest")) {
|
||||
$RemoteKeepLatest
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_KEEP_LATEST")) {
|
||||
[int]$config["OFFSITE_BACKUP_REMOTE_KEEP_LATEST"]
|
||||
} else {
|
||||
14
|
||||
}
|
||||
$backupLabelValue = if (-not [string]::IsNullOrWhiteSpace($BackupLabel)) {
|
||||
$BackupLabel
|
||||
} elseif ($config.ContainsKey("OFFSITE_BACKUP_LABEL") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_LABEL"])) {
|
||||
$config["OFFSITE_BACKUP_LABEL"]
|
||||
} else {
|
||||
"offsite-auto"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($sshTargetValue)) {
|
||||
throw "OFFSITE_BACKUP_SSH_TARGET is required for offsite backup sync."
|
||||
}
|
||||
|
||||
$status = @{
|
||||
startedAt = (Get-Date).ToString("o")
|
||||
repoRoot = $repoRoot
|
||||
status = "starting"
|
||||
sshTarget = $sshTargetValue
|
||||
remoteWindowsDir = $remoteWindowsDirValue
|
||||
remoteSftpDir = $remoteSftpDirValue
|
||||
localKeepLatest = $localKeepLatestValue
|
||||
remoteKeepLatest = $remoteKeepLatestValue
|
||||
backupLabel = $backupLabelValue
|
||||
}
|
||||
|
||||
$transcriptStarted = Start-LogCapture -LogPath $logPath
|
||||
$archivePath = $null
|
||||
|
||||
try {
|
||||
Write-StatusFile -Path $statusPath -Status $status
|
||||
|
||||
Write-Host "Running local backup first..."
|
||||
$backupArgs = @(
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
$backupScript,
|
||||
"-Label",
|
||||
$backupLabelValue
|
||||
)
|
||||
|
||||
if ($NoRestart) {
|
||||
$backupArgs += "-NoRestart"
|
||||
}
|
||||
|
||||
& powershell @backupArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Local backup script failed."
|
||||
}
|
||||
|
||||
$backupDir = Get-LatestBackupDirectory -BackupRoot $backupRoot -Label $backupLabelValue
|
||||
$backupName = $backupDir.Name
|
||||
$archivePath = Join-Path $backupRoot "$backupName.zip"
|
||||
|
||||
$status.status = "compressing"
|
||||
$status.backupDirectory = $backupDir.FullName
|
||||
$status.archivePath = $archivePath
|
||||
Write-StatusFile -Path $statusPath -Status $status
|
||||
|
||||
Write-Host "Compressing $backupName for offsite transfer..."
|
||||
& tar.exe -a -cf $archivePath -C $backupRoot $backupName
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "tar.exe failed while creating $archivePath."
|
||||
}
|
||||
|
||||
$archiveItem = Get-Item -LiteralPath $archivePath
|
||||
$status.archiveSize = $archiveItem.Length
|
||||
$status.status = "uploading"
|
||||
Write-StatusFile -Path $statusPath -Status $status
|
||||
|
||||
Write-Host "Ensuring remote directory exists..."
|
||||
Ensure-RemoteDirectory -Target $sshTargetValue -RemoteWindowsDir $remoteWindowsDirValue
|
||||
|
||||
$remoteFileName = $archiveItem.Name
|
||||
$remoteWindowsPath = Join-Path $remoteWindowsDirValue $remoteFileName
|
||||
$remoteSftpPath = "$($remoteSftpDirValue.TrimEnd('/'))/$remoteFileName"
|
||||
$batchPath = Join-Path $storageRoot "status\offsite-upload.sftp"
|
||||
|
||||
Write-Host "Uploading $remoteFileName to $sshTargetValue..."
|
||||
Upload-WithSftp -Target $sshTargetValue -LocalFile $archivePath -RemoteSftpPath $remoteSftpPath -BatchFilePath $batchPath
|
||||
|
||||
$remoteInfo = Get-RemoteFileInfo -Target $sshTargetValue -RemoteWindowsPath $remoteWindowsPath
|
||||
$status.remoteFile = @{
|
||||
path = $remoteWindowsPath
|
||||
name = $remoteInfo.name
|
||||
size = $remoteInfo.size
|
||||
lastWriteTimeUtc = $remoteInfo.lastWriteTimeUtc
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
$archivePath = $null
|
||||
|
||||
$status.status = "pruning"
|
||||
Write-StatusFile -Path $statusPath -Status $status
|
||||
|
||||
Write-Host "Pruning local scheduled backups..."
|
||||
$deletedLocalDirs = Prune-LocalBackupDirectories -BackupRoot $backupRoot -BackupLabel $backupLabelValue -KeepLatest $localKeepLatestValue
|
||||
$deletedLocalArchives = Prune-LocalBackupArchives -BackupRoot $backupRoot -BackupLabel $backupLabelValue -KeepLatest 0
|
||||
|
||||
Write-Host "Pruning remote scheduled backups..."
|
||||
$deletedRemoteArchives = Prune-RemoteBackups -Target $sshTargetValue -RemoteWindowsDir $remoteWindowsDirValue -KeepLatest $remoteKeepLatestValue
|
||||
|
||||
$status.deletedLocalBackupDirectories = $deletedLocalDirs
|
||||
$status.deletedLocalArchives = $deletedLocalArchives
|
||||
$status.deletedRemoteArchives = $deletedRemoteArchives
|
||||
$status.status = "completed"
|
||||
$status.completedAt = (Get-Date).ToString("o")
|
||||
Write-StatusFile -Path $statusPath -Status $status
|
||||
|
||||
Write-Host "Offsite backup sync complete."
|
||||
Write-Host "Latest uploaded archive: $remoteWindowsPath"
|
||||
} catch {
|
||||
$status.status = "failed"
|
||||
$status.completedAt = (Get-Date).ToString("o")
|
||||
$status.error = $_.Exception.Message
|
||||
Write-StatusFile -Path $statusPath -Status $status
|
||||
throw
|
||||
} finally {
|
||||
if ($archivePath -and (Test-Path $archivePath)) {
|
||||
Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Stop-LogCapture -Started $transcriptStarted
|
||||
}
|
||||
Loading…
Reference in a new issue