chore: import upstream snapshot with attribution
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions
Obsidian Plugin Lint / lint (push) Waiting to run
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions
Obsidian Plugin Lint / lint (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
# =============================================================================
|
||||
# SurfSense — One-line Install Script (Windows / PowerShell)
|
||||
#
|
||||
#
|
||||
# Usage: irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
|
||||
#
|
||||
# To pass flags, save and run locally:
|
||||
# .\install.ps1 -NoWatchtower
|
||||
# .\install.ps1 -WatchtowerInterval 3600
|
||||
# .\install.ps1 -Variant cuda
|
||||
# .\install.ps1 -Variant cuda -GpuCount all
|
||||
#
|
||||
# Handles two cases automatically:
|
||||
# 1. Fresh install — no prior SurfSense data detected
|
||||
# 2. Migration from the legacy all-in-one container (surfsense-data volume)
|
||||
# Downloads and runs migrate-database.sh --yes, then restores the dump
|
||||
# into the new PostgreSQL 17 stack. The user runs one command for both.
|
||||
# =============================================================================
|
||||
|
||||
# NOTE: Do not use [ValidateSet()] (or other validation attributes without a
|
||||
# valid default) on these params. When the script is piped into iex, PowerShell
|
||||
# applies the attributes to variables in the caller's scope, and an empty
|
||||
# $Variant fails ValidateSet with a ValidationMetadataException. Validate
|
||||
# manually below instead.
|
||||
param(
|
||||
[switch]$NoWatchtower,
|
||||
[int]$WatchtowerInterval = 86400,
|
||||
[string]$Variant,
|
||||
[string]$GpuCount,
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
$RepoRaw = "https://raw.githubusercontent.com/MODSetter/SurfSense/main"
|
||||
$InstallDir = ".\surfsense"
|
||||
$OldVolume = "surfsense-data"
|
||||
$DumpFile = ".\surfsense_migration_backup.sql"
|
||||
$KeyFile = ".\surfsense_migration_secret.key"
|
||||
$MigrationDoneFile = "$InstallDir\.migration_done"
|
||||
$MigrationMode = $false
|
||||
$SetupWatchtower = -not $NoWatchtower
|
||||
$WatchtowerContainer = "watchtower"
|
||||
|
||||
if ($Variant -and $Variant -notin @("cpu", "cuda", "cuda126")) {
|
||||
Write-Host "[SurfSense] ERROR: Invalid -Variant '$Variant'. Use 'cpu', 'cuda', or 'cuda126'." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($GpuCount -and $GpuCount -notmatch '^([0-9]+|all)$') {
|
||||
Write-Host "[SurfSense] ERROR: Invalid -GpuCount '$GpuCount'. Use a number or 'all'." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Output helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Info { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Cyan -NoNewline; Write-Host $Msg }
|
||||
function Write-Ok { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Green -NoNewline; Write-Host $Msg }
|
||||
function Write-Warn { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Yellow -NoNewline; Write-Host $Msg }
|
||||
function Write-Step { param([string]$Msg) Write-Host "`n-- $Msg" -ForegroundColor Cyan }
|
||||
function Write-Err { param([string]$Msg) Write-Host "[SurfSense] ERROR: $Msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
function Show-Banner {
|
||||
Write-Host ""
|
||||
Write-Host @"
|
||||
|
||||
|
||||
███████╗██╗ ██╗██████╗ ███████╗███████╗███████╗███╗ ██╗███████╗███████╗
|
||||
██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔════╝██╔════╝
|
||||
███████╗██║ ██║██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║███████╗█████╗
|
||||
╚════██║██║ ██║██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║╚════██║██╔══╝
|
||||
███████║╚██████╔╝██║ ██║██║ ███████║███████╗██║ ╚████║███████║███████╗
|
||||
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝
|
||||
|
||||
|
||||
"@ -ForegroundColor White
|
||||
Write-Host " OSS Alternative to NotebookLM for Teams" -ForegroundColor Yellow
|
||||
Write-Host ("=" * 62) -ForegroundColor Cyan
|
||||
Write-Info "This installer will create $InstallDir\ and start SurfSense with Docker Compose."
|
||||
}
|
||||
|
||||
Show-Banner
|
||||
|
||||
function Invoke-NativeSafe {
|
||||
param([scriptblock]$Command)
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Command
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-WatchtowerPreference {
|
||||
if ($NoWatchtower -or $Quiet -or -not [Environment]::UserInteractive) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Automatic updates" -ForegroundColor Cyan
|
||||
$choice = Read-Host "Enable automatic daily updates with Watchtower? (may download several GB in the background) [Y/n]"
|
||||
|
||||
switch ($choice) {
|
||||
"" { $script:SetupWatchtower = $true }
|
||||
{ $_ -match '^(?i)y(es)?$' } { $script:SetupWatchtower = $true }
|
||||
{ $_ -match '^(?i)n(o)?$' } { $script:SetupWatchtower = $false }
|
||||
default {
|
||||
Write-Warn "Unrecognized choice '$choice'; enabling Watchtower by default. Use -NoWatchtower to skip it."
|
||||
$script:SetupWatchtower = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Resolve-WatchtowerPreference
|
||||
|
||||
# ── Pre-flight checks ──────────────────────────────────────────────────────
|
||||
|
||||
Write-Step "Checking prerequisites"
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Write-Err "Docker is not installed. Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/"
|
||||
}
|
||||
Write-Ok "Docker found."
|
||||
|
||||
Invoke-NativeSafe { docker info *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Docker daemon is not running. Please start Docker Desktop and try again."
|
||||
}
|
||||
Write-Ok "Docker daemon is running."
|
||||
|
||||
Invoke-NativeSafe { docker compose version *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Docker Compose is not available. It should be bundled with Docker Desktop."
|
||||
}
|
||||
Write-Ok "Docker Compose found."
|
||||
|
||||
# ── Wait-for-postgres helper ────────────────────────────────────────────────
|
||||
|
||||
function Wait-ForPostgres {
|
||||
param([string]$DbUser)
|
||||
$maxAttempts = 45
|
||||
$attempt = 0
|
||||
|
||||
Write-Info "Waiting for PostgreSQL to accept connections..."
|
||||
do {
|
||||
$attempt++
|
||||
if ($attempt -ge $maxAttempts) {
|
||||
Write-Err "PostgreSQL did not become ready after $($maxAttempts * 2) seconds.`nCheck logs: cd $InstallDir; docker compose logs db"
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
Push-Location $InstallDir
|
||||
Invoke-NativeSafe { docker compose exec -T db pg_isready -U $DbUser -q *>$null } | Out-Null
|
||||
$ready = $LASTEXITCODE -eq 0
|
||||
Pop-Location
|
||||
} while (-not $ready)
|
||||
|
||||
Write-Ok "PostgreSQL is ready."
|
||||
}
|
||||
|
||||
# ── Stack startup helper ────────────────────────────────────────────────────
|
||||
|
||||
function Invoke-StackFailureReport {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Stack did not reach a healthy state." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Info "Recent logs from migrations / zero-cache / backend:"
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
Invoke-NativeSafe { docker compose logs --tail=60 migrations zero-cache backend 2>&1 } | Write-Host
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Recovery hints:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Inspect migrations: cd $InstallDir; docker compose logs migrations"
|
||||
Write-Host " 2. Verify publication: cd $InstallDir; docker compose exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
|
||||
Write-Host " 3. Hard reset zero db: cd $InstallDir; docker compose down; docker volume rm surfsense-zero-cache; docker compose up -d --wait"
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Invoke-ComposeUpWait {
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
Invoke-NativeSafe { docker compose up -d --wait }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Invoke-StackFailureReport
|
||||
}
|
||||
}
|
||||
|
||||
# ── Variant and .env helpers ────────────────────────────────────────────────
|
||||
|
||||
function Set-EnvValue {
|
||||
param([string]$Path, [string]$Key, [string]$Value)
|
||||
$lines = @()
|
||||
if (Test-Path $Path) {
|
||||
$lines = @(Get-Content $Path)
|
||||
}
|
||||
$updated = $false
|
||||
$newLines = foreach ($line in $lines) {
|
||||
if ($line -match "^$([regex]::Escape($Key))=") {
|
||||
$updated = $true
|
||||
"$Key=$Value"
|
||||
} else {
|
||||
$line
|
||||
}
|
||||
}
|
||||
if (-not $updated) {
|
||||
$newLines += "$Key=$Value"
|
||||
}
|
||||
Set-Content -Path $Path -Value $newLines
|
||||
}
|
||||
|
||||
function Remove-EnvValue {
|
||||
param([string]$Path, [string]$Key)
|
||||
if (-not (Test-Path $Path)) { return }
|
||||
$newLines = Get-Content $Path | Where-Object { $_ -notmatch "^$([regex]::Escape($Key))=" }
|
||||
Set-Content -Path $Path -Value $newLines
|
||||
}
|
||||
|
||||
function Test-NvidiaGpu {
|
||||
if (-not (Get-Command nvidia-smi -ErrorAction SilentlyContinue)) { return $false }
|
||||
Invoke-NativeSafe { nvidia-smi *>$null } | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Test-NvidiaRuntime {
|
||||
$info = Invoke-NativeSafe { docker info 2>$null }
|
||||
if ($info -match 'nvidia') { return $true }
|
||||
if (Get-Command nvidia-ctk -ErrorAction SilentlyContinue) { return $true }
|
||||
if (Get-Command nvidia-container-runtime -ErrorAction SilentlyContinue) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-RecommendedVariant {
|
||||
$driver = (Invoke-NativeSafe { nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>$null } | Select-Object -First 1)
|
||||
$major = 0
|
||||
if ($driver -match '^(\d+)') {
|
||||
$major = [int]$Matches[1]
|
||||
}
|
||||
if ($major -gt 0 -and $major -lt 570) {
|
||||
return "cuda126"
|
||||
}
|
||||
return "cuda"
|
||||
}
|
||||
|
||||
function Resolve-Variant {
|
||||
$hasGpu = Test-NvidiaGpu
|
||||
$hasRuntime = $false
|
||||
$recommended = "cpu"
|
||||
|
||||
if ($hasGpu) {
|
||||
$recommended = Get-RecommendedVariant
|
||||
$hasRuntime = Test-NvidiaRuntime
|
||||
}
|
||||
|
||||
if ($Variant) {
|
||||
if ($Variant -eq "cpu") { return "cpu" }
|
||||
if (-not $hasGpu) {
|
||||
Write-Warn "No NVIDIA GPU detected; falling back to CPU variant."
|
||||
return "cpu"
|
||||
}
|
||||
if (-not $hasRuntime) {
|
||||
Write-Warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; falling back to CPU variant."
|
||||
Write-Warn "Install the toolkit before enabling SurfSense GPU acceleration."
|
||||
return "cpu"
|
||||
}
|
||||
return $Variant
|
||||
}
|
||||
|
||||
if ($hasGpu -and -not $hasRuntime) {
|
||||
Write-Warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; using CPU variant."
|
||||
}
|
||||
|
||||
if ($hasGpu -and $hasRuntime -and -not $Quiet -and [Environment]::UserInteractive) {
|
||||
Write-Host ""
|
||||
Write-Host "SurfSense detected an NVIDIA GPU." -ForegroundColor Cyan
|
||||
$choice = Read-Host "Use GPU acceleration? [Y/n]"
|
||||
switch ($choice) {
|
||||
"" { return $recommended }
|
||||
{ $_ -match '^(?i)y(es)?$' } { return $recommended }
|
||||
{ $_ -match '^(?i)n(o)?$' } { return "cpu" }
|
||||
default {
|
||||
Write-Warn "Unrecognized choice '$choice'; using CPU variant."
|
||||
return "cpu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "cpu"
|
||||
}
|
||||
|
||||
function Set-VariantEnv {
|
||||
param([string]$Path, [string]$SelectedVariant, [bool]$AllowExistingUpdate)
|
||||
|
||||
if ((Test-Path $Path) -and -not $AllowExistingUpdate) {
|
||||
Write-Warn ".env already exists - keeping your existing configuration."
|
||||
Write-Info "To change variants later, edit SURFSENSE_VARIANT and COMPOSE_FILE in $Path, then run docker compose up -d --wait."
|
||||
return
|
||||
}
|
||||
|
||||
if ($SelectedVariant -eq "cpu") {
|
||||
Set-EnvValue -Path $Path -Key "SURFSENSE_VARIANT" -Value ""
|
||||
Remove-EnvValue -Path $Path -Key "COMPOSE_FILE"
|
||||
Remove-EnvValue -Path $Path -Key "SURFSENSE_GPU_COUNT"
|
||||
} else {
|
||||
Set-EnvValue -Path $Path -Key "SURFSENSE_VARIANT" -Value $SelectedVariant
|
||||
Set-EnvValue -Path $Path -Key "COMPOSE_FILE" -Value "docker-compose.yml;docker-compose.gpu.yml"
|
||||
if ($GpuCount) {
|
||||
Set-EnvValue -Path $Path -Key "SURFSENSE_GPU_COUNT" -Value $GpuCount
|
||||
}
|
||||
}
|
||||
|
||||
Remove-EnvValue -Path $Path -Key "COMPOSE_PROFILES"
|
||||
}
|
||||
|
||||
$SelectedVariant = Resolve-Variant
|
||||
|
||||
# ── Download files ──────────────────────────────────────────────────────────
|
||||
|
||||
Write-Step "Downloading SurfSense files"
|
||||
Write-Info "Installation directory: $InstallDir"
|
||||
|
||||
New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "$InstallDir\proxy" -Force | Out-Null
|
||||
|
||||
$Files = @(
|
||||
@{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" }
|
||||
@{ Src = "docker/docker-compose.gpu.yml"; Dest = "docker-compose.gpu.yml" }
|
||||
@{ Src = "docker/.env.example"; Dest = ".env.example" }
|
||||
@{ Src = "docker/proxy/Caddyfile"; Dest = "proxy/Caddyfile" }
|
||||
@{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" }
|
||||
@{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" }
|
||||
)
|
||||
|
||||
foreach ($f in $Files) {
|
||||
$destPath = Join-Path $InstallDir $f.Dest
|
||||
Write-Info "Downloading $($f.Dest)..."
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$RepoRaw/$($f.Src)" -OutFile $destPath -UseBasicParsing
|
||||
} catch {
|
||||
Write-Err "Failed to download $($f.Dest). Check your internet connection and try again."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Ok "All files downloaded to $InstallDir/"
|
||||
|
||||
# ── Legacy all-in-one detection ─────────────────────────────────────────────
|
||||
|
||||
$volumeList = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
|
||||
if (($volumeList -split "`n") -contains $OldVolume -and -not (Test-Path $MigrationDoneFile)) {
|
||||
$MigrationMode = $true
|
||||
|
||||
if (Test-Path $DumpFile) {
|
||||
Write-Step "Migration mode - using existing dump (skipping extraction)"
|
||||
Write-Info "Found existing dump: $DumpFile"
|
||||
Write-Info "Skipping data extraction - proceeding directly to restore."
|
||||
Write-Info "To force a fresh extraction, remove the dump first: Remove-Item $DumpFile"
|
||||
} else {
|
||||
Write-Step "Migration mode - legacy all-in-one container detected"
|
||||
Write-Warn "Volume '$OldVolume' found. Your data will be migrated automatically."
|
||||
Write-Warn "PostgreSQL is being upgraded from version 14 to 17."
|
||||
Write-Warn "Your original data will NOT be deleted."
|
||||
Write-Host ""
|
||||
Write-Info "Running data extraction (migrate-database.ps1 -Yes)..."
|
||||
Write-Info "Full extraction log: ./surfsense-migration.log"
|
||||
Write-Host ""
|
||||
|
||||
$migrateScript = Join-Path $InstallDir "scripts/migrate-database.ps1"
|
||||
& $migrateScript -Yes
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Data extraction failed. See ./surfsense-migration.log for details.`nYou can also run migrate-database.ps1 manually with custom flags."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Ok "Data extraction complete. Proceeding with installation and restore."
|
||||
}
|
||||
}
|
||||
|
||||
# ── Set up .env ─────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Step "Configuring environment"
|
||||
|
||||
$envPath = Join-Path $InstallDir ".env"
|
||||
$envExamplePath = Join-Path $InstallDir ".env.example"
|
||||
|
||||
if (-not (Test-Path $envPath)) {
|
||||
Copy-Item $envExamplePath $envPath
|
||||
|
||||
if ($MigrationMode -and (Test-Path $KeyFile)) {
|
||||
$SecretKey = (Get-Content $KeyFile -Raw).Trim()
|
||||
Write-Ok "Using SECRET_KEY recovered from legacy container."
|
||||
} else {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||
$rng.GetBytes($bytes)
|
||||
$rng.Dispose()
|
||||
$SecretKey = [Convert]::ToBase64String($bytes)
|
||||
Write-Ok "Generated new random SECRET_KEY."
|
||||
}
|
||||
|
||||
$content = Get-Content $envPath -Raw
|
||||
$content = $content -replace 'SECRET_KEY=replace_me_with_a_random_string', "SECRET_KEY=$SecretKey"
|
||||
Set-Content -Path $envPath -Value $content -NoNewline
|
||||
|
||||
Set-VariantEnv -Path $envPath -SelectedVariant $SelectedVariant -AllowExistingUpdate $false
|
||||
Write-Info "Created $envPath"
|
||||
} else {
|
||||
if ($PSBoundParameters.ContainsKey('Variant')) {
|
||||
Set-VariantEnv -Path $envPath -SelectedVariant $SelectedVariant -AllowExistingUpdate $true
|
||||
Write-Info "Updated SurfSense image variant in existing $envPath"
|
||||
} else {
|
||||
Set-VariantEnv -Path $envPath -SelectedVariant $SelectedVariant -AllowExistingUpdate $false
|
||||
}
|
||||
}
|
||||
|
||||
# ── Start containers ────────────────────────────────────────────────────────
|
||||
|
||||
if ($MigrationMode) {
|
||||
$envContent = Get-Content $envPath
|
||||
$DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
$DbPass = ($envContent | Select-String '^DB_PASSWORD=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
$DbName = ($envContent | Select-String '^DB_NAME=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
if (-not $DbUser) { $DbUser = "surfsense" }
|
||||
if (-not $DbPass) { $DbPass = "surfsense" }
|
||||
if (-not $DbName) { $DbName = "surfsense" }
|
||||
|
||||
Write-Step "Starting PostgreSQL 17"
|
||||
Push-Location $InstallDir
|
||||
Invoke-NativeSafe { docker compose up -d db } | Out-Null
|
||||
Pop-Location
|
||||
Wait-ForPostgres -DbUser $DbUser
|
||||
|
||||
Write-Step "Restoring database"
|
||||
if (-not (Test-Path $DumpFile)) {
|
||||
Write-Err "Dump file '$DumpFile' not found. The migration script may have failed."
|
||||
}
|
||||
$DumpFilePath = (Resolve-Path $DumpFile).Path
|
||||
Write-Info "Restoring dump into PostgreSQL 17 - this may take a while for large databases..."
|
||||
|
||||
$restoreErrFile = Join-Path $env:TEMP "surfsense_restore_err.log"
|
||||
Push-Location $InstallDir
|
||||
Invoke-NativeSafe { Get-Content -LiteralPath $DumpFilePath | docker compose exec -T -e "PGPASSWORD=$DbPass" db psql -U $DbUser -d $DbName 2>$restoreErrFile | Out-Null } | Out-Null
|
||||
Pop-Location
|
||||
|
||||
$fatalErrors = @()
|
||||
if (Test-Path $restoreErrFile) {
|
||||
$fatalErrors = Get-Content $restoreErrFile |
|
||||
Where-Object { $_ -match '^ERROR:' } |
|
||||
Where-Object { $_ -notmatch 'already exists' } |
|
||||
Where-Object { $_ -notmatch 'multiple primary keys' }
|
||||
}
|
||||
|
||||
if ($fatalErrors.Count -gt 0) {
|
||||
Write-Warn "Restore completed with errors (may be harmless pg_dump header noise):"
|
||||
$fatalErrors | ForEach-Object { Write-Host $_ }
|
||||
Write-Warn "If SurfSense behaves incorrectly, inspect manually."
|
||||
} else {
|
||||
Write-Ok "Database restored with no fatal errors."
|
||||
}
|
||||
|
||||
# Smoke test
|
||||
Push-Location $InstallDir
|
||||
$tableCount = (Invoke-NativeSafe { docker compose exec -T -e "PGPASSWORD=$DbPass" db psql -U $DbUser -d $DbName -t -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>$null }).Trim()
|
||||
Pop-Location
|
||||
|
||||
if (-not $tableCount -or $tableCount -eq "0") {
|
||||
Write-Warn "Smoke test: no tables found after restore."
|
||||
Write-Warn "The restore may have failed silently. Check: cd $InstallDir; docker compose logs db"
|
||||
} else {
|
||||
Write-Ok "Smoke test passed: $tableCount table(s) restored successfully."
|
||||
New-Item -Path $MigrationDoneFile -ItemType File -Force | Out-Null
|
||||
}
|
||||
|
||||
Write-Step "Starting all SurfSense services"
|
||||
Invoke-ComposeUpWait
|
||||
Write-Ok "All services started and healthy."
|
||||
|
||||
Remove-Item $KeyFile -ErrorAction SilentlyContinue
|
||||
|
||||
} else {
|
||||
Write-Step "Starting SurfSense"
|
||||
Invoke-ComposeUpWait
|
||||
Write-Ok "All services started and healthy."
|
||||
}
|
||||
|
||||
# ── Watchtower (auto-update) ────────────────────────────────────────────────
|
||||
|
||||
if ($SetupWatchtower) {
|
||||
$wtHours = [math]::Floor($WatchtowerInterval / 3600)
|
||||
Write-Step "Setting up Watchtower (auto-updates every ${wtHours}h)"
|
||||
|
||||
$wtState = Invoke-NativeSafe { docker inspect -f '{{.State.Running}}' $WatchtowerContainer 2>$null }
|
||||
if ($LASTEXITCODE -ne 0) { $wtState = "missing" }
|
||||
|
||||
if ($wtState -eq "true") {
|
||||
Write-Ok "Watchtower is already running - skipping."
|
||||
} else {
|
||||
if ($wtState -ne "missing") {
|
||||
Write-Info "Removing stopped Watchtower container..."
|
||||
Invoke-NativeSafe { docker rm -f $WatchtowerContainer *>$null } | Out-Null
|
||||
}
|
||||
Invoke-NativeSafe {
|
||||
docker run -d `
|
||||
--name $WatchtowerContainer `
|
||||
--restart unless-stopped `
|
||||
-v /var/run/docker.sock:/var/run/docker.sock `
|
||||
nickfedor/watchtower `
|
||||
--label-enable `
|
||||
--interval $WatchtowerInterval *>$null
|
||||
} | Out-Null
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Ok "Watchtower started - labeled SurfSense containers will auto-update."
|
||||
} else {
|
||||
Write-Warn "Could not start Watchtower. You can set it up manually or use: docker compose pull; docker compose up -d --wait"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Info "Skipping Watchtower setup (-NoWatchtower flag)."
|
||||
}
|
||||
|
||||
# ── Done ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
$versionDisplay = (Get-Content $envPath | Select-String '^SURFSENSE_VERSION=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
if (-not $versionDisplay) { $versionDisplay = "latest" }
|
||||
$variantDisplay = (Get-Content $envPath | Select-String '^SURFSENSE_VARIANT=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
if (-not $variantDisplay) { $variantDisplay = "cpu" }
|
||||
$wtHours = [math]::Floor($WatchtowerInterval / 3600)
|
||||
Write-Step "SurfSense is now installed [$versionDisplay]"
|
||||
|
||||
Write-Info " Frontend: http://localhost:3929"
|
||||
Write-Info " Backend: http://localhost:8929"
|
||||
Write-Info " API Docs: http://localhost:8929/docs"
|
||||
Write-Info ""
|
||||
Write-Info " Config: $InstallDir\.env"
|
||||
Write-Info " Variant: $variantDisplay"
|
||||
Write-Info " Logs: cd $InstallDir; docker compose logs -f"
|
||||
Write-Info " Stop: cd $InstallDir; docker compose down"
|
||||
Write-Info " Update: cd $InstallDir; docker compose pull; docker compose up -d --wait"
|
||||
Write-Info ""
|
||||
|
||||
if ($SetupWatchtower) {
|
||||
Write-Info " Watchtower: auto-updates every ${wtHours}h (disable: docker rm -f $WatchtowerContainer)"
|
||||
} else {
|
||||
Write-Warn " Watchtower skipped. For auto-updates, re-run without -NoWatchtower."
|
||||
}
|
||||
Write-Info ""
|
||||
|
||||
if ($MigrationMode) {
|
||||
Write-Warn " Migration complete! Open frontend and verify your data."
|
||||
Write-Warn " Once verified, clean up the legacy volume and migration files:"
|
||||
Write-Warn " docker volume rm $OldVolume"
|
||||
Write-Warn " Remove-Item $DumpFile"
|
||||
Write-Warn " Remove-Item $MigrationDoneFile"
|
||||
} else {
|
||||
Write-Warn " First startup may take a few minutes while images are pulled."
|
||||
Write-Warn " Edit $InstallDir\.env to configure API keys, OAuth, etc."
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# SurfSense — One-line Install Script
|
||||
#
|
||||
#
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
|
||||
#
|
||||
# Flags:
|
||||
# --no-watchtower Skip automatic Watchtower setup
|
||||
# --watchtower-interval=SECS Check interval in seconds (default: 86400 = 24h)
|
||||
# --variant=cpu|cuda|cuda126 Select backend image variant
|
||||
# --gpu Alias for --variant=cuda
|
||||
# --cpu Alias for --variant=cpu
|
||||
# --gpu-count=N|all Number of GPUs to reserve when GPU is enabled
|
||||
# --quiet Skip interactive prompts
|
||||
#
|
||||
# Handles two cases automatically:
|
||||
# 1. Fresh install — no prior SurfSense data detected
|
||||
# 2. Migration from the legacy all-in-one container (surfsense-data volume)
|
||||
# Downloads and runs migrate-database.sh --yes, then restores the dump
|
||||
# into the new PostgreSQL 17 stack. The user runs one command for both.
|
||||
#
|
||||
# If you used custom database credentials in the old all-in-one container, run
|
||||
# migrate-database.sh manually first (with --db-user / --db-password flags),
|
||||
# then re-run this script:
|
||||
# curl -fsSL .../docker/scripts/migrate-database.sh | bash -s -- --db-user X --db-password Y
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
main() {
|
||||
|
||||
REPO_RAW="https://raw.githubusercontent.com/MODSetter/SurfSense/main"
|
||||
INSTALL_DIR="./surfsense"
|
||||
OLD_VOLUME="surfsense-data"
|
||||
DUMP_FILE="./surfsense_migration_backup.sql"
|
||||
KEY_FILE="./surfsense_migration_secret.key"
|
||||
MIGRATION_DONE_FILE="${INSTALL_DIR}/.migration_done"
|
||||
MIGRATION_MODE=false
|
||||
SETUP_WATCHTOWER=true
|
||||
WATCHTOWER_INTERVAL=86400
|
||||
WATCHTOWER_CONTAINER="watchtower"
|
||||
WATCHTOWER_EXPLICIT=false
|
||||
REQUESTED_VARIANT=""
|
||||
VARIANT_EXPLICIT=false
|
||||
GPU_COUNT=""
|
||||
QUIET=false
|
||||
|
||||
# ── Parse flags ─────────────────────────────────────────────────────────────
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-watchtower) SETUP_WATCHTOWER=false; WATCHTOWER_EXPLICIT=true ;;
|
||||
--watchtower-interval=*) WATCHTOWER_INTERVAL="${arg#*=}" ;;
|
||||
--variant=*) REQUESTED_VARIANT="${arg#*=}"; VARIANT_EXPLICIT=true ;;
|
||||
--gpu) REQUESTED_VARIANT="cuda"; VARIANT_EXPLICIT=true ;;
|
||||
--cpu) REQUESTED_VARIANT="cpu"; VARIANT_EXPLICIT=true ;;
|
||||
--gpu-count=*) GPU_COUNT="${arg#*=}" ;;
|
||||
--quiet) QUIET=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
CYAN='\033[1;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { printf "${CYAN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
success() { printf "${GREEN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
warn() { printf "${YELLOW}[SurfSense]${NC} %s\n" "$1"; }
|
||||
error() { printf "${RED}[SurfSense]${NC} ERROR: %s\n" "$1" >&2; exit 1; }
|
||||
step() { printf "\n${BOLD}${CYAN}── %s${NC}\n" "$1"; }
|
||||
|
||||
show_banner() {
|
||||
echo ""
|
||||
printf '\033[1;37m'
|
||||
cat << 'EOF'
|
||||
|
||||
|
||||
███████╗██╗ ██╗██████╗ ███████╗███████╗███████╗███╗ ██╗███████╗███████╗
|
||||
██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔════╝██╔════╝
|
||||
███████╗██║ ██║██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║███████╗█████╗
|
||||
╚════██║██║ ██║██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║╚════██║██╔══╝
|
||||
███████║╚██████╔╝██║ ██║██║ ███████║███████╗██║ ╚████║███████║███████╗
|
||||
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝
|
||||
|
||||
|
||||
EOF
|
||||
printf "${YELLOW} OSS Alternative to NotebookLM for Teams${NC}\n"
|
||||
printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n"
|
||||
info "This installer will create ${INSTALL_DIR}/ and start SurfSense with Docker Compose."
|
||||
}
|
||||
|
||||
show_banner
|
||||
|
||||
case "${REQUESTED_VARIANT}" in
|
||||
""|cpu|cuda|cuda126) ;;
|
||||
*) error "Invalid --variant='${REQUESTED_VARIANT}'. Use cpu, cuda, or cuda126." ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${GPU_COUNT}" && ! "${GPU_COUNT}" =~ ^([0-9]+|all)$ ]]; then
|
||||
error "Invalid --gpu-count='${GPU_COUNT}'. Use a number or 'all'."
|
||||
fi
|
||||
|
||||
resolve_watchtower_preference() {
|
||||
if $WATCHTOWER_EXPLICIT || $QUIET || [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local choice
|
||||
echo "" > /dev/tty
|
||||
printf "${BOLD}${CYAN}Automatic updates${NC}\n" > /dev/tty
|
||||
printf "Enable automatic daily updates with Watchtower? (may download several GB in the background) [Y/n]: " > /dev/tty
|
||||
read -r choice < /dev/tty || choice=""
|
||||
|
||||
case "$choice" in
|
||||
""|[Yy]|[Yy][Ee][Ss]) SETUP_WATCHTOWER=true ;;
|
||||
[Nn]|[Nn][Oo]) SETUP_WATCHTOWER=false ;;
|
||||
*) warn "Unrecognized choice '${choice}', enabling Watchtower by default. Use --no-watchtower to skip it." >&2; SETUP_WATCHTOWER=true ;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_watchtower_preference
|
||||
|
||||
# ── Pre-flight checks ────────────────────────────────────────────────────────
|
||||
|
||||
step "Checking prerequisites"
|
||||
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| error "Docker is not installed. Install it at: https://docs.docker.com/get-docker/"
|
||||
success "Docker found."
|
||||
|
||||
docker info >/dev/null 2>&1 < /dev/null \
|
||||
|| error "Docker daemon is not running. Please start Docker and try again."
|
||||
success "Docker daemon is running."
|
||||
|
||||
if docker compose version >/dev/null 2>&1 < /dev/null; then
|
||||
DC="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DC="docker-compose"
|
||||
else
|
||||
error "Docker Compose is not installed. Install it at: https://docs.docker.com/compose/install/"
|
||||
fi
|
||||
success "Docker Compose found ($DC)."
|
||||
|
||||
# ── Wait-for-postgres helper ─────────────────────────────────────────────────
|
||||
wait_for_pg() {
|
||||
local db_user="$1"
|
||||
local max_attempts=45
|
||||
local attempt=0
|
||||
|
||||
info "Waiting for PostgreSQL to accept connections..."
|
||||
until (cd "${INSTALL_DIR}" && ${DC} exec -T db pg_isready -U "${db_user}" -q 2>/dev/null) < /dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge $max_attempts ]]; then
|
||||
error "PostgreSQL did not become ready after $((max_attempts * 2)) seconds.\nCheck logs: cd ${INSTALL_DIR} && ${DC} logs db"
|
||||
fi
|
||||
printf "."
|
||||
sleep 2
|
||||
done
|
||||
printf "\n"
|
||||
success "PostgreSQL is ready."
|
||||
}
|
||||
|
||||
# ── Stack startup helper ─────────────────────────────────────────────────────
|
||||
|
||||
stack_failure_report() {
|
||||
echo ""
|
||||
echo -e "\033[31m[ERROR]\033[0m Stack did not reach a healthy state."
|
||||
echo ""
|
||||
info "Recent logs from migrations / zero-cache / backend:"
|
||||
(cd "${INSTALL_DIR}" && ${DC} logs --tail=60 migrations zero-cache backend 2>&1) || true
|
||||
echo ""
|
||||
echo "Recovery hints:"
|
||||
echo " 1. Inspect migrations: cd ${INSTALL_DIR} && ${DC} logs migrations"
|
||||
echo " 2. Verify publication: cd ${INSTALL_DIR} && ${DC} exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
|
||||
echo " 3. Hard reset zero db: cd ${INSTALL_DIR} && ${DC} down && docker volume rm surfsense-zero-cache && ${DC} up -d --wait"
|
||||
echo ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
compose_up_wait() {
|
||||
local service="${1:-}"
|
||||
if [[ -n "$service" ]]; then
|
||||
(cd "${INSTALL_DIR}" && ${DC} up -d --wait "$service") < /dev/null
|
||||
else
|
||||
(cd "${INSTALL_DIR}" && ${DC} up -d --wait) < /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Variant and .env helpers ─────────────────────────────────────────────────
|
||||
|
||||
set_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
|
||||
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
||||
awk -v key="$key" -v value="$value" 'BEGIN { prefix = key "=" } $0 ~ "^" prefix { print prefix value; next } { print }' "$file" > "$tmp"
|
||||
else
|
||||
cp "$file" "$tmp"
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
fi
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
remove_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
awk -v key="$key" 'BEGIN { prefix = key "=" } $0 !~ "^" prefix { print }' "$file" > "$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
version_major() {
|
||||
printf '%s' "$1" | cut -d. -f1
|
||||
}
|
||||
|
||||
recommend_cuda_variant() {
|
||||
local driver_version driver_major
|
||||
driver_version=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n 1 | tr -d '[:space:]' || true)
|
||||
driver_major=$(version_major "$driver_version")
|
||||
|
||||
# CUDA 12.8 generally requires an R570+ driver. Use CUDA 12.6 as the
|
||||
# compatibility fallback for older 12.x driver stacks and GPUs.
|
||||
if [[ "$driver_major" =~ ^[0-9]+$ && "$driver_major" -lt 570 ]]; then
|
||||
printf 'cuda126'
|
||||
else
|
||||
printf 'cuda'
|
||||
fi
|
||||
}
|
||||
|
||||
gpu_runtime_available() {
|
||||
docker info 2>/dev/null | grep -qi 'nvidia' \
|
||||
|| command -v nvidia-ctk >/dev/null 2>&1 \
|
||||
|| command -v nvidia-container-runtime >/dev/null 2>&1
|
||||
}
|
||||
|
||||
host_has_nvidia_gpu() {
|
||||
command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1
|
||||
}
|
||||
|
||||
resolve_variant() {
|
||||
local detected_variant="cpu"
|
||||
local has_gpu=false
|
||||
local has_runtime=false
|
||||
|
||||
if host_has_nvidia_gpu; then
|
||||
has_gpu=true
|
||||
detected_variant=$(recommend_cuda_variant)
|
||||
if gpu_runtime_available; then
|
||||
has_runtime=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if $VARIANT_EXPLICIT; then
|
||||
if [[ "$REQUESTED_VARIANT" == "cpu" ]]; then
|
||||
printf 'cpu'
|
||||
return 0
|
||||
fi
|
||||
if ! $has_gpu; then
|
||||
warn "No NVIDIA GPU detected; falling back to CPU variant." >&2
|
||||
printf 'cpu'
|
||||
return 0
|
||||
fi
|
||||
if ! $has_runtime; then
|
||||
warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; falling back to CPU variant." >&2
|
||||
warn "Install the toolkit before enabling SurfSense GPU acceleration." >&2
|
||||
printf 'cpu'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$REQUESTED_VARIANT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if $has_gpu && ! $has_runtime; then
|
||||
warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; using CPU variant." >&2
|
||||
fi
|
||||
|
||||
if $has_gpu && $has_runtime && ! $QUIET && [[ -r /dev/tty && -w /dev/tty ]]; then
|
||||
local choice
|
||||
echo "" > /dev/tty
|
||||
printf "${BOLD}${CYAN}SurfSense detected an NVIDIA GPU.${NC}\n" > /dev/tty
|
||||
printf "Use GPU acceleration? [Y/n]: " > /dev/tty
|
||||
read -r choice < /dev/tty || choice=""
|
||||
case "$choice" in
|
||||
"") printf '%s' "$detected_variant" ;;
|
||||
[Yy]|[Yy][Ee][Ss]) printf '%s' "$detected_variant" ;;
|
||||
[Nn]|[Nn][Oo]) printf 'cpu' ;;
|
||||
*) warn "Unrecognized choice '${choice}', using CPU variant." >&2; printf 'cpu' ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'cpu'
|
||||
}
|
||||
|
||||
apply_variant_env() {
|
||||
local env_file="$1"
|
||||
local variant="$2"
|
||||
local allow_existing_update="$3"
|
||||
|
||||
if [[ -f "$env_file" && "$allow_existing_update" != "true" ]]; then
|
||||
warn ".env already exists — keeping your existing configuration."
|
||||
info "To change variants later, edit SURFSENSE_VARIANT and COMPOSE_FILE in ${env_file}, then run ${DC} up -d --wait."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$variant" == "cpu" ]]; then
|
||||
set_env_value "$env_file" "SURFSENSE_VARIANT" ""
|
||||
remove_env_value "$env_file" "COMPOSE_FILE"
|
||||
remove_env_value "$env_file" "SURFSENSE_GPU_COUNT"
|
||||
else
|
||||
set_env_value "$env_file" "SURFSENSE_VARIANT" "$variant"
|
||||
set_env_value "$env_file" "COMPOSE_FILE" "docker-compose.yml:docker-compose.gpu.yml"
|
||||
if [[ -n "$GPU_COUNT" ]]; then
|
||||
set_env_value "$env_file" "SURFSENSE_GPU_COUNT" "$GPU_COUNT"
|
||||
fi
|
||||
fi
|
||||
|
||||
remove_env_value "$env_file" "COMPOSE_PROFILES"
|
||||
}
|
||||
|
||||
SELECTED_VARIANT=$(resolve_variant)
|
||||
|
||||
# ── Download files ───────────────────────────────────────────────────────────
|
||||
|
||||
step "Downloading SurfSense files"
|
||||
info "Installation directory: ${INSTALL_DIR}"
|
||||
mkdir -p "${INSTALL_DIR}/scripts"
|
||||
mkdir -p "${INSTALL_DIR}/proxy"
|
||||
|
||||
FILES=(
|
||||
"docker/docker-compose.yml:docker-compose.yml"
|
||||
"docker/docker-compose.gpu.yml:docker-compose.gpu.yml"
|
||||
"docker/.env.example:.env.example"
|
||||
"docker/proxy/Caddyfile:proxy/Caddyfile"
|
||||
"docker/postgresql.conf:postgresql.conf"
|
||||
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
|
||||
)
|
||||
|
||||
for entry in "${FILES[@]}"; do
|
||||
src="${entry%%:*}"
|
||||
dest="${entry##*:}"
|
||||
info "Downloading ${dest}..."
|
||||
curl -fsSL "${REPO_RAW}/${src}" -o "${INSTALL_DIR}/${dest}" \
|
||||
|| error "Failed to download ${dest}. Check your internet connection and try again."
|
||||
done
|
||||
|
||||
chmod +x "${INSTALL_DIR}/scripts/migrate-database.sh"
|
||||
success "All files downloaded to ${INSTALL_DIR}/"
|
||||
|
||||
# ── Legacy all-in-one detection ──────────────────────────────────────────────
|
||||
# Detect surfsense-data volume → migration mode.
|
||||
# If a dump already exists (from a previous partial run) skip extraction and
|
||||
# go straight to restore — this makes re-runs safe and idempotent.
|
||||
|
||||
if docker volume ls --format '{{.Name}}' 2>/dev/null < /dev/null | grep -q "^${OLD_VOLUME}$" \
|
||||
&& [[ ! -f "${MIGRATION_DONE_FILE}" ]]; then
|
||||
MIGRATION_MODE=true
|
||||
|
||||
if [[ -f "${DUMP_FILE}" ]]; then
|
||||
step "Migration mode — using existing dump (skipping extraction)"
|
||||
info "Found existing dump: ${DUMP_FILE}"
|
||||
info "Skipping data extraction — proceeding directly to restore."
|
||||
info "To force a fresh extraction, remove the dump first: rm ${DUMP_FILE}"
|
||||
else
|
||||
step "Migration mode — legacy all-in-one container detected"
|
||||
warn "Volume '${OLD_VOLUME}' found. Your data will be migrated automatically."
|
||||
warn "PostgreSQL is being upgraded from version 14 to 17."
|
||||
warn "Your original data will NOT be deleted."
|
||||
printf "\n"
|
||||
info "Running data extraction (migrate-database.sh --yes)..."
|
||||
info "Full extraction log: ./surfsense-migration.log"
|
||||
printf "\n"
|
||||
|
||||
# Run extraction non-interactively. On failure the error from
|
||||
# migrate-database.sh is printed and install.sh exits here.
|
||||
bash "${INSTALL_DIR}/scripts/migrate-database.sh" --yes < /dev/null \
|
||||
|| error "Data extraction failed. See ./surfsense-migration.log for details.\nYou can also run migrate-database.sh manually with custom flags:\n bash ${INSTALL_DIR}/scripts/migrate-database.sh --db-user X --db-password Y"
|
||||
|
||||
printf "\n"
|
||||
success "Data extraction complete. Proceeding with installation and restore."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Set up .env ──────────────────────────────────────────────────────────────
|
||||
|
||||
step "Configuring environment"
|
||||
|
||||
if [ ! -f "${INSTALL_DIR}/.env" ]; then
|
||||
cp "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
|
||||
|
||||
if $MIGRATION_MODE && [[ -f "${KEY_FILE}" ]]; then
|
||||
SECRET_KEY=$(cat "${KEY_FILE}" | tr -d '[:space:]')
|
||||
success "Using SECRET_KEY recovered from legacy container."
|
||||
else
|
||||
SECRET_KEY=$(openssl rand -base64 32 2>/dev/null \
|
||||
|| head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||||
success "Generated new random SECRET_KEY."
|
||||
fi
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s|SECRET_KEY=replace_me_with_a_random_string|SECRET_KEY=${SECRET_KEY}|" "${INSTALL_DIR}/.env"
|
||||
else
|
||||
sed -i "s|SECRET_KEY=replace_me_with_a_random_string|SECRET_KEY=${SECRET_KEY}|" "${INSTALL_DIR}/.env"
|
||||
fi
|
||||
apply_variant_env "${INSTALL_DIR}/.env" "$SELECTED_VARIANT" "false"
|
||||
info "Created ${INSTALL_DIR}/.env"
|
||||
else
|
||||
if $VARIANT_EXPLICIT; then
|
||||
apply_variant_env "${INSTALL_DIR}/.env" "$SELECTED_VARIANT" "true"
|
||||
info "Updated SurfSense image variant in existing ${INSTALL_DIR}/.env"
|
||||
else
|
||||
apply_variant_env "${INSTALL_DIR}/.env" "$SELECTED_VARIANT" "false"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Start containers ─────────────────────────────────────────────────────────
|
||||
|
||||
if $MIGRATION_MODE; then
|
||||
# Read DB credentials from .env (fall back to defaults from docker-compose.yml)
|
||||
DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
DB_PASS=$(grep '^DB_PASSWORD=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
DB_NAME=$(grep '^DB_NAME=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
DB_USER="${DB_USER:-surfsense}"
|
||||
DB_PASS="${DB_PASS:-surfsense}"
|
||||
DB_NAME="${DB_NAME:-surfsense}"
|
||||
|
||||
step "Starting PostgreSQL 17"
|
||||
(cd "${INSTALL_DIR}" && ${DC} up -d db) < /dev/null
|
||||
wait_for_pg "${DB_USER}"
|
||||
|
||||
step "Restoring database"
|
||||
[[ -f "${DUMP_FILE}" ]] \
|
||||
|| error "Dump file '${DUMP_FILE}' not found. The migration script may have failed.\n Check: ./surfsense-migration.log\n Or run manually: bash ${INSTALL_DIR}/scripts/migrate-database.sh --yes"
|
||||
info "Restoring dump into PostgreSQL 17 — this may take a while for large databases..."
|
||||
|
||||
RESTORE_ERR="/tmp/surfsense_restore_err.log"
|
||||
(cd "${INSTALL_DIR}" && ${DC} exec -T \
|
||||
-e PGPASSWORD="${DB_PASS}" \
|
||||
db psql -U "${DB_USER}" -d "${DB_NAME}" \
|
||||
>/dev/null 2>"${RESTORE_ERR}") < "${DUMP_FILE}" || true
|
||||
|
||||
# Surface real errors; ignore benign "already exists" noise from pg_dump headers
|
||||
FATAL_ERRORS=$(grep -i "^ERROR:" "${RESTORE_ERR}" \
|
||||
| grep -iv "already exists" \
|
||||
| grep -iv "multiple primary keys" \
|
||||
|| true)
|
||||
|
||||
if [[ -n "${FATAL_ERRORS}" ]]; then
|
||||
warn "Restore completed with errors (may be harmless pg_dump header noise):"
|
||||
printf "%s\n" "${FATAL_ERRORS}"
|
||||
warn "If SurfSense behaves incorrectly, inspect manually:"
|
||||
warn " cd ${INSTALL_DIR} && ${DC} exec db psql -U ${DB_USER} -d ${DB_NAME} < ${DUMP_FILE}"
|
||||
else
|
||||
success "Database restored with no fatal errors."
|
||||
fi
|
||||
|
||||
# Smoke test — verify tables are present
|
||||
TABLE_COUNT=$(
|
||||
cd "${INSTALL_DIR}" && ${DC} exec -T \
|
||||
-e PGPASSWORD="${DB_PASS}" \
|
||||
db psql -U "${DB_USER}" -d "${DB_NAME}" -t \
|
||||
-c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" \
|
||||
2>/dev/null < /dev/null | tr -d ' \n' || echo "0"
|
||||
)
|
||||
if [[ "${TABLE_COUNT}" == "0" || -z "${TABLE_COUNT}" ]]; then
|
||||
warn "Smoke test: no tables found after restore."
|
||||
warn "The restore may have failed silently. Check: cd ${INSTALL_DIR} && ${DC} logs db"
|
||||
else
|
||||
success "Smoke test passed: ${TABLE_COUNT} table(s) restored successfully."
|
||||
touch "${MIGRATION_DONE_FILE}"
|
||||
fi
|
||||
|
||||
step "Starting all SurfSense services"
|
||||
if ! compose_up_wait; then
|
||||
stack_failure_report
|
||||
fi
|
||||
success "All services started and healthy."
|
||||
|
||||
# Key file is no longer needed — SECRET_KEY is now in .env
|
||||
rm -f "${KEY_FILE}"
|
||||
|
||||
else
|
||||
step "Starting SurfSense"
|
||||
if ! compose_up_wait; then
|
||||
stack_failure_report
|
||||
fi
|
||||
success "All services started and healthy."
|
||||
fi
|
||||
|
||||
# ── Watchtower (auto-update) ─────────────────────────────────────────────────
|
||||
|
||||
if $SETUP_WATCHTOWER; then
|
||||
step "Setting up Watchtower (auto-updates every $((WATCHTOWER_INTERVAL / 3600))h)"
|
||||
|
||||
WT_STATE=$(docker inspect -f '{{.State.Running}}' "${WATCHTOWER_CONTAINER}" 2>/dev/null < /dev/null || echo "missing")
|
||||
|
||||
if [[ "${WT_STATE}" == "true" ]]; then
|
||||
success "Watchtower is already running — skipping."
|
||||
else
|
||||
if [[ "${WT_STATE}" != "missing" ]]; then
|
||||
info "Removing stopped Watchtower container..."
|
||||
docker rm -f "${WATCHTOWER_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
fi
|
||||
docker run -d \
|
||||
--name "${WATCHTOWER_CONTAINER}" \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
nickfedor/watchtower \
|
||||
--label-enable \
|
||||
--interval "${WATCHTOWER_INTERVAL}" >/dev/null 2>&1 < /dev/null \
|
||||
&& success "Watchtower started — labeled SurfSense containers will auto-update." \
|
||||
|| warn "Could not start Watchtower. You can set it up manually or use: docker compose pull && docker compose up -d --wait"
|
||||
fi
|
||||
else
|
||||
info "Skipping Watchtower setup (--no-watchtower flag)."
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
_version_display=$(grep '^SURFSENSE_VERSION=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
_version_display="${_version_display:-latest}"
|
||||
_variant_display=$(grep '^SURFSENSE_VARIANT=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
_variant_display="${_variant_display:-cpu}"
|
||||
step "SurfSense is now installed [${_version_display}]"
|
||||
|
||||
_public_url=$(grep '^SURFSENSE_PUBLIC_URL=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | head -1 || true)
|
||||
_public_url="${_public_url:-http://localhost:3929}"
|
||||
|
||||
info " SurfSense: ${_public_url}"
|
||||
info " Backend: ${_public_url}/api/v1"
|
||||
info " Zero sync: ${_public_url}/zero"
|
||||
info ""
|
||||
info " Config: ${INSTALL_DIR}/.env"
|
||||
info " Variant: ${_variant_display}"
|
||||
info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f"
|
||||
info " Stop: cd ${INSTALL_DIR} && ${DC} down"
|
||||
info " Update: cd ${INSTALL_DIR} && ${DC} pull && ${DC} up -d --wait"
|
||||
info ""
|
||||
|
||||
if $SETUP_WATCHTOWER; then
|
||||
info " Watchtower: auto-updates every $((WATCHTOWER_INTERVAL / 3600))h (disable: docker rm -f ${WATCHTOWER_CONTAINER})"
|
||||
else
|
||||
warn " Watchtower skipped. For auto-updates, re-run without --no-watchtower."
|
||||
fi
|
||||
info ""
|
||||
|
||||
if $MIGRATION_MODE; then
|
||||
warn " Migration complete! Open frontend and verify your data."
|
||||
warn " Once verified, clean up the legacy volume and migration files:"
|
||||
warn " docker volume rm ${OLD_VOLUME}"
|
||||
warn " rm ${DUMP_FILE}"
|
||||
warn " rm ${MIGRATION_DONE_FILE}"
|
||||
else
|
||||
warn " First startup may take a few minutes while images are pulled."
|
||||
warn " Edit ${INSTALL_DIR}/.env to configure API keys, OAuth, etc."
|
||||
fi
|
||||
|
||||
} # end main()
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,343 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Database Migration Script (Windows / PowerShell)
|
||||
#
|
||||
# Extracts data from the legacy all-in-one surfsense-data volume (PostgreSQL 14)
|
||||
# and saves it as a SQL dump + SECRET_KEY file ready for install.ps1 to restore.
|
||||
#
|
||||
# Usage:
|
||||
# .\migrate-database.ps1 [options]
|
||||
#
|
||||
# Options:
|
||||
# -DbUser USER Old PostgreSQL username (default: surfsense)
|
||||
# -DbPassword PASS Old PostgreSQL password (default: surfsense)
|
||||
# -DbName NAME Old PostgreSQL database (default: surfsense)
|
||||
# -Yes Skip all confirmation prompts
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker Desktop installed and running
|
||||
# - The legacy surfsense-data volume must exist
|
||||
# - ~500 MB free disk space for the dump file
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Stops any container using surfsense-data (to prevent corruption)
|
||||
# 2. Starts a temporary PG14 container against the old volume
|
||||
# 3. Dumps the database to .\surfsense_migration_backup.sql
|
||||
# 4. Recovers the SECRET_KEY to .\surfsense_migration_secret.key
|
||||
# 5. Exits — leaving installation to install.ps1
|
||||
#
|
||||
# What this script does NOT do:
|
||||
# - Delete the original surfsense-data volume (do this manually after verifying)
|
||||
# - Install the new SurfSense stack (install.ps1 handles that automatically)
|
||||
#
|
||||
# Note:
|
||||
# install.ps1 downloads and runs this script automatically when it detects the
|
||||
# legacy surfsense-data volume. You only need to run this script manually if
|
||||
# you have custom database credentials (-DbUser / -DbPassword / -DbName)
|
||||
# or if the automatic migration inside install.ps1 fails at the extraction step.
|
||||
# =============================================================================
|
||||
|
||||
param(
|
||||
[string]$DbUser = "surfsense",
|
||||
[string]$DbPassword = "surfsense",
|
||||
[string]$DbName = "surfsense",
|
||||
[switch]$Yes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
$OldVolume = "surfsense-data"
|
||||
$TempContainer = "surfsense-pg14-migration"
|
||||
$DumpFile = ".\surfsense_migration_backup.sql"
|
||||
$KeyFile = ".\surfsense_migration_secret.key"
|
||||
$PG14Image = "pgvector/pgvector:pg14"
|
||||
$LogFile = ".\surfsense-migration.log"
|
||||
|
||||
# ── Output helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Info { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Cyan -NoNewline; Write-Host $Msg }
|
||||
function Write-Ok { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Green -NoNewline; Write-Host $Msg }
|
||||
function Write-Warn { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Yellow -NoNewline; Write-Host $Msg }
|
||||
function Write-Step { param([string]$Step, [string]$Msg) Write-Host "`n-- Step ${Step}: $Msg" -ForegroundColor Cyan }
|
||||
function Write-Err { param([string]$Msg) Write-Host "[SurfSense] ERROR: $Msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
function Log { param([string]$Msg) Add-Content -Path $LogFile -Value $Msg }
|
||||
|
||||
function Invoke-NativeSafe {
|
||||
param([scriptblock]$Command)
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Command
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
|
||||
function Confirm-Action {
|
||||
param([string]$Prompt)
|
||||
if ($Yes) { return }
|
||||
$reply = Read-Host "[SurfSense] $Prompt [y/N]"
|
||||
if ($reply -notmatch '^[Yy]$') {
|
||||
Write-Warn "Aborted."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# ── Cleanup helper ───────────────────────────────────────────────────────────
|
||||
|
||||
function Remove-TempContainer {
|
||||
$containers = Invoke-NativeSafe { docker ps -a --format '{{.Names}}' 2>$null }
|
||||
if ($containers -and ($containers -split "`n") -contains $TempContainer) {
|
||||
Write-Info "Cleaning up temporary container '$TempContainer'..."
|
||||
Invoke-NativeSafe { docker stop $TempContainer *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm $TempContainer *>$null } | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Register cleanup on script exit
|
||||
Register-EngineEvent PowerShell.Exiting -Action {
|
||||
$containers = Invoke-NativeSafe { docker ps -a --format '{{.Names}}' 2>$null }
|
||||
if ($containers -and ($containers -split "`n") -contains "surfsense-pg14-migration") {
|
||||
Invoke-NativeSafe { docker stop "surfsense-pg14-migration" *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm "surfsense-pg14-migration" *>$null } | Out-Null
|
||||
}
|
||||
} | Out-Null
|
||||
|
||||
# ── Wait-for-postgres helper ────────────────────────────────────────────────
|
||||
|
||||
function Wait-ForPostgres {
|
||||
param(
|
||||
[string]$Container,
|
||||
[string]$User,
|
||||
[string]$Label = "PostgreSQL"
|
||||
)
|
||||
$maxAttempts = 45
|
||||
$attempt = 0
|
||||
|
||||
Write-Info "Waiting for $Label to accept connections..."
|
||||
do {
|
||||
$attempt++
|
||||
if ($attempt -ge $maxAttempts) {
|
||||
Write-Err "$Label did not become ready after $($maxAttempts * 2) seconds. Check: docker logs $Container"
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
Invoke-NativeSafe { docker exec $Container pg_isready -U $User -q 2>$null } | Out-Null
|
||||
} while ($LASTEXITCODE -ne 0)
|
||||
|
||||
Write-Ok "$Label is ready."
|
||||
}
|
||||
|
||||
Write-Info "Migrating data from legacy database (PostgreSQL 14 -> 17)"
|
||||
"Migration started at $(Get-Date)" | Out-File $LogFile
|
||||
|
||||
# ── Step 0: Pre-flight checks ───────────────────────────────────────────────
|
||||
|
||||
Write-Step "0" "Pre-flight checks"
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Write-Err "Docker is not installed. Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/"
|
||||
}
|
||||
|
||||
Invoke-NativeSafe { docker info *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Docker daemon is not running. Please start Docker Desktop and try again."
|
||||
}
|
||||
|
||||
$volumeList = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
|
||||
if (-not (($volumeList -split "`n") -contains $OldVolume)) {
|
||||
Write-Err "Legacy volume '$OldVolume' not found. Are you sure you ran the old all-in-one SurfSense container?"
|
||||
}
|
||||
Write-Ok "Found legacy volume: $OldVolume"
|
||||
|
||||
$oldContainer = (Invoke-NativeSafe { docker ps --filter "volume=$OldVolume" --format '{{.Names}}' 2>$null } | Select-Object -First 1)
|
||||
if ($oldContainer) {
|
||||
Write-Warn "Container '$oldContainer' is running and using the '$OldVolume' volume."
|
||||
Write-Warn "It must be stopped before migration to prevent data file corruption."
|
||||
Confirm-Action "Stop '$oldContainer' now and proceed with data extraction?"
|
||||
Invoke-NativeSafe { docker stop $oldContainer *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Failed to stop '$oldContainer'. Try: docker stop $oldContainer"
|
||||
}
|
||||
Write-Ok "Container '$oldContainer' stopped."
|
||||
}
|
||||
|
||||
if (Test-Path $DumpFile) {
|
||||
Write-Warn "Dump file '$DumpFile' already exists."
|
||||
Write-Warn "If a previous extraction succeeded, just run install.ps1 now."
|
||||
Write-Warn "To re-extract, remove the file first: Remove-Item $DumpFile"
|
||||
Write-Err "Aborting to avoid overwriting an existing dump."
|
||||
}
|
||||
|
||||
$staleContainers = Invoke-NativeSafe { docker ps -a --format '{{.Names}}' 2>$null }
|
||||
if ($staleContainers -and ($staleContainers -split "`n") -contains $TempContainer) {
|
||||
Write-Warn "Stale migration container '$TempContainer' found - removing it."
|
||||
Invoke-NativeSafe { docker stop $TempContainer *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm $TempContainer *>$null } | Out-Null
|
||||
}
|
||||
|
||||
$drive = (Get-Item .).PSDrive
|
||||
$freeMB = [math]::Floor($drive.Free / 1MB)
|
||||
if ($freeMB -lt 500) {
|
||||
Write-Warn "Low disk space: $freeMB MB free. At least 500 MB recommended for the dump."
|
||||
Confirm-Action "Continue anyway?"
|
||||
} else {
|
||||
Write-Ok "Disk space: $freeMB MB free."
|
||||
}
|
||||
|
||||
Write-Ok "All pre-flight checks passed."
|
||||
|
||||
# ── Confirmation prompt ──────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Extraction plan:" -ForegroundColor White
|
||||
Write-Host " Source volume : " -NoNewline; Write-Host "$OldVolume" -ForegroundColor Yellow -NoNewline; Write-Host " (PG14 data at /data/postgres)"
|
||||
Write-Host " Old credentials : user=" -NoNewline; Write-Host "$DbUser" -ForegroundColor Yellow -NoNewline; Write-Host " db=" -NoNewline; Write-Host "$DbName" -ForegroundColor Yellow
|
||||
Write-Host " Dump saved to : " -NoNewline; Write-Host "$DumpFile" -ForegroundColor Yellow
|
||||
Write-Host " SECRET_KEY to : " -NoNewline; Write-Host "$KeyFile" -ForegroundColor Yellow
|
||||
Write-Host " Log file : " -NoNewline; Write-Host "$LogFile" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Confirm-Action "Start data extraction? (Your original data will not be deleted or modified.)"
|
||||
|
||||
# ── Step 1: Start temporary PostgreSQL 14 container ──────────────────────────
|
||||
|
||||
Write-Step "1" "Starting temporary PostgreSQL 14 container"
|
||||
|
||||
Write-Info "Pulling $PG14Image..."
|
||||
Invoke-NativeSafe { docker pull $PG14Image *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "Could not pull $PG14Image - using cached image if available."
|
||||
}
|
||||
|
||||
$dataUid = Invoke-NativeSafe { docker run --rm -v "${OldVolume}:/data" alpine stat -c '%u' /data/postgres 2>$null }
|
||||
if (-not $dataUid -or $dataUid -eq "0") {
|
||||
Write-Warn "Could not detect data directory UID - falling back to default (may chown files)."
|
||||
$userFlag = @()
|
||||
} else {
|
||||
Write-Info "Data directory owned by UID $dataUid - starting temp container as that user."
|
||||
$userFlag = @("--user", $dataUid)
|
||||
}
|
||||
|
||||
$dockerRunArgs = @(
|
||||
"run", "-d",
|
||||
"--name", $TempContainer,
|
||||
"-v", "${OldVolume}:/data",
|
||||
"-e", "PGDATA=/data/postgres",
|
||||
"-e", "POSTGRES_USER=$DbUser",
|
||||
"-e", "POSTGRES_PASSWORD=$DbPassword",
|
||||
"-e", "POSTGRES_DB=$DbName"
|
||||
) + $userFlag + @($PG14Image)
|
||||
|
||||
Invoke-NativeSafe { docker @dockerRunArgs *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Failed to start temporary PostgreSQL 14 container."
|
||||
}
|
||||
|
||||
Write-Ok "Temporary container '$TempContainer' started."
|
||||
Wait-ForPostgres -Container $TempContainer -User $DbUser -Label "PostgreSQL 14"
|
||||
|
||||
# ── Step 2: Dump the database ────────────────────────────────────────────────
|
||||
|
||||
Write-Step "2" "Dumping PostgreSQL 14 database"
|
||||
|
||||
Write-Info "Running pg_dump - this may take a while for large databases..."
|
||||
|
||||
$pgDumpErrFile = Join-Path $env:TEMP "surfsense_pgdump_err.log"
|
||||
Invoke-NativeSafe { docker exec -e "PGPASSWORD=$DbPassword" $TempContainer pg_dump -U $DbUser --no-password $DbName > $DumpFile 2>$pgDumpErrFile } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if (Test-Path $pgDumpErrFile) { Get-Content $pgDumpErrFile | Write-Host -ForegroundColor Red }
|
||||
Remove-TempContainer
|
||||
Write-Err "pg_dump failed. See above for details."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $DumpFile) -or (Get-Item $DumpFile).Length -eq 0) {
|
||||
Remove-TempContainer
|
||||
Write-Err "Dump file '$DumpFile' is empty. Something went wrong with pg_dump."
|
||||
}
|
||||
|
||||
$dumpContent = (Get-Content $DumpFile -TotalCount 5) -join "`n"
|
||||
if ($dumpContent -notmatch "PostgreSQL database dump") {
|
||||
Remove-TempContainer
|
||||
Write-Err "Dump file does not contain a valid PostgreSQL dump header - the file may be corrupt."
|
||||
}
|
||||
|
||||
$dumpLines = (Get-Content $DumpFile | Measure-Object -Line).Lines
|
||||
if ($dumpLines -lt 10) {
|
||||
Remove-TempContainer
|
||||
Write-Err "Dump has only $dumpLines lines - suspiciously small. Aborting."
|
||||
}
|
||||
|
||||
$dumpSize = "{0:N1} MB" -f ((Get-Item $DumpFile).Length / 1MB)
|
||||
Write-Ok "Dump complete: $dumpSize ($dumpLines lines) -> $DumpFile"
|
||||
|
||||
Write-Info "Stopping temporary PostgreSQL 14 container..."
|
||||
Invoke-NativeSafe { docker stop $TempContainer *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm $TempContainer *>$null } | Out-Null
|
||||
Write-Ok "Temporary container removed."
|
||||
|
||||
# ── Step 3: Recover SECRET_KEY ───────────────────────────────────────────────
|
||||
|
||||
Write-Step "3" "Recovering SECRET_KEY"
|
||||
|
||||
$recoveredKey = ""
|
||||
|
||||
$keyCheck = Invoke-NativeSafe { docker run --rm -v "${OldVolume}:/data" alpine sh -c 'test -f /data/.secret_key && cat /data/.secret_key' 2>$null }
|
||||
if ($LASTEXITCODE -eq 0 -and $keyCheck) {
|
||||
$recoveredKey = $keyCheck.Trim()
|
||||
Write-Ok "Recovered SECRET_KEY from '$OldVolume'."
|
||||
} else {
|
||||
Write-Warn "No SECRET_KEY file found at /data/.secret_key in '$OldVolume'."
|
||||
Write-Warn "This means the all-in-one container was launched with SECRET_KEY set as an explicit env var."
|
||||
|
||||
if ($Yes) {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||
$rng.GetBytes($bytes)
|
||||
$rng.Dispose()
|
||||
$recoveredKey = [Convert]::ToBase64String($bytes)
|
||||
Write-Warn "Non-interactive mode: generated a new SECRET_KEY automatically."
|
||||
Write-Warn "All active browser sessions will be logged out after migration."
|
||||
Write-Warn "To restore your original key, update SECRET_KEY in .\surfsense\.env afterwards."
|
||||
} else {
|
||||
Write-Warn "Enter the SECRET_KEY from your old container's environment"
|
||||
$recoveredKey = Read-Host "[SurfSense] (press Enter to generate a new one - existing sessions will be invalidated)"
|
||||
if (-not $recoveredKey) {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||
$rng.GetBytes($bytes)
|
||||
$rng.Dispose()
|
||||
$recoveredKey = [Convert]::ToBase64String($bytes)
|
||||
Write-Warn "Generated a new SECRET_KEY. All active browser sessions will be logged out after migration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set-Content -Path $KeyFile -Value $recoveredKey -NoNewline
|
||||
Write-Ok "SECRET_KEY saved to $KeyFile"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 62) -ForegroundColor Green
|
||||
Write-Host " Data extraction complete!" -ForegroundColor Green
|
||||
Write-Host ("=" * 62) -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
Write-Ok "Dump file : $DumpFile ($dumpSize)"
|
||||
Write-Ok "Secret key: $KeyFile"
|
||||
Write-Host ""
|
||||
Write-Info "Next step - run install.ps1 from this same directory:"
|
||||
Write-Host ""
|
||||
Write-Host " irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Info "install.ps1 will detect the dump, restore your data into PostgreSQL 17,"
|
||||
Write-Info "and start the full SurfSense stack automatically."
|
||||
Write-Host ""
|
||||
Write-Warn "Keep both files until you have verified the migration:"
|
||||
Write-Warn " $DumpFile"
|
||||
Write-Warn " $KeyFile"
|
||||
Write-Warn "Full log saved to: $LogFile"
|
||||
Write-Host ""
|
||||
|
||||
Log "Migration extraction completed successfully at $(Get-Date)"
|
||||
Executable
+335
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# SurfSense — Database Migration Script
|
||||
#
|
||||
# Extracts data from the legacy all-in-one surfsense-data volume (PostgreSQL 14)
|
||||
# and saves it as a SQL dump + SECRET_KEY file ready for install.sh to restore.
|
||||
#
|
||||
# Usage:
|
||||
# bash migrate-database.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --db-user USER Old PostgreSQL username (default: surfsense)
|
||||
# --db-password PASS Old PostgreSQL password (default: surfsense)
|
||||
# --db-name NAME Old PostgreSQL database (default: surfsense)
|
||||
# --yes / -y Skip all confirmation prompts
|
||||
# --help / -h Show this help
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker installed and running
|
||||
# - The legacy surfsense-data volume must exist
|
||||
# - ~500 MB free disk space for the dump file
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Stops any container using surfsense-data (to prevent corruption)
|
||||
# 2. Starts a temporary PG14 container against the old volume
|
||||
# 3. Dumps the database to ./surfsense_migration_backup.sql
|
||||
# 4. Recovers the SECRET_KEY to ./surfsense_migration_secret.key
|
||||
# 5. Exits — leaving installation to install.sh
|
||||
#
|
||||
# What this script does NOT do:
|
||||
# - Delete the original surfsense-data volume (do this manually after verifying)
|
||||
# - Install the new SurfSense stack (install.sh handles that automatically)
|
||||
#
|
||||
# Note:
|
||||
# install.sh downloads and runs this script automatically when it detects the
|
||||
# legacy surfsense-data volume. You only need to run this script manually if
|
||||
# you have custom database credentials (--db-user / --db-password / --db-name)
|
||||
# or if the automatic migration inside install.sh fails at the extraction step.
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
CYAN='\033[1;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ── Logging — tee everything to a log file ───────────────────────────────────
|
||||
LOG_FILE="./surfsense-migration.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
# ── Output helpers ────────────────────────────────────────────────────────────
|
||||
info() { printf "${CYAN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
success() { printf "${GREEN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
warn() { printf "${YELLOW}[SurfSense]${NC} %s\n" "$1"; }
|
||||
error() { printf "${RED}[SurfSense]${NC} ERROR: %s\n" "$1" >&2; exit 1; }
|
||||
step() { printf "\n${BOLD}${CYAN}── Step %s: %s${NC}\n" "$1" "$2"; }
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
OLD_VOLUME="surfsense-data"
|
||||
TEMP_CONTAINER="surfsense-pg14-migration"
|
||||
DUMP_FILE="./surfsense_migration_backup.sql"
|
||||
KEY_FILE="./surfsense_migration_secret.key"
|
||||
PG14_IMAGE="pgvector/pgvector:pg14"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
OLD_DB_USER="surfsense"
|
||||
OLD_DB_PASSWORD="surfsense"
|
||||
OLD_DB_NAME="surfsense"
|
||||
AUTO_YES=false
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--db-user) OLD_DB_USER="$2"; shift 2 ;;
|
||||
--db-password) OLD_DB_PASSWORD="$2"; shift 2 ;;
|
||||
--db-name) OLD_DB_NAME="$2"; shift 2 ;;
|
||||
--yes|-y) AUTO_YES=true; shift ;;
|
||||
--help|-h)
|
||||
grep '^#' "$0" | grep -v '^#!/' | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) error "Unknown option: $1 — run with --help for usage." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Confirmation helper ───────────────────────────────────────────────────────
|
||||
confirm() {
|
||||
if $AUTO_YES; then return 0; fi
|
||||
printf "${YELLOW}[SurfSense]${NC} %s [y/N] " "$1"
|
||||
read -r reply
|
||||
[[ "$reply" =~ ^[Yy]$ ]] || { warn "Aborted."; exit 0; }
|
||||
}
|
||||
|
||||
# ── Cleanup trap — always remove the temp container ──────────────────────────
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if docker ps -a --format '{{.Names}}' 2>/dev/null < /dev/null | grep -q "^${TEMP_CONTAINER}$"; then
|
||||
info "Cleaning up temporary container '${TEMP_CONTAINER}'..."
|
||||
docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
fi
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
printf "\n${RED}[SurfSense]${NC} Migration data extraction failed (exit code %s).\n" "${exit_code}" >&2
|
||||
printf "${RED}[SurfSense]${NC} Full log: %s\n" "${LOG_FILE}" >&2
|
||||
printf "${YELLOW}[SurfSense]${NC} Your original data in '${OLD_VOLUME}' is untouched.\n" >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Wait-for-postgres helper ──────────────────────────────────────────────────
|
||||
wait_for_pg() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local label="${3:-PostgreSQL}"
|
||||
local max_attempts=45
|
||||
local attempt=0
|
||||
|
||||
info "Waiting for ${label} to accept connections..."
|
||||
until docker exec "${container}" pg_isready -U "${user}" -q 2>/dev/null < /dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge $max_attempts ]]; then
|
||||
error "${label} did not become ready after $((max_attempts * 2)) seconds. Check: docker logs ${container}"
|
||||
fi
|
||||
printf "."
|
||||
sleep 2
|
||||
done
|
||||
printf "\n"
|
||||
success "${label} is ready."
|
||||
}
|
||||
|
||||
info "Migrating data from legacy database (PostgreSQL 14 → 17)"
|
||||
|
||||
# ── Step 0: Pre-flight checks ─────────────────────────────────────────────────
|
||||
step "0" "Pre-flight checks"
|
||||
|
||||
# Docker CLI
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| error "Docker is not installed. Install it at: https://docs.docker.com/get-docker/"
|
||||
|
||||
# Docker daemon
|
||||
docker info >/dev/null 2>&1 < /dev/null \
|
||||
|| error "Docker daemon is not running. Please start Docker and try again."
|
||||
|
||||
# Old volume must exist
|
||||
docker volume ls --format '{{.Name}}' < /dev/null | grep -q "^${OLD_VOLUME}$" \
|
||||
|| error "Legacy volume '${OLD_VOLUME}' not found.\n Are you sure you ran the old all-in-one SurfSense container?"
|
||||
success "Found legacy volume: ${OLD_VOLUME}"
|
||||
|
||||
# Detect and stop any container currently using the old volume
|
||||
# (mounting a live PG volume into a second container causes the new container's
|
||||
# entrypoint to chown the data files, breaking the running container's access)
|
||||
OLD_CONTAINER=$(docker ps --filter "volume=${OLD_VOLUME}" --format '{{.Names}}' < /dev/null | head -n1 || true)
|
||||
if [[ -n "${OLD_CONTAINER}" ]]; then
|
||||
warn "Container '${OLD_CONTAINER}' is running and using the '${OLD_VOLUME}' volume."
|
||||
warn "It must be stopped before migration to prevent data file corruption."
|
||||
confirm "Stop '${OLD_CONTAINER}' now and proceed with data extraction?"
|
||||
docker stop "${OLD_CONTAINER}" >/dev/null 2>&1 < /dev/null \
|
||||
|| error "Failed to stop '${OLD_CONTAINER}'. Try: docker stop ${OLD_CONTAINER}"
|
||||
success "Container '${OLD_CONTAINER}' stopped."
|
||||
fi
|
||||
|
||||
# Bail out if a dump already exists — don't overwrite a previous successful run
|
||||
if [[ -f "${DUMP_FILE}" ]]; then
|
||||
warn "Dump file '${DUMP_FILE}' already exists."
|
||||
warn "If a previous extraction succeeded, just run install.sh now."
|
||||
warn "To re-extract, remove the file first: rm ${DUMP_FILE}"
|
||||
error "Aborting to avoid overwriting an existing dump."
|
||||
fi
|
||||
|
||||
# Clean up any stale temp container from a previous failed run
|
||||
if docker ps -a --format '{{.Names}}' < /dev/null | grep -q "^${TEMP_CONTAINER}$"; then
|
||||
warn "Stale migration container '${TEMP_CONTAINER}' found — removing it."
|
||||
docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
fi
|
||||
|
||||
# Disk space (warn if < 500 MB free)
|
||||
if command -v df >/dev/null 2>&1; then
|
||||
FREE_KB=$(df -k . | awk 'NR==2 {print $4}')
|
||||
FREE_MB=$(( FREE_KB / 1024 ))
|
||||
if [[ $FREE_MB -lt 500 ]]; then
|
||||
warn "Low disk space: ${FREE_MB} MB free. At least 500 MB recommended for the dump."
|
||||
confirm "Continue anyway?"
|
||||
else
|
||||
success "Disk space: ${FREE_MB} MB free."
|
||||
fi
|
||||
fi
|
||||
|
||||
success "All pre-flight checks passed."
|
||||
|
||||
# ── Confirmation prompt ───────────────────────────────────────────────────────
|
||||
printf "\n${BOLD}Extraction plan:${NC}\n"
|
||||
printf " Source volume : ${YELLOW}%s${NC} (PG14 data at /data/postgres)\n" "${OLD_VOLUME}"
|
||||
printf " Old credentials : user=${YELLOW}%s${NC} db=${YELLOW}%s${NC}\n" "${OLD_DB_USER}" "${OLD_DB_NAME}"
|
||||
printf " Dump saved to : ${YELLOW}%s${NC}\n" "${DUMP_FILE}"
|
||||
printf " SECRET_KEY to : ${YELLOW}%s${NC}\n" "${KEY_FILE}"
|
||||
printf " Log file : ${YELLOW}%s${NC}\n\n" "${LOG_FILE}"
|
||||
confirm "Start data extraction? (Your original data will not be deleted or modified.)"
|
||||
|
||||
# ── Step 1: Start temporary PostgreSQL 14 container ──────────────────────────
|
||||
step "1" "Starting temporary PostgreSQL 14 container"
|
||||
|
||||
info "Pulling ${PG14_IMAGE}..."
|
||||
docker pull "${PG14_IMAGE}" >/dev/null 2>&1 < /dev/null \
|
||||
|| warn "Could not pull ${PG14_IMAGE} — using cached image if available."
|
||||
|
||||
# Detect the UID that owns the existing data files and run the temp container
|
||||
# as that user. This prevents the official postgres image entrypoint from
|
||||
# running as root and doing `chown -R postgres /data/postgres`, which would
|
||||
# re-own the files to UID 999 and break any subsequent access by the original
|
||||
# container's postgres process (which may run as a different UID).
|
||||
DATA_UID=$(docker run --rm -v "${OLD_VOLUME}:/data" alpine \
|
||||
stat -c '%u' /data/postgres 2>/dev/null < /dev/null || echo "")
|
||||
if [[ -z "${DATA_UID}" || "${DATA_UID}" == "0" ]]; then
|
||||
warn "Could not detect data directory UID — falling back to default (may chown files)."
|
||||
USER_FLAG=""
|
||||
else
|
||||
info "Data directory owned by UID ${DATA_UID} — starting temp container as that user."
|
||||
USER_FLAG="--user ${DATA_UID}"
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name "${TEMP_CONTAINER}" \
|
||||
-v "${OLD_VOLUME}:/data" \
|
||||
-e PGDATA=/data/postgres \
|
||||
-e POSTGRES_USER="${OLD_DB_USER}" \
|
||||
-e POSTGRES_PASSWORD="${OLD_DB_PASSWORD}" \
|
||||
-e POSTGRES_DB="${OLD_DB_NAME}" \
|
||||
${USER_FLAG} \
|
||||
"${PG14_IMAGE}" >/dev/null < /dev/null
|
||||
|
||||
success "Temporary container '${TEMP_CONTAINER}' started."
|
||||
wait_for_pg "${TEMP_CONTAINER}" "${OLD_DB_USER}" "PostgreSQL 14"
|
||||
|
||||
# ── Step 2: Dump the database ─────────────────────────────────────────────────
|
||||
step "2" "Dumping PostgreSQL 14 database"
|
||||
|
||||
info "Running pg_dump — this may take a while for large databases..."
|
||||
|
||||
if ! docker exec \
|
||||
-e PGPASSWORD="${OLD_DB_PASSWORD}" \
|
||||
"${TEMP_CONTAINER}" \
|
||||
pg_dump -U "${OLD_DB_USER}" --no-password "${OLD_DB_NAME}" \
|
||||
> "${DUMP_FILE}" 2>/tmp/surfsense_pgdump_err < /dev/null; then
|
||||
cat /tmp/surfsense_pgdump_err >&2
|
||||
error "pg_dump failed. See above for details."
|
||||
fi
|
||||
|
||||
# Validate: non-empty file
|
||||
[[ -s "${DUMP_FILE}" ]] \
|
||||
|| error "Dump file '${DUMP_FILE}' is empty. Something went wrong with pg_dump."
|
||||
|
||||
# Validate: looks like a real PG dump
|
||||
grep -q "PostgreSQL database dump" "${DUMP_FILE}" \
|
||||
|| error "Dump file does not contain a valid PostgreSQL dump header — the file may be corrupt."
|
||||
|
||||
# Validate: sanity-check line count
|
||||
DUMP_LINES=$(wc -l < "${DUMP_FILE}" | tr -d ' ')
|
||||
[[ $DUMP_LINES -ge 10 ]] \
|
||||
|| error "Dump has only ${DUMP_LINES} lines — suspiciously small. Aborting."
|
||||
|
||||
DUMP_SIZE=$(du -sh "${DUMP_FILE}" 2>/dev/null | cut -f1)
|
||||
success "Dump complete: ${DUMP_SIZE} (${DUMP_LINES} lines) → ${DUMP_FILE}"
|
||||
|
||||
# Stop the temp container (trap will also handle it on unexpected exit)
|
||||
info "Stopping temporary PostgreSQL 14 container..."
|
||||
docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
success "Temporary container removed."
|
||||
|
||||
# ── Step 3: Recover SECRET_KEY ────────────────────────────────────────────────
|
||||
step "3" "Recovering SECRET_KEY"
|
||||
|
||||
RECOVERED_KEY=""
|
||||
|
||||
if docker run --rm -v "${OLD_VOLUME}:/data" alpine \
|
||||
sh -c 'test -f /data/.secret_key && cat /data/.secret_key' \
|
||||
2>/dev/null < /dev/null | grep -q .; then
|
||||
RECOVERED_KEY=$(
|
||||
docker run --rm -v "${OLD_VOLUME}:/data" alpine \
|
||||
cat /data/.secret_key 2>/dev/null < /dev/null | tr -d '[:space:]'
|
||||
)
|
||||
success "Recovered SECRET_KEY from '${OLD_VOLUME}'."
|
||||
else
|
||||
warn "No SECRET_KEY file found at /data/.secret_key in '${OLD_VOLUME}'."
|
||||
warn "This means the all-in-one container was launched with SECRET_KEY set as an explicit env var."
|
||||
if $AUTO_YES; then
|
||||
# Non-interactive (called from install.sh) — auto-generate rather than hanging on read
|
||||
RECOVERED_KEY=$(openssl rand -base64 32 2>/dev/null \
|
||||
|| head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||||
warn "Non-interactive mode: generated a new SECRET_KEY automatically."
|
||||
warn "All active browser sessions will be logged out after migration."
|
||||
warn "To restore your original key, update SECRET_KEY in ./surfsense/.env afterwards."
|
||||
else
|
||||
printf "${YELLOW}[SurfSense]${NC} Enter the SECRET_KEY from your old container's environment\n"
|
||||
printf "${YELLOW}[SurfSense]${NC} (press Enter to generate a new one — existing sessions will be invalidated): "
|
||||
read -r RECOVERED_KEY
|
||||
if [[ -z "${RECOVERED_KEY}" ]]; then
|
||||
RECOVERED_KEY=$(openssl rand -base64 32 2>/dev/null \
|
||||
|| head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||||
warn "Generated a new SECRET_KEY. All active browser sessions will be logged out after migration."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Save SECRET_KEY to a file for install.sh to pick up
|
||||
printf '%s' "${RECOVERED_KEY}" > "${KEY_FILE}"
|
||||
success "SECRET_KEY saved to ${KEY_FILE}"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
printf "\n${GREEN}${BOLD}"
|
||||
printf "══════════════════════════════════════════════════════════════\n"
|
||||
printf " Data extraction complete!\n"
|
||||
printf "══════════════════════════════════════════════════════════════\n"
|
||||
printf "${NC}\n"
|
||||
|
||||
success "Dump file : ${DUMP_FILE} (${DUMP_SIZE})"
|
||||
success "Secret key: ${KEY_FILE}"
|
||||
printf "\n"
|
||||
info "Next step — run install.sh from this same directory:"
|
||||
printf "\n"
|
||||
printf "${CYAN} curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash${NC}\n"
|
||||
printf "\n"
|
||||
info "install.sh will detect the dump, restore your data into PostgreSQL 17,"
|
||||
info "and start the full SurfSense stack automatically."
|
||||
printf "\n"
|
||||
warn "Keep both files until you have verified the migration:"
|
||||
warn " ${DUMP_FILE}"
|
||||
warn " ${KEY_FILE}"
|
||||
warn "Full log saved to: ${LOG_FILE}"
|
||||
printf "\n"
|
||||
Reference in New Issue
Block a user