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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:33:44 +08:00
commit 6ede33ccdb
3841 changed files with 594049 additions and 0 deletions
+567
View File
@@ -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."
}
+568
View File
@@ -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 "$@"
+343
View File
@@ -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)"
+335
View File
@@ -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"