SurfSense/docker/scripts/install.ps1

230 lines
12 KiB
PowerShell

# SurfSense in Docker: installs it, and updates it when run again (docs/architecture/docker.md).
#
# irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
#
# The install lives in .\surfsense: docker-compose.yml, which each run replaces,
# and .env, which only the first run writes. Needs Docker Desktop.
function Install-SurfSense {
# A test points this at a local copy of the repository.
$Source = if ($env:SURFSENSE_INSTALL_SOURCE) { $env:SURFSENSE_INSTALL_SOURCE } else { 'https://raw.githubusercontent.com/MODSetter/SurfSense/main' }
# Another project's containers in "surfsense" are none of this install's business.
$env:COMPOSE_IGNORE_ORPHANS = '1'
# The app's dark palette (frontend-workflow's color/PALETTE.css), since a terminal is
# usually dark; success is green-500. Every Windows console with VT takes 24-bit colour.
$colour = $Host.UI.SupportsVirtualTerminal -and -not [Console]::IsOutputRedirected -and -not $env:NO_COLOR
function Get-Paint([int]$Red, [int]$Green, [int]$Blue) {
if ($colour) { return "$([char]27)[38;2;$Red;$Green;${Blue}m" } else { return '' }
}
$Primary = Get-Paint 217 170 144 # primary #d9aa90
$Foreground = Get-Paint 232 227 218 # foreground #e8e3da
$Muted = Get-Paint 142 138 131 # muted-foreground #8e8a83
$Success = Get-Paint 34 197 94 # green-500 #22c55e
$Warning = Get-Paint 228 160 60 # warning #e4a03c
$Destructive = Get-Paint 239 68 68 # destructive #ef4444
$Bold = if ($colour) { "$([char]27)[1m" } else { '' }
$Reset = if ($colour) { "$([char]27)[0m" } else { '' }
function Write-Info([string]$Message) { Write-Host "$Muted[SurfSense]$Reset $Message" }
function Write-Ok([string]$Message) { Write-Host "$Success[SurfSense] $Message$Reset" }
function Write-Warn([string]$Message) { Write-Host "$Warning[SurfSense] $Message$Reset" }
function Write-Step([string]$Message) { Write-Host "`n$Bold$Foreground── $Message$Reset" }
# The outcome, set apart: heavy rules and an emoji on a terminal, a plain step in a log.
function Write-Done([string]$Emoji, [string]$Message) {
if (-not $colour) { Write-Host "`n── $Message"; return }
$rule = '━' * ($Message.Length + 10)
Write-Host "`n$Bold$Success$rule`n $Emoji $Message`n$rule$Reset"
}
function Invoke-Compose {
Push-Location $InstallDir
try { & docker compose @args } finally { Pop-Location }
}
# As Compose reads it: a quoted value whole, otherwise up to " #".
function Get-EnvValue([string]$Name) {
$line = Get-Content (Join-Path $InstallDir '.env') -ErrorAction SilentlyContinue |
Where-Object { $_ -match "^$Name=" } | Select-Object -Last 1
if (-not $line) { return '' }
$value = $line.Substring($Name.Length + 1).TrimEnd("`r")
if ($value -match '^"([^"]*)"' -or $value -match "^'([^']*)'") { return $Matches[1] }
return ($value -replace '\s+#.*$', '').Trim()
}
function Test-ThisInstall([string]$Dir) {
$compose = Join-Path $Dir 'docker-compose.yml'
return (Test-Path $compose) -and [bool](Select-String -Path $compose -Pattern '^ surfsense:' -Quiet)
}
function Get-File([string]$From, [string]$To) {
$target = Join-Path $InstallDir $To
try {
(New-Object System.Net.WebClient).DownloadFile("$Source/$From", "$target.download")
} catch {
throw "Couldn't download $To. Check your internet connection and try again."
}
Move-Item -Force "$target.download" $target
}
function Get-ImageId([string]$Image) {
$id = & docker image inspect --format '{{.Id}}' $Image 2>$null
if ($LASTEXITCODE -ne 0) { return '' }
return "$id".Trim()
}
function Get-LanAddress {
try {
$address = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.InterfaceAlias -notlike 'vEthernet*' } |
Select-Object -First 1
if ($address) { return $address.IPAddress }
} catch {}
return ''
}
# raw.githubusercontent.com refuses Windows PowerShell 5.1's default TLS.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
Write-Host ''
Write-Host "$Primary" -NoNewline
Write-Host @'
███████╗██╗ ██╗██████╗ ███████╗███████╗███████╗███╗ ██╗███████╗███████╗
██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔════╝██╔════╝
███████╗██║ ██║██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║███████╗█████╗
╚════██║██║ ██║██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║╚════██║██╔══╝
███████║╚██████╔╝██║ ██║██║ ███████║███████╗██║ ╚████║███████║███████╗
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝
'@
Write-Host "$Reset" -NoNewline
Write-Step 'Checking prerequisites'
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'Docker is not installed. Install Docker Desktop: https://docs.docker.com/desktop/'
}
& docker info *> $null
if ($LASTEXITCODE -ne 0) { throw 'Docker is not running. Start Docker Desktop and try again.' }
Write-Ok 'Docker is running.'
& docker compose version *> $null
if ($LASTEXITCODE -ne 0) { throw 'Docker Compose is not available. Update Docker Desktop and try again.' }
Write-Ok 'Docker Compose found.'
$arch = "$(& docker info --format '{{.Architecture}}' 2>$null)".Trim()
if ($arch -ne 'x86_64' -and $arch -ne 'amd64') {
throw "SurfSense runs on x86-64 (amd64) machines only, for now. This one is $(if ($arch) { $arch } else { 'unknown' })."
}
# Run again from any folder, it finds the install by its container. The label is
# read through inspect: Windows PowerShell mangles quotes inside a --format argument.
$found = ''
$container = & docker ps -aq --filter 'label=com.docker.compose.project=surfsense' --filter 'label=com.docker.compose.service=surfsense' 2>$null |
Select-Object -First 1
if ($container) {
$labels = (& docker inspect $container 2>$null | Out-String | ConvertFrom-Json)[0].Config.Labels
$found = $labels.'com.docker.compose.project.working_dir'
}
$here = Join-Path (Get-Location).Path 'surfsense'
if ($found -and (Test-ThisInstall $found)) {
$InstallDir = $found
} elseif (Test-Path $here) {
$InstallDir = $here
if (-not (Test-ThisInstall $here) -and (Get-ChildItem -Force $here | Select-Object -First 1)) {
throw '.\surfsense already holds something else. Move it, or run this command from another folder.'
}
} else {
$InstallDir = $here
}
$updating = Test-Path (Join-Path $InstallDir '.env')
if ($updating) {
Write-Step "Updating SurfSense in $($InstallDir)"
Write-Info 'Keeping your settings (.env).'
} else {
Write-Step 'Downloading SurfSense'
Write-Info "Installing into $($InstallDir)"
}
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Get-File 'docker/surfsense/docker-compose.yml' 'docker-compose.yml'
Get-File 'docker/surfsense/.env.example' '.env.example'
if (-not $updating) {
Copy-Item (Join-Path $InstallDir '.env.example') (Join-Path $InstallDir '.env')
Write-Ok "Created $(Join-Path $InstallDir '.env')"
}
$image = "$(Invoke-Compose config --images 2>$null | Select-Object -First 1)".Trim()
if (-not $image) { throw "docker-compose.yml names no image. Check $(Join-Path $InstallDir '.env')." }
$before = Get-ImageId $image
if ($before) { Write-Step 'Pulling SurfSense' } else { Write-Step 'Pulling SurfSense (about 5 GB the first time)' }
Invoke-Compose pull
if ($LASTEXITCODE -ne 0) { throw "Couldn't download the SurfSense image. Check your internet connection and try again." }
$after = Get-ImageId $image
Write-Step 'Starting SurfSense'
Write-Info 'Waiting for SurfSense to be ready. A first start takes a minute or two.'
Invoke-Compose up -d --wait --wait-timeout 600
if ($LASTEXITCODE -ne 0) {
Invoke-Compose logs --tail=40 surfsense
throw "SurfSense didn't start. Logs: cd $($InstallDir); docker compose logs"
}
if ($before -and $before -ne $after) {
# Every version is about 5 GB; the one replaced goes.
& docker image rm $before *> $null
if ($LASTEXITCODE -eq 0) { Write-Info 'Removed the previous image.' }
}
$version = "$(Invoke-Compose exec -T surfsense cat /app/VERSION 2>$null)".Trim()
$pinned = Get-EnvValue 'SURFSENSE_VERSION'
$port = Get-EnvValue 'SURFSENSE_PORT'
if (-not $port) { $port = '4929' }
$bind = Get-EnvValue 'SURFSENSE_BIND'
$domain = Get-EnvValue 'SURFSENSE_DOMAIN'
$named = if ($version) { "SurfSense $version" } else { 'SurfSense' }
if (-not $updating) {
Write-Done ([char]::ConvertFromUtf32(0x1F389)) "$named is running"
} elseif ($before -eq $after) {
Write-Done ([char]::ConvertFromUtf32(0x2705)) "$named is already up to date"
} else {
Write-Done ([char]::ConvertFromUtf32(0x2728)) "SurfSense updated$(if ($version) { " to $version" })"
}
if ($pinned -and $pinned -ne 'latest') {
Write-Warn "Pinned to $pinned by SURFSENSE_VERSION in $(Join-Path $InstallDir '.env'). Set it to latest to update."
}
Write-Host ''
if ($domain) {
Write-Host " Open: https://$domain"
} else {
$address = if ($bind -ne '127.0.0.1') { Get-LanAddress } else { '' }
if ($address) {
Write-Host " Open: http://${address}:$port (any device on this network)"
Write-Host " http://localhost:$port (this computer)"
} else {
Write-Host " Open: http://localhost:$port"
}
}
Write-Host ''
if (-not $updating) {
Write-Host ' The first visit asks you to create a password.'
Write-Host ''
}
Write-Host " Settings: $(Join-Path $InstallDir '.env')"
Write-Host " Logs: cd $($InstallDir); docker compose logs -f"
Write-Host " Stop: cd $($InstallDir); docker compose down"
Write-Host ' Update: run this same command again'
Write-Host ''
}
try {
Install-SurfSense
} catch {
# destructive #ef4444, as Install-SurfSense paints it; its variables stay out of the caller's session.
$paint = if ($Host.UI.SupportsVirtualTerminal -and -not [Console]::IsOutputRedirected -and -not $env:NO_COLOR) { @("$([char]27)[38;2;239;68;68m", "$([char]27)[0m") } else { @('', '') }
Write-Host "$($paint[0])[SurfSense] ERROR: $($_.Exception.Message)$($paint[1])"
# Piped into iex, exit would close the user's window; run as a file, it reports failure.
if ($PSCommandPath) { exit 1 }
}