VoidBot/scripts/backup-voidbot-state.ps1
2026-05-06 08:48:45 +01:00

692 lines
18 KiB
PowerShell

param(
[string] $OutputRoot,
[string] $Label,
[switch] $NoRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$startScript = Join-Path $PSScriptRoot "start-voidbot-stack.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 Parse-DatabaseDsn {
param(
[Parameter(Mandatory = $true)]
[string] $Dsn
)
$uri = [Uri]$Dsn
$userInfo = if ([string]::IsNullOrWhiteSpace($uri.UserInfo)) { @() } else { $uri.UserInfo.Split(":", 2) }
return @{
host = $uri.Host
port = if ($uri.Port -gt 0) { $uri.Port } else { 5432 }
database = $uri.AbsolutePath.TrimStart("/")
username = if ($userInfo.Length -ge 1) { [Uri]::UnescapeDataString($userInfo[0]) } else { "voidbot" }
password = if ($userInfo.Length -ge 2) { [Uri]::UnescapeDataString($userInfo[1]) } else { "voidbot" }
}
}
function Invoke-CurlJson {
param(
[Parameter(Mandatory = $true)]
[string[]] $Arguments,
[int] $ConnectTimeoutSeconds = 5,
[int] $MaxTimeSeconds = 30
)
$response = & curl.exe --connect-timeout $ConnectTimeoutSeconds --max-time $MaxTimeSeconds @Arguments
if ($LASTEXITCODE -ne 0) {
throw "curl.exe failed: $($Arguments -join ' ')"
}
if ([string]::IsNullOrWhiteSpace($response)) {
return $null
}
return $response | ConvertFrom-Json
}
function Test-TcpEndpoint {
param(
[Parameter(Mandatory = $true)]
[string] $HostName,
[Parameter(Mandatory = $true)]
[int] $Port
)
$client = [System.Net.Sockets.TcpClient]::new()
try {
$connectTask = $client.ConnectAsync($HostName, $Port)
if (-not $connectTask.Wait(1500)) {
return $false
}
return $client.Connected
} catch {
return $false
} finally {
$client.Dispose()
}
}
function Test-IsLocalHostName {
param(
[Parameter(Mandatory = $true)]
[string] $HostName
)
return $HostName -in @("127.0.0.1", "localhost", "::1")
}
function Test-IsLocalUrl {
param(
[Parameter(Mandatory = $true)]
[string] $Url
)
$uri = [Uri]$Url
return $uri.Host -in @("127.0.0.1", "localhost", "::1")
}
function Wait-Until {
param(
[Parameter(Mandatory = $true)]
[scriptblock] $Condition,
[Parameter(Mandatory = $true)]
[string] $FailureMessage,
[int] $Attempts = 30,
[int] $SleepSeconds = 2
)
for ($attempt = 0; $attempt -lt $Attempts; $attempt += 1) {
if (& $Condition) {
return
}
Start-Sleep -Seconds $SleepSeconds
}
throw $FailureMessage
}
function Get-DockerContainerHealth {
param(
[Parameter(Mandatory = $true)]
[string] $ContainerName
)
try {
$status = & docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}" $ContainerName 2>$null
if ($LASTEXITCODE -ne 0) {
return $null
}
return $status.Trim()
} catch {
return $null
}
}
function Ensure-DockerOnPath {
$dockerCliDir = "C:\Program Files\Docker\Docker\resources\bin"
if (Test-Path $dockerCliDir) {
$env:Path = "$dockerCliDir;$env:Path"
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw "Docker CLI is not available on PATH."
}
& docker version *> $null
if ($LASTEXITCODE -ne 0) {
throw "Docker is installed but the daemon is not ready."
}
}
function Ensure-Postgres {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $DatabaseDsn
)
$database = Parse-DatabaseDsn -Dsn $DatabaseDsn
if (Test-TcpEndpoint -HostName $database.host -Port $database.port) {
return @{
dsn = $DatabaseDsn
host = $database.host
port = $database.port
database = $database.database
username = $database.username
password = $database.password
containerName = "voidbot-postgres"
startedByScript = $false
healthy = $true
}
}
if (-not (Test-IsLocalHostName -HostName $database.host)) {
throw "Postgres is not reachable at $($database.host):$($database.port), and the host is not local enough for Docker auto-start."
}
Ensure-DockerOnPath
$composeFile = Join-Path $RepoRoot "infra\postgres\docker-compose.yml"
$env:VOIDBOT_POSTGRES_PORT = [string]$database.port
$env:VOIDBOT_POSTGRES_DB = $database.database
$env:VOIDBOT_POSTGRES_USER = $database.username
$env:VOIDBOT_POSTGRES_PASSWORD = $database.password
& docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Postgres."
}
Wait-Until -Condition {
$health = Get-DockerContainerHealth -ContainerName "voidbot-postgres"
if ([string]::IsNullOrWhiteSpace($health) -or $health -eq "none") {
return Test-TcpEndpoint -HostName $database.host -Port $database.port
}
return $health -eq "healthy"
} -FailureMessage "Postgres did not become reachable at $($database.host):$($database.port) in time."
return @{
dsn = $DatabaseDsn
host = $database.host
port = $database.port
database = $database.database
username = $database.username
password = $database.password
containerName = "voidbot-postgres"
startedByScript = $true
healthy = $true
}
}
function Ensure-Qdrant {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
$healthUrl = "$($QdrantUrl.TrimEnd('/'))/collections"
try {
[void](Invoke-CurlJson -Arguments @("-fsS", $healthUrl))
return @{
url = $QdrantUrl
startedByScript = $false
healthy = $true
}
} catch {
}
if (-not (Test-IsLocalUrl -Url $QdrantUrl)) {
throw "Qdrant is not reachable at $QdrantUrl, and the URL is not local enough for Docker auto-start."
}
Ensure-DockerOnPath
$composeFile = Join-Path $RepoRoot "infra\qdrant\docker-compose.yml"
& docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Qdrant."
}
Wait-Until -Condition {
try {
[void](Invoke-CurlJson -Arguments @("-fsS", $healthUrl))
return $true
} catch {
return $false
}
} -FailureMessage "Qdrant did not become reachable at $QdrantUrl in time."
return @{
url = $QdrantUrl
startedByScript = $true
healthy = $true
}
}
function Get-RuntimeProcesses {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot
)
$patterns = @(
"apps\bot\src\index.ts",
"apps/bot/src/index.ts",
"apps\bot\dist\index.js",
"apps/bot/dist/index.js",
"apps\worker\src\index.ts",
"apps/worker/src/index.ts",
"apps\worker\dist\index.js",
"apps/worker/dist/index.js"
)
return Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "node.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*$RepoRoot*"
} |
Where-Object {
$commandLine = $_.CommandLine
$matchesRuntime = $false
foreach ($pattern in $patterns) {
if ($commandLine -like "*$pattern*") {
$matchesRuntime = $true
break
}
}
return $matchesRuntime
}
}
function Stop-RuntimeProcesses {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot
)
$processes = @(Get-RuntimeProcesses -RepoRoot $RepoRoot)
foreach ($process in $processes) {
try {
Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
} catch {
}
}
return $processes
}
function Get-PostgresVersion {
param(
[Parameter(Mandatory = $true)]
[string] $ContainerName
)
$version = & docker exec $ContainerName postgres --version
if ($LASTEXITCODE -ne 0) {
throw "Failed to determine PostgreSQL version from $ContainerName."
}
return $version.Trim()
}
function Get-QdrantInfo {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
return Invoke-CurlJson -Arguments @("-fsS", "$($QdrantUrl.TrimEnd('/'))/")
}
function Get-QdrantCollections {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
$response = Invoke-CurlJson -Arguments @("-fsS", "$($QdrantUrl.TrimEnd('/'))/collections")
return @($response.result.collections | ForEach-Object { $_.name })
}
function Backup-Postgres {
param(
[Parameter(Mandatory = $true)]
[hashtable] $Postgres,
[Parameter(Mandatory = $true)]
[string] $OutputDir
)
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$containerPath = "/tmp/voidbot-backup.dump"
$outputPath = Join-Path $OutputDir "voidbot.dump"
& docker exec "-e" "PGPASSWORD=$($Postgres.password)" $Postgres.containerName `
pg_dump `
"--username=$($Postgres.username)" `
"--dbname=$($Postgres.database)" `
"--format=custom" `
"--no-owner" `
"--no-privileges" `
"--file=$containerPath"
if ($LASTEXITCODE -ne 0) {
throw "pg_dump failed inside $($Postgres.containerName)."
}
try {
& docker cp "$($Postgres.containerName):$containerPath" $outputPath
if ($LASTEXITCODE -ne 0) {
throw "docker cp failed while exporting the PostgreSQL dump."
}
} finally {
& docker exec $Postgres.containerName rm -f $containerPath *> $null
}
return @{
skipped = $false
containerName = $Postgres.containerName
version = Get-PostgresVersion -ContainerName $Postgres.containerName
dumpFile = "postgres/voidbot.dump"
database = $Postgres.database
host = $Postgres.host
port = $Postgres.port
}
}
function Backup-QdrantCollections {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl,
[Parameter(Mandatory = $true)]
[string] $OutputDir
)
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$rootInfo = Get-QdrantInfo -QdrantUrl $QdrantUrl
$collections = Get-QdrantCollections -QdrantUrl $QdrantUrl
$backedUpCollections = @()
foreach ($collectionName in $collections) {
Write-Host "Creating Qdrant snapshot for $collectionName..."
$escapedCollection = [Uri]::EscapeDataString($collectionName)
$createResponse = Invoke-CurlJson -Arguments @(
"-fsS",
"-X",
"POST",
"$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots"
) -MaxTimeSeconds 180
$snapshotName = [string]$createResponse.result.name
Wait-Until -Condition {
try {
$listResponse = Invoke-CurlJson -Arguments @(
"-fsS",
"$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots"
)
$names = @($listResponse.result | ForEach-Object { $_.name })
return $names -contains $snapshotName
} catch {
return $false
}
} -FailureMessage "Qdrant snapshot $snapshotName for collection $collectionName did not become downloadable in time."
$downloadPath = Join-Path $OutputDir $snapshotName
try {
Write-Host "Downloading Qdrant snapshot $snapshotName..."
& curl.exe -fsS "$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots/$snapshotName" --output $downloadPath
if ($LASTEXITCODE -ne 0) {
throw "Failed to download Qdrant snapshot $snapshotName for collection $collectionName."
}
} finally {
& curl.exe -fsS -X DELETE "$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots/$snapshotName" *> $null
}
$backedUpCollections += @{
name = $collectionName
snapshotFile = "qdrant/$snapshotName"
}
}
return @{
skipped = $false
url = $QdrantUrl
version = [string]$rootInfo.version
title = [string]$rootInfo.title
collections = $backedUpCollections
}
}
function Backup-RagArchives {
param(
[Parameter(Mandatory = $true)]
[string] $StorageRoot,
[Parameter(Mandatory = $true)]
[string] $OutputDir
)
$ragRoot = Join-Path $StorageRoot "rag"
if (-not (Test-Path $ragRoot)) {
return @{
skipped = $true
reason = "RAG archive directory does not exist."
files = @()
}
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$items = @(Get-ChildItem -Path $ragRoot -Force)
if ($items.Count -gt 0) {
Copy-Item -Path (Join-Path $ragRoot "*") -Destination $OutputDir -Recurse -Force
}
$files = Get-ChildItem -Path $OutputDir -File -Recurse |
ForEach-Object {
@{
relativePath = $_.FullName.Substring($OutputDir.Length + 1)
size = $_.Length
}
}
return @{
skipped = $false
sourceRoot = $ragRoot
files = $files
}
}
function Sanitize-Label {
param(
[Parameter(Mandatory = $true)]
[string] $Value
)
$sanitized = ($Value -replace "[^A-Za-z0-9._-]", "-").Trim("-")
if ([string]::IsNullOrWhiteSpace($sanitized)) {
return $null
}
return $sanitized
}
$config = Read-DotEnv -Path $envFile
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
$backupRoot = if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
Join-Path $storageRoot "backups"
} else {
[System.IO.Path]::GetFullPath($OutputRoot)
}
$stateStorageBackend = if ($config.ContainsKey("STATE_STORAGE_BACKEND")) { $config["STATE_STORAGE_BACKEND"] } else { "postgres" }
$databaseDsn = if ($config.ContainsKey("DATABASE_DSN")) { $config["DATABASE_DSN"] } else { "postgres://voidbot:voidbot@localhost:5432/voidbot" }
$vectorStoreKind = if ($config.ContainsKey("VECTOR_STORE_KIND")) { $config["VECTOR_STORE_KIND"] } else { "local_json" }
$qdrantUrl = if ($config.ContainsKey("QDRANT_URL")) { $config["QDRANT_URL"] } else { "http://127.0.0.1:6333" }
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$labelSuffix = $null
if (-not [string]::IsNullOrWhiteSpace($Label)) {
$sanitizedLabel = Sanitize-Label -Value $Label
if ($sanitizedLabel) {
$labelSuffix = "-$sanitizedLabel"
}
}
$backupDir = Join-Path $backupRoot "$timestamp$labelSuffix"
$postgresDir = Join-Path $backupDir "postgres"
$qdrantDir = Join-Path $backupDir "qdrant"
$archiveDir = Join-Path $backupDir "archives"
$manifestPath = Join-Path $backupDir "manifest.json"
$stoppedRuntime = @()
$shouldRestart = $false
$pendingError = $null
$backupCompleted = $false
try {
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
$stoppedRuntime = @(Stop-RuntimeProcesses -RepoRoot $repoRoot)
$shouldRestart = $stoppedRuntime.Count -gt 0 -and -not $NoRestart
$stoppedPids = @($stoppedRuntime | ForEach-Object { $_.ProcessId })
$stoppedDisplay = if ($stoppedPids.Count -gt 0) { $stoppedPids -join ", " } else { "none" }
Write-Host "Stopped runtime processes: $stoppedDisplay"
$manifest = @{
backupFormat = "voidbot_state_backup_v1"
createdAt = (Get-Date).ToString("o")
repoRoot = $repoRoot
storageRoot = $storageRoot
runtimeStopped = @($stoppedRuntime | ForEach-Object { $_.ProcessId })
postgres = @{
skipped = $true
reason = "STATE_STORAGE_BACKEND is not postgres."
}
qdrant = @{
skipped = $true
reason = "VECTOR_STORE_KIND is not qdrant."
}
archives = $null
}
if ($stateStorageBackend -eq "postgres") {
Write-Host "Backing up Postgres state..."
Ensure-DockerOnPath
$postgres = Ensure-Postgres -RepoRoot $repoRoot -DatabaseDsn $databaseDsn
$manifest.postgres = Backup-Postgres -Postgres $postgres -OutputDir $postgresDir
}
if ($vectorStoreKind -eq "qdrant") {
Write-Host "Backing up Qdrant collections..."
Ensure-DockerOnPath
[void](Ensure-Qdrant -RepoRoot $repoRoot -QdrantUrl $qdrantUrl)
$manifest.qdrant = Backup-QdrantCollections -QdrantUrl $qdrantUrl -OutputDir $qdrantDir
}
Write-Host "Copying RAG archives..."
$manifest.archives = Backup-RagArchives -StorageRoot $storageRoot -OutputDir $archiveDir
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Path $manifestPath -Encoding utf8
$backupCompleted = $true
Write-Host "VoidBot state backup complete."
Write-Host "Backup: $backupDir"
} catch {
$pendingError = $_
} finally {
if ($shouldRestart) {
try {
Write-Host "Restarting VoidBot stack..."
& powershell -ExecutionPolicy Bypass -File $startScript
if ($LASTEXITCODE -ne 0) {
throw "Failed to restart the VoidBot stack after backup."
}
} catch {
if ($null -eq $pendingError) {
$pendingError = $_
} else {
Write-Warning "Backup hit an error, and the automatic stack restart also failed: $($_.Exception.Message)"
}
}
}
}
if ($null -ne $pendingError) {
if (-not $backupCompleted -and (Test-Path -LiteralPath $backupDir)) {
try {
Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction Stop
Write-Warning "Removed incomplete backup directory $backupDir after failure."
} catch {
Write-Warning ("Failed to remove incomplete backup directory {0}: {1}" -f $backupDir, $_.Exception.Message)
}
}
if ($pendingError.Exception) {
throw $pendingError.Exception
}
throw $pendingError
}