1385 lines
52 KiB
PowerShell
1385 lines
52 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
Deploys the complete Memby Docker Compose stack to MATT-NAS.
|
||
|
||
.DESCRIPTION
|
||
Builds and verifies a signed Memby APK, packages it with the local server build
|
||
context, Docker Compose file and .env.example, then streams everything to the NAS
|
||
over one SSH connection. Once the new gateway is healthy it publishes the APK
|
||
through the gateway's release endpoint, making it available to older TVs.
|
||
|
||
The remote deployment:
|
||
- checks Docker and Docker Compose;
|
||
- installs .env from the local .env.example, overwriting the deployed
|
||
copy, and keeps the old one alongside as .env.previous;
|
||
- preserves the named database volume;
|
||
- unless --Quiet is supplied, tells every signed-in television a deployment
|
||
is starting through the gateway that is still running before the build begins;
|
||
- pulls Redis and PostgreSQL, then builds the Memby server;
|
||
- starts each service and waits for its health check;
|
||
- restores the previous application files if activation fails.
|
||
|
||
Configuration lives in the local .env.example and is included directly in every
|
||
deployment. It does not need to be committed or pushed before deployment.
|
||
|
||
SSH performs the password prompt directly. The password is never read or stored
|
||
by this script.
|
||
|
||
This deploys the current local working tree, including uncommitted server changes.
|
||
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates'
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1 --Quiet
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1 -EstimateOnly
|
||
|
||
.EXAMPLE
|
||
.\deploy-server.ps1 -SkipAppRelease
|
||
#>
|
||
|
||
#Requires -Version 7.2
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
[Parameter()]
|
||
[ValidateNotNullOrEmpty()]
|
||
[string] $SourceDirectory = $PSScriptRoot,
|
||
|
||
[Parameter()]
|
||
[ValidateNotNullOrEmpty()]
|
||
[string] $RemoteHost = '10.0.0.213',
|
||
|
||
[Parameter()]
|
||
[ValidateNotNullOrEmpty()]
|
||
[string] $RemoteUser = 'ssh',
|
||
|
||
[Parameter()]
|
||
[ValidateNotNullOrEmpty()]
|
||
[string] $Destination = '/share/Docker/Memby',
|
||
|
||
[Parameter()]
|
||
[ValidateRange(30, 600)]
|
||
[int] $HealthTimeoutSeconds = 120,
|
||
|
||
[Parameter()]
|
||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||
[string] $AppVersion,
|
||
|
||
[Parameter()]
|
||
[string] $ReleaseNotes = '',
|
||
|
||
[Parameter()]
|
||
[switch] $SkipAppTests,
|
||
|
||
[Parameter()]
|
||
[switch] $SkipAppRelease,
|
||
|
||
[Parameter()]
|
||
[Alias('m')]
|
||
[switch] $MandatoryUpdate,
|
||
|
||
[Parameter()]
|
||
[switch] $Quiet,
|
||
|
||
[Parameter()]
|
||
[switch] $NoAnimation,
|
||
|
||
[Parameter()]
|
||
[Alias('Preview')]
|
||
[switch] $EstimateOnly,
|
||
|
||
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
|
||
# Accept the two documented flags in either positional order instead.
|
||
[Parameter(Position = 0)]
|
||
[string] $TrailingFlag0,
|
||
|
||
[Parameter(Position = 1)]
|
||
[string] $TrailingFlag1
|
||
)
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = 'Stop'
|
||
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||
$unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') })
|
||
if ($unknownFlags.Count -gt 0) {
|
||
throw "Unknown deployment option: $($unknownFlags -join ', ')"
|
||
}
|
||
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
|
||
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
|
||
|
||
if ($mandatoryRelease -and $SkipAppRelease) {
|
||
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
|
||
}
|
||
|
||
$script:CurrentStep = 0
|
||
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
|
||
$script:RemoteStepCount = 10
|
||
$script:TotalSteps = $script:LocalStepCount + $script:RemoteStepCount
|
||
$script:PhaseOrder = @(
|
||
'prerequisites',
|
||
'source',
|
||
'payload'
|
||
) + $(if ($SkipAppRelease) { @() } else { @('android-release') }) + @(
|
||
'packaging',
|
||
'remote-deployment'
|
||
)
|
||
$script:FallbackSeconds = @{
|
||
'prerequisites' = 3
|
||
'source' = 2
|
||
'payload' = 4
|
||
'android-release' = 230
|
||
'packaging' = 12
|
||
'remote-deployment' = 330
|
||
}
|
||
$script:PhaseDurations = [ordered]@{}
|
||
$script:CurrentPhaseKey = $null
|
||
$script:CurrentPhaseTimer = $null
|
||
$script:History = @()
|
||
$script:IsCI = -not [string]::IsNullOrWhiteSpace($env:CI) -and $env:CI -notin @('0', 'false', 'False')
|
||
$script:UseColour = [string]::IsNullOrWhiteSpace($env:NO_COLOR) -and
|
||
-not [Console]::IsOutputRedirected -and
|
||
-not $script:IsCI
|
||
$script:UseAnimation = -not $NoAnimation -and
|
||
[Environment]::UserInteractive -and
|
||
-not [Console]::IsOutputRedirected -and
|
||
-not $script:IsCI
|
||
|
||
function Write-Styled {
|
||
param(
|
||
[Parameter(Mandatory)][string] $Message,
|
||
[Parameter(Mandatory)][ConsoleColor] $Colour,
|
||
[switch] $NoNewline
|
||
)
|
||
$arguments = @{ Object = $Message; NoNewline = $NoNewline }
|
||
if ($script:UseColour) { $arguments.ForegroundColor = $Colour }
|
||
Write-Host @arguments
|
||
}
|
||
|
||
function Format-DeploymentDuration {
|
||
param([Parameter(Mandatory)][double] $Seconds)
|
||
$seconds = [Math]::Max(0, [Math]::Round($Seconds))
|
||
if ($seconds -lt 60) { return "${seconds}s" }
|
||
$span = [TimeSpan]::FromSeconds($seconds)
|
||
if ($span.TotalHours -ge 1) {
|
||
return ('{0}h {1}m' -f [Math]::Floor($span.TotalHours), $span.Minutes)
|
||
}
|
||
return ('{0}m {1}s' -f $span.Minutes, $span.Seconds)
|
||
}
|
||
|
||
function Get-DeploymentHistoryPath {
|
||
$root = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||
Join-Path ([System.IO.Path]::GetTempPath()) 'Memby'
|
||
} else {
|
||
Join-Path $env:LOCALAPPDATA 'Memby'
|
||
}
|
||
return Join-Path $root 'deploy-history/deploy-server.json'
|
||
}
|
||
|
||
function Import-DeploymentHistory {
|
||
$script:HistoryPath = Get-DeploymentHistoryPath
|
||
if (-not (Test-Path -LiteralPath $script:HistoryPath -PathType Leaf)) { return }
|
||
try {
|
||
$document = Get-Content -LiteralPath $script:HistoryPath -Raw | ConvertFrom-Json
|
||
$script:History = @($document.runs | Where-Object { $_.success -eq $true })
|
||
}
|
||
catch {
|
||
# Estimates are advisory. A truncated or hand-edited history file must never stop
|
||
# a deployment, and the next successful run will replace it atomically.
|
||
$script:History = @()
|
||
}
|
||
}
|
||
|
||
function Get-PhaseHistory {
|
||
param([Parameter(Mandatory)][string] $Key)
|
||
$values = foreach ($run in $script:History) {
|
||
$phasesProperty = $run.PSObject.Properties['phases']
|
||
if (-not $phasesProperty -or -not $phasesProperty.Value) { continue }
|
||
$property = $phasesProperty.Value.PSObject.Properties[$Key]
|
||
if ($property -and [double]$property.Value -gt 0) { [double]$property.Value }
|
||
}
|
||
return @($values | Select-Object -Last 20)
|
||
}
|
||
|
||
function Get-PhaseEstimate {
|
||
param([Parameter(Mandatory)][string] $Key)
|
||
$historyValues = @(Get-PhaseHistory -Key $Key)
|
||
if ($historyValues.Count -eq 0) {
|
||
return [pscustomobject]@{
|
||
Seconds = [double]$script:FallbackSeconds[$Key]
|
||
Low = $null; High = $null; Samples = 0; Last = $null
|
||
}
|
||
}
|
||
$last = $historyValues[-1]
|
||
$values = @($historyValues | Sort-Object)
|
||
$middle = [Math]::Floor($values.Count / 2)
|
||
$median = if ($values.Count % 2) { $values[$middle] } else { ($values[$middle - 1] + $values[$middle]) / 2 }
|
||
return [pscustomobject]@{
|
||
Seconds = [double]$median
|
||
Low = [double]$values[[Math]::Floor(($values.Count - 1) * 0.25)]
|
||
High = [double]$values[[Math]::Ceiling(($values.Count - 1) * 0.75)]
|
||
Samples = $values.Count
|
||
Last = [double]$last
|
||
}
|
||
}
|
||
|
||
function Get-RemainingEstimate {
|
||
param([Parameter(Mandatory)][string] $CurrentKey)
|
||
$remaining = 0.0
|
||
$found = $false
|
||
foreach ($key in $script:PhaseOrder) {
|
||
if ($key -eq $CurrentKey) { $found = $true }
|
||
if (-not $found) { continue }
|
||
$estimate = Get-PhaseEstimate -Key $key
|
||
if ($key -eq $CurrentKey -and $script:CurrentPhaseTimer) {
|
||
$remaining += [Math]::Max(0, $estimate.Seconds - $script:CurrentPhaseTimer.Elapsed.TotalSeconds)
|
||
} else {
|
||
$remaining += $estimate.Seconds
|
||
}
|
||
}
|
||
return $remaining
|
||
}
|
||
|
||
function Show-DeploymentProgress {
|
||
param(
|
||
[Parameter(Mandatory)][string] $Key,
|
||
[Parameter(Mandatory)][string] $Status,
|
||
[int] $CompletedPhases = 0
|
||
)
|
||
if (-not $script:UseAnimation) { return }
|
||
$remaining = Get-RemainingEstimate -CurrentKey $Key
|
||
$percent = [Math]::Min(99, [Math]::Round(($CompletedPhases / $script:PhaseOrder.Count) * 100))
|
||
Write-Progress -Id 1 -Activity 'Memby deployment' -Status $Status `
|
||
-PercentComplete $percent -SecondsRemaining ([Math]::Max(0, [int]$remaining))
|
||
}
|
||
|
||
function Update-DeploymentEta {
|
||
param([switch] $WriteLine)
|
||
if (-not $script:CurrentPhaseKey -or -not $script:CurrentPhaseTimer) { return }
|
||
$phaseIndex = [Array]::IndexOf($script:PhaseOrder, $script:CurrentPhaseKey)
|
||
$phaseEstimate = Get-PhaseEstimate -Key $script:CurrentPhaseKey
|
||
$phaseElapsed = $script:CurrentPhaseTimer.Elapsed.TotalSeconds
|
||
$phaseRemaining = [Math]::Max(0, $phaseEstimate.Seconds - $phaseElapsed)
|
||
$remaining = Get-RemainingEstimate -CurrentKey $script:CurrentPhaseKey
|
||
if ($script:UseAnimation) {
|
||
$withinPhase = if ($phaseEstimate.Seconds -gt 0) {
|
||
[Math]::Min(0.95, $phaseElapsed / $phaseEstimate.Seconds)
|
||
} else { 0 }
|
||
$percent = [Math]::Min(99, [Math]::Round(
|
||
(($phaseIndex + $withinPhase) / $script:PhaseOrder.Count) * 100
|
||
))
|
||
Write-Progress -Id 1 -Activity 'Memby deployment' `
|
||
-Status ("{0} elapsed in this phase · {1} overall remaining" -f `
|
||
(Format-DeploymentDuration $phaseElapsed),
|
||
(Format-DeploymentDuration $remaining)) `
|
||
-PercentComplete $percent -SecondsRemaining ([int]$remaining)
|
||
}
|
||
if ($WriteLine) {
|
||
$phaseText = if ($phaseRemaining -gt 0) {
|
||
"about $(Format-DeploymentDuration $phaseRemaining) left in this phase"
|
||
} else {
|
||
'running beyond its typical phase time'
|
||
}
|
||
Write-Styled -Message (" ◷ {0} elapsed · {1} · about {2} overall, near {3}" -f `
|
||
(Format-DeploymentDuration $phaseElapsed),
|
||
$phaseText,
|
||
(Format-DeploymentDuration $remaining),
|
||
(Get-Date).AddSeconds($remaining).ToString('h:mm tt')) -Colour Yellow
|
||
}
|
||
}
|
||
|
||
function Complete-DeploymentPhase {
|
||
if (-not $script:CurrentPhaseKey -or -not $script:CurrentPhaseTimer) { return }
|
||
$script:CurrentPhaseTimer.Stop()
|
||
$script:PhaseDurations[$script:CurrentPhaseKey] = [Math]::Round(
|
||
$script:CurrentPhaseTimer.Elapsed.TotalSeconds, 2
|
||
)
|
||
$script:CurrentPhaseKey = $null
|
||
$script:CurrentPhaseTimer = $null
|
||
}
|
||
|
||
function Start-DeploymentPhase {
|
||
param(
|
||
[Parameter(Mandatory)][string] $Key,
|
||
[Parameter(Mandatory)][string] $Message,
|
||
[switch] $RemoteRange
|
||
)
|
||
Complete-DeploymentPhase
|
||
$script:CurrentPhaseKey = $Key
|
||
$script:CurrentPhaseTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||
$phaseIndex = [Array]::IndexOf($script:PhaseOrder, $Key)
|
||
Show-DeploymentProgress -Key $Key -Status $Message -CompletedPhases ([Math]::Max(0, $phaseIndex))
|
||
|
||
Write-Styled -Message '● ' -Colour Magenta -NoNewline
|
||
if ($RemoteRange) {
|
||
Write-Styled -Message ("[{0}–{1}/{1}] " -f ($script:LocalStepCount + 1), $script:TotalSteps) -Colour DarkCyan -NoNewline
|
||
} else {
|
||
$script:CurrentStep++
|
||
Write-Styled -Message ("[{0}/{1}] " -f $script:CurrentStep, $script:TotalSteps) -Colour DarkCyan -NoNewline
|
||
}
|
||
Write-Styled -Message $Message -Colour Cyan
|
||
|
||
$phase = Get-PhaseEstimate -Key $Key
|
||
$remaining = Get-RemainingEstimate -CurrentKey $Key
|
||
$readyAt = (Get-Date).AddSeconds($remaining).ToString('h:mm tt')
|
||
$basis = if ($phase.Samples -eq 0) {
|
||
'first-run estimate; timings will improve after this deployment'
|
||
} elseif ($phase.Samples -eq 1) {
|
||
'based on the previous successful deployment'
|
||
} else {
|
||
"median of $($phase.Samples) successful deployments"
|
||
}
|
||
Write-Styled -Message (" ◷ About {0} remaining · ready near {1} · {2}" -f `
|
||
(Format-DeploymentDuration $remaining), $readyAt, $basis) -Colour Yellow
|
||
if ($phase.Samples -gt 1) {
|
||
Write-Styled -Message (" Typical phase range {0}–{1}; last run {2}" -f `
|
||
(Format-DeploymentDuration $phase.Low),
|
||
(Format-DeploymentDuration $phase.High),
|
||
(Format-DeploymentDuration $phase.Last)) -Colour Gray
|
||
}
|
||
}
|
||
|
||
function Save-DeploymentHistory {
|
||
param([Parameter(Mandatory)][bool] $Success, [Parameter(Mandatory)][double] $DurationSeconds)
|
||
if (-not $Success) { return }
|
||
$record = [ordered]@{
|
||
completedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||
success = $true
|
||
durationSeconds = [Math]::Round($DurationSeconds, 2)
|
||
phases = $script:PhaseDurations
|
||
appRelease = -not [bool]$SkipAppRelease
|
||
}
|
||
$runs = @($script:History) + @([pscustomobject]$record) | Select-Object -Last 30
|
||
$directory = Split-Path -Parent $script:HistoryPath
|
||
[void](New-Item -ItemType Directory -Path $directory -Force)
|
||
$temporary = "$($script:HistoryPath).tmp.$PID"
|
||
try {
|
||
@{ schemaVersion = 1; runs = @($runs) } | ConvertTo-Json -Depth 8 |
|
||
Set-Content -LiteralPath $temporary -Encoding utf8
|
||
[System.IO.File]::Move($temporary, $script:HistoryPath, $true)
|
||
}
|
||
catch {
|
||
Write-Styled -Message ' ↳ Deployment succeeded, but timing history could not be saved' -Colour Yellow
|
||
}
|
||
finally {
|
||
if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force }
|
||
}
|
||
}
|
||
|
||
function Show-DeploymentEstimate {
|
||
Write-Banner
|
||
Write-Styled -Message 'Estimated phase timings' -Colour Cyan
|
||
$labels = @{
|
||
'prerequisites' = 'Local prerequisites'
|
||
'source' = 'Source selection'
|
||
'payload' = 'Compose validation'
|
||
'android-release' = 'Android build and tests'
|
||
'packaging' = 'Release packaging'
|
||
'remote-deployment' = 'Upload, remote build and activation'
|
||
}
|
||
foreach ($key in $script:PhaseOrder) {
|
||
$estimate = Get-PhaseEstimate -Key $key
|
||
$detail = if ($estimate.Samples -gt 1) {
|
||
"typical {0}–{1}; {2} samples" -f `
|
||
(Format-DeploymentDuration $estimate.Low),
|
||
(Format-DeploymentDuration $estimate.High),
|
||
$estimate.Samples
|
||
} elseif ($estimate.Samples -eq 1) {
|
||
'one successful deployment recorded'
|
||
} else {
|
||
'baseline until a successful deployment is recorded'
|
||
}
|
||
Write-Styled -Message (" {0,-38} {1,9} {2}" -f `
|
||
$labels[$key], (Format-DeploymentDuration $estimate.Seconds), $detail) -Colour Gray
|
||
}
|
||
Write-Host ''
|
||
Write-Styled -Message "Timing history: $($script:HistoryPath)" -Colour DarkCyan
|
||
}
|
||
|
||
function Write-Banner {
|
||
Write-Host ''
|
||
Write-Styled -Message '╭──────────────────── Memby deployment ────────────────────╮' -Colour Magenta
|
||
Write-Styled -Message '│ SOURCE ' -Colour DarkCyan -NoNewline
|
||
Write-Styled -Message "$SourceDirectory (local working tree)" -Colour Gray
|
||
Write-Styled -Message '│ TARGET ' -Colour DarkCyan -NoNewline
|
||
Write-Styled -Message "${RemoteUser}@${RemoteHost}:$Destination" -Colour White
|
||
if ($mandatoryRelease) {
|
||
Write-Styled -Message '│ UPDATE mandatory (viewers cannot skip it)' -Colour Yellow
|
||
}
|
||
if ($quietDeployment) {
|
||
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
||
}
|
||
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
|
||
Measure-Object -Sum).Sum
|
||
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
|
||
$historyRuns = if ($sampleCounts.Count) { ($sampleCounts | Measure-Object -Minimum).Minimum } else { 0 }
|
||
$estimateBasis = if ($historyRuns -gt 0) { "median history ($historyRuns+ runs)" } else { 'learning baseline' }
|
||
Write-Styled -Message '│ ETA ' -Colour DarkCyan -NoNewline
|
||
Write-Styled -Message ("about {0}, near {1} · {2}" -f `
|
||
(Format-DeploymentDuration $initialEstimate),
|
||
(Get-Date).AddSeconds($initialEstimate).ToString('h:mm tt'),
|
||
$estimateBasis) -Colour Yellow
|
||
Write-Styled -Message '╰───────────────────────────────────────────────────────────╯' -Colour Magenta
|
||
Write-Host ''
|
||
}
|
||
|
||
function Write-Step {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $Message,
|
||
[Parameter(Mandatory)]
|
||
[string] $Key
|
||
)
|
||
Start-DeploymentPhase -Key $Key -Message $Message
|
||
}
|
||
|
||
function Write-Detail {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $Message
|
||
)
|
||
|
||
Write-Styled -Message " ↳ $Message" -Colour Gray
|
||
}
|
||
|
||
function Write-Success {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $Message
|
||
)
|
||
|
||
Write-Styled -Message "✓ $Message" -Colour Green
|
||
Update-DeploymentEta -WriteLine
|
||
}
|
||
|
||
function Write-Failure {
|
||
param([Parameter(Mandatory)][string] $Message)
|
||
Write-Styled -Message "✗ $Message" -Colour Red
|
||
}
|
||
|
||
function Get-RequiredCommand {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $Name
|
||
)
|
||
|
||
$command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
|
||
Select-Object -First 1
|
||
|
||
if (-not $command) {
|
||
throw "Required command '$Name' was not found in PATH."
|
||
}
|
||
|
||
return $command.Source
|
||
}
|
||
|
||
function Invoke-Checked {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $FilePath,
|
||
|
||
[Parameter()]
|
||
[string[]] $Arguments = @()
|
||
)
|
||
|
||
& $FilePath @Arguments
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "'$FilePath' failed with exit code $LASTEXITCODE."
|
||
}
|
||
}
|
||
|
||
function Get-AndroidSdk {
|
||
param([Parameter(Mandatory)][string] $RepositoryDirectory)
|
||
|
||
if ($env:ANDROID_HOME -and (Test-Path -LiteralPath $env:ANDROID_HOME)) {
|
||
return $env:ANDROID_HOME
|
||
}
|
||
$propertiesPath = Join-Path $RepositoryDirectory 'local.properties'
|
||
if (Test-Path -LiteralPath $propertiesPath) {
|
||
$sdkLine = Get-Content -LiteralPath $propertiesPath |
|
||
Where-Object { $_ -match '^sdk\.dir=' } |
|
||
Select-Object -First 1
|
||
if ($sdkLine) {
|
||
$sdkPath = $sdkLine.Substring($sdkLine.IndexOf('=') + 1).
|
||
Replace('\:', ':').
|
||
Replace('\\', '\')
|
||
if (Test-Path -LiteralPath $sdkPath) {
|
||
return $sdkPath
|
||
}
|
||
}
|
||
}
|
||
throw 'Android SDK not found. Set ANDROID_HOME or sdk.dir in local.properties.'
|
||
}
|
||
|
||
function Import-UserSigningEnvironment {
|
||
foreach ($name in @(
|
||
'MEMBY_KEYSTORE',
|
||
'MEMBY_KEYSTORE_PASSWORD',
|
||
'MEMBY_KEY_ALIAS',
|
||
'MEMBY_KEY_PASSWORD'
|
||
)) {
|
||
$value = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||
$value = [Environment]::GetEnvironmentVariable($name, 'User')
|
||
}
|
||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||
throw "Release signing variable $name is not configured."
|
||
}
|
||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||
}
|
||
if (-not (Test-Path -LiteralPath $env:MEMBY_KEYSTORE -PathType Leaf)) {
|
||
throw "Release keystore not found: $env:MEMBY_KEYSTORE"
|
||
}
|
||
}
|
||
|
||
function Get-ConfiguredAppVersion {
|
||
param([Parameter(Mandatory)][string] $RepositoryDirectory)
|
||
|
||
if ($AppVersion) {
|
||
return $AppVersion
|
||
}
|
||
$gradle = Get-Content -LiteralPath (Join-Path $RepositoryDirectory 'app/build.gradle.kts') -Raw
|
||
if ($gradle -notmatch 'val defaultVersionName = "(\d+\.\d+\.\d+)"') {
|
||
throw 'Could not read defaultVersionName from app/build.gradle.kts.'
|
||
}
|
||
return $Matches[1]
|
||
}
|
||
|
||
function Get-DotEnvValue {
|
||
param(
|
||
[Parameter(Mandatory)][string] $Path,
|
||
[Parameter(Mandatory)][string] $Name
|
||
)
|
||
|
||
$line = Get-Content -LiteralPath $Path |
|
||
Where-Object { $_ -match ("^" + [regex]::Escape($Name) + "=") } |
|
||
Select-Object -First 1
|
||
if (-not $line) { return '' }
|
||
return $line.Substring($line.IndexOf('=') + 1).Trim()
|
||
}
|
||
|
||
function Assert-SafeRemoteSettings {
|
||
if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') {
|
||
throw "RemoteHost contains unsupported characters: '$RemoteHost'."
|
||
}
|
||
|
||
if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') {
|
||
throw "RemoteUser contains unsupported characters: '$RemoteUser'."
|
||
}
|
||
|
||
if ($Destination -notmatch '^/[A-Za-z0-9._/-]+$') {
|
||
throw "Destination must be an absolute Linux path using only letters, numbers, '.', '_', '-', and '/'."
|
||
}
|
||
|
||
$segments = $Destination.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||
if ($segments -contains '..') {
|
||
throw "Destination cannot contain '..' path segments."
|
||
}
|
||
|
||
$broadPaths = @(
|
||
'/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64',
|
||
'/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp',
|
||
'/usr', '/var'
|
||
)
|
||
if ($Destination -in $broadPaths) {
|
||
throw "Destination '$Destination' is too broad to replace safely."
|
||
}
|
||
}
|
||
|
||
function New-DeploymentArchive {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $RepositoryDirectory,
|
||
|
||
[Parameter(Mandatory)]
|
||
[string] $ArchivePath,
|
||
|
||
[Parameter()]
|
||
[string] $ReleaseDirectory
|
||
)
|
||
|
||
# Build artefacts left in either build context are paid for on the wire. Both
|
||
# directory entries and their contents are named because bsdtar — which is what
|
||
# tar.exe is on Windows — matches --exclude against each entry rather than
|
||
# pruning the walk.
|
||
$arguments = @(
|
||
'-cf', $ArchivePath,
|
||
'--exclude', 'server/.tmp-go-cache', '--exclude', 'server/.tmp-go-cache/*',
|
||
'--exclude', 'server/bin', '--exclude', 'server/bin/*',
|
||
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
|
||
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
|
||
'-C', $RepositoryDirectory,
|
||
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
|
||
)
|
||
if ($ReleaseDirectory) {
|
||
$arguments += @(
|
||
'-C', (Split-Path -Parent $ReleaseDirectory),
|
||
(Split-Path -Leaf $ReleaseDirectory)
|
||
)
|
||
}
|
||
& $script:TarCommand @arguments
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)."
|
||
}
|
||
|
||
if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) {
|
||
throw 'The deployment archive was not created.'
|
||
}
|
||
}
|
||
|
||
function Send-ArchiveOverSsh {
|
||
param(
|
||
[Parameter(Mandatory)]
|
||
[string] $ArchivePath,
|
||
|
||
[Parameter(Mandatory)]
|
||
[string] $RemoteCommand
|
||
)
|
||
|
||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||
$startInfo.FileName = $script:SshCommand
|
||
$startInfo.UseShellExecute = $false
|
||
$startInfo.RedirectStandardInput = $true
|
||
|
||
# Force interactive password authentication as requested. OpenSSH reads the
|
||
# password from the console while stdin carries the tar archive.
|
||
foreach ($argument in @(
|
||
'-o', 'PubkeyAuthentication=no',
|
||
'-o', 'PreferredAuthentications=keyboard-interactive,password',
|
||
'-o', 'NumberOfPasswordPrompts=3',
|
||
"$RemoteUser@$RemoteHost",
|
||
$RemoteCommand
|
||
)) {
|
||
[void] $startInfo.ArgumentList.Add($argument)
|
||
}
|
||
|
||
$process = [System.Diagnostics.Process]::new()
|
||
$process.StartInfo = $startInfo
|
||
$archiveStream = $null
|
||
$processStarted = $false
|
||
$exitCode = $null
|
||
|
||
try {
|
||
if (-not $process.Start()) {
|
||
throw 'SSH could not be started.'
|
||
}
|
||
$processStarted = $true
|
||
|
||
$archiveStream = [System.IO.File]::OpenRead($ArchivePath)
|
||
try {
|
||
$totalBytes = $archiveStream.Length
|
||
$transferred = 0L
|
||
$buffer = [byte[]]::new(1MB)
|
||
$transferTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||
$lastRefresh = [TimeSpan]::Zero
|
||
$nextLoggedPercent = 25
|
||
while (($read = $archiveStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
||
$process.StandardInput.BaseStream.Write($buffer, 0, $read)
|
||
$transferred += $read
|
||
if (($transferTimer.Elapsed - $lastRefresh).TotalMilliseconds -lt 250) { continue }
|
||
$lastRefresh = $transferTimer.Elapsed
|
||
$percent = if ($totalBytes -gt 0) { [Math]::Min(100, [int](100 * $transferred / $totalBytes)) } else { 0 }
|
||
$bytesPerSecond = if ($transferTimer.Elapsed.TotalSeconds -gt 0) {
|
||
$transferred / $transferTimer.Elapsed.TotalSeconds
|
||
} else { 0 }
|
||
$secondsLeft = if ($bytesPerSecond -gt 0) {
|
||
[int](($totalBytes - $transferred) / $bytesPerSecond)
|
||
} else { -1 }
|
||
if ($script:UseAnimation) {
|
||
$rate = if ($bytesPerSecond -gt 0) { '{0:N1} MiB/s' -f ($bytesPerSecond / 1MB) } else { 'measuring speed' }
|
||
Write-Progress -Id 2 -ParentId 1 -Activity 'Uploading deployment archive' `
|
||
-Status ("{0}% · {1}" -f $percent, $rate) -PercentComplete $percent `
|
||
-SecondsRemaining $secondsLeft
|
||
} elseif ($percent -ge $nextLoggedPercent) {
|
||
Write-Detail ("Upload {0}% · {1:N1} MiB of {2:N1} MiB" -f `
|
||
$percent, ($transferred / 1MB), ($totalBytes / 1MB))
|
||
$nextLoggedPercent += 25
|
||
}
|
||
}
|
||
$process.StandardInput.BaseStream.Flush()
|
||
if ($script:UseAnimation) { Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed }
|
||
Write-Success ("Archive uploaded in {0} at {1:N1} MiB/s" -f `
|
||
(Format-DeploymentDuration $transferTimer.Elapsed.TotalSeconds),
|
||
$(if ($transferTimer.Elapsed.TotalSeconds -gt 0) { ($transferred / 1MB) / $transferTimer.Elapsed.TotalSeconds } else { 0 }))
|
||
}
|
||
catch [System.IO.IOException] {
|
||
# The remote side exited before reading the whole archive — a failed early
|
||
# step, or a shell that rejected the command. Its own message is already on
|
||
# screen, so let the exit code below do the explaining rather than burying it
|
||
# under a broken-pipe stack trace.
|
||
}
|
||
try { $process.StandardInput.Close() } catch [System.IO.IOException] { }
|
||
$process.WaitForExit()
|
||
$exitCode = $process.ExitCode
|
||
}
|
||
catch {
|
||
if ($processStarted -and -not $process.HasExited) {
|
||
$process.StandardInput.Close()
|
||
$process.WaitForExit()
|
||
}
|
||
throw
|
||
}
|
||
finally {
|
||
if ($archiveStream) {
|
||
$archiveStream.Dispose()
|
||
}
|
||
$process.Dispose()
|
||
}
|
||
|
||
if ($exitCode -ne 0) {
|
||
throw "Remote deployment failed with SSH exit code $exitCode. Review the last remote step above."
|
||
}
|
||
}
|
||
|
||
Import-DeploymentHistory
|
||
if ($EstimateOnly) {
|
||
Show-DeploymentEstimate
|
||
return
|
||
}
|
||
Write-Banner
|
||
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||
|
||
Write-Step 'Checking local prerequisites and settings' -Key 'prerequisites'
|
||
Assert-SafeRemoteSettings
|
||
$script:TarCommand = Get-RequiredCommand -Name 'tar'
|
||
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
|
||
Write-Detail "SSH $script:SshCommand"
|
||
Write-Success 'Local prerequisites are ready'
|
||
Write-Host ''
|
||
|
||
$workDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("memby-deploy-" + [guid]::NewGuid().ToString('N'))
|
||
$archivePath = Join-Path $workDirectory 'memby-deployment.tar'
|
||
|
||
try {
|
||
[void] (New-Item -ItemType Directory -Path $workDirectory)
|
||
|
||
Write-Step 'Selecting the local deployment source' -Key 'source'
|
||
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
|
||
Write-Success "Using $checkoutDirectory"
|
||
Write-Host ''
|
||
|
||
Write-Step 'Validating the Compose deployment payload' -Key 'payload'
|
||
$requiredPaths = @(
|
||
(Join-Path $checkoutDirectory 'server'),
|
||
(Join-Path $checkoutDirectory 'server/Dockerfile'),
|
||
(Join-Path $checkoutDirectory 'server/go.mod'),
|
||
(Join-Path $checkoutDirectory 'admin-ui'),
|
||
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
|
||
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
|
||
(Join-Path $checkoutDirectory 'admin-ui/src'),
|
||
(Join-Path $checkoutDirectory 'docker-compose.yml'),
|
||
(Join-Path $checkoutDirectory '.env.example')
|
||
)
|
||
foreach ($requiredPath in $requiredPaths) {
|
||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||
throw "Required deployment file is missing: $requiredPath"
|
||
}
|
||
}
|
||
Write-Detail 'server/ build context'
|
||
Write-Detail 'admin-ui/ build context'
|
||
Write-Detail 'docker-compose.yml'
|
||
Write-Detail '.env.example'
|
||
$releaseDirectory = ''
|
||
$releaseVersion = ''
|
||
$releaseSHA256 = ''
|
||
if (-not $SkipAppRelease) {
|
||
foreach ($appPath in @(
|
||
(Join-Path $checkoutDirectory 'app/build.gradle.kts'),
|
||
(Join-Path $checkoutDirectory 'gradlew.bat')
|
||
)) {
|
||
if (-not (Test-Path -LiteralPath $appPath -PathType Leaf)) {
|
||
throw "Required Android build file is missing: $appPath"
|
||
}
|
||
}
|
||
}
|
||
Write-Success 'Deployment payload is complete'
|
||
Write-Host ''
|
||
|
||
if (-not $SkipAppRelease) {
|
||
Write-Step 'Building and verifying the signed Android update' -Key 'android-release'
|
||
Import-UserSigningEnvironment
|
||
$releaseVersion = Get-ConfiguredAppVersion -RepositoryDirectory $checkoutDirectory
|
||
$sdk = Get-AndroidSdk -RepositoryDirectory $checkoutDirectory
|
||
$buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
|
||
Sort-Object { [version]$_.Name } -Descending |
|
||
Select-Object -First 1
|
||
if (-not $buildTools) {
|
||
throw 'Android SDK Build Tools are not installed.'
|
||
}
|
||
$apkSigner = Join-Path $buildTools.FullName 'apksigner.bat'
|
||
if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) {
|
||
throw "APK signer not found: $apkSigner"
|
||
}
|
||
|
||
$jdkHome = 'C:\Program Files\Android\Android Studio\jbr'
|
||
if (Test-Path -LiteralPath $jdkHome) {
|
||
$env:JAVA_HOME = $jdkHome
|
||
}
|
||
$gradleArguments = @('--console=plain')
|
||
if (-not $SkipAppTests) {
|
||
$gradleArguments += 'testDebugUnitTest'
|
||
}
|
||
$gradleArguments += @(
|
||
'assembleRelease',
|
||
"-Pmemby.versionName=$releaseVersion"
|
||
)
|
||
Invoke-Checked -FilePath (Join-Path $checkoutDirectory 'gradlew.bat') `
|
||
-Arguments $gradleArguments
|
||
|
||
$apk = Join-Path $checkoutDirectory 'app/build/outputs/apk/release/app-release.apk'
|
||
if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) {
|
||
$unsigned = Join-Path $checkoutDirectory `
|
||
'app/build/outputs/apk/release/app-release-unsigned.apk'
|
||
if (Test-Path -LiteralPath $unsigned -PathType Leaf) {
|
||
throw 'Gradle produced an unsigned APK; deployment was stopped.'
|
||
}
|
||
throw "Signed release APK not found: $apk"
|
||
}
|
||
Invoke-Checked -FilePath $apkSigner -Arguments @(
|
||
'verify', '--verbose', '--print-certs', $apk
|
||
)
|
||
|
||
$metadataPath = Join-Path $checkoutDirectory `
|
||
'app/build/outputs/apk/release/output-metadata.json'
|
||
$metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
|
||
$builtVersion = [string]$metadata.elements[0].versionName
|
||
if ($builtVersion -ne $releaseVersion) {
|
||
throw "Built APK version is $builtVersion, expected $releaseVersion."
|
||
}
|
||
|
||
$releaseDirectory = Join-Path $workDirectory 'release'
|
||
[void] (New-Item -ItemType Directory -Path $releaseDirectory)
|
||
$apkName = "memby-$releaseVersion.apk"
|
||
Copy-Item -LiteralPath $apk -Destination (Join-Path $releaseDirectory $apkName)
|
||
$releaseSHA256 = (
|
||
Get-FileHash -LiteralPath (Join-Path $releaseDirectory $apkName) -Algorithm SHA256
|
||
).Hash.ToLowerInvariant()
|
||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||
[System.IO.File]::WriteAllText(
|
||
(Join-Path $releaseDirectory 'version.txt'), $releaseVersion, $utf8NoBom
|
||
)
|
||
[System.IO.File]::WriteAllText(
|
||
(Join-Path $releaseDirectory 'sha256.txt'), $releaseSHA256, $utf8NoBom
|
||
)
|
||
[System.IO.File]::WriteAllText(
|
||
(Join-Path $releaseDirectory 'notes.txt'), $ReleaseNotes, $utf8NoBom
|
||
)
|
||
Write-Detail "Version $releaseVersion"
|
||
Write-Detail "SHA-256 $releaseSHA256"
|
||
Write-Success 'Signed Android update is ready'
|
||
Write-Host ''
|
||
}
|
||
|
||
Write-Step 'Packaging the release' -Key 'packaging'
|
||
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
|
||
-ReleaseDirectory $releaseDirectory
|
||
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
|
||
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
|
||
Write-Success 'Release archive is ready'
|
||
Write-Host ''
|
||
|
||
# This template is single-quoted so PowerShell does not expand the remote
|
||
# shell's variables. Replacement values are validated before insertion.
|
||
$remoteCommand = @'
|
||
set -eu
|
||
|
||
destination='__DESTINATION__'
|
||
health_timeout=__HEALTH_TIMEOUT__
|
||
publish_release=__PUBLISH_RELEASE__
|
||
mandatory_update=__MANDATORY_UPDATE__
|
||
quiet_deployment=__QUIET_DEPLOYMENT__
|
||
colour_output=__COLOUR_OUTPUT__
|
||
remote_step_offset=__REMOTE_STEP_OFFSET__
|
||
total_steps=__TOTAL_STEPS__
|
||
parent=$(dirname "$destination")
|
||
staging="${destination}.new.$$"
|
||
backup="${destination}.previous.$$"
|
||
activated=0
|
||
previous_stopped=0
|
||
remote_step=0
|
||
|
||
step() {
|
||
remote_step=$((remote_step + 1))
|
||
overall_step=$((remote_step_offset + remote_step))
|
||
if [ "$colour_output" -eq 1 ]; then
|
||
printf '\033[35m ● \033[36m[%s/%s] %s\033[0m\n' "$overall_step" "$total_steps" "$1"
|
||
else
|
||
printf ' * [%s/%s] %s\n' "$overall_step" "$total_steps" "$1"
|
||
fi
|
||
}
|
||
|
||
detail() {
|
||
if [ "$colour_output" -eq 1 ]; then printf '\033[37m ↳ %s\033[0m\n' "$1"; else printf ' - %s\n' "$1"; fi
|
||
}
|
||
|
||
success() {
|
||
if [ "$colour_output" -eq 1 ]; then printf '\033[32m ✓ %s\033[0m\n' "$1"; else printf ' OK %s\n' "$1"; fi
|
||
}
|
||
|
||
failure() {
|
||
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
|
||
}
|
||
|
||
start_restored_stack() {
|
||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||
}
|
||
|
||
rollback() {
|
||
status=$?
|
||
trap - EXIT
|
||
|
||
if [ "$status" -eq 0 ]; then
|
||
return
|
||
fi
|
||
|
||
failure "Deployment failed; cleaning up"
|
||
rm -rf -- "$staging"
|
||
|
||
if [ "$activated" -eq 1 ]; then
|
||
if [ -f "$destination/docker-compose.yml" ]; then
|
||
detail "Stopping the incomplete application release"
|
||
(
|
||
cd "$destination"
|
||
docker compose down --remove-orphans >/dev/null 2>&1
|
||
) || true
|
||
fi
|
||
|
||
rm -rf -- "$destination"
|
||
if [ -e "$backup" ] || [ -L "$backup" ]; then
|
||
detail "Restoring the previous application release"
|
||
mv -- "$backup" "$destination"
|
||
(
|
||
cd "$destination"
|
||
start_restored_stack
|
||
) || true
|
||
failure "Previous application files were restored"
|
||
fi
|
||
elif [ "$previous_stopped" -eq 1 ] && [ -f "$destination/docker-compose.yml" ]; then
|
||
detail "Restarting the previous application release"
|
||
(
|
||
cd "$destination"
|
||
start_restored_stack
|
||
) || true
|
||
fi
|
||
|
||
exit "$status"
|
||
}
|
||
|
||
wait_for_service() {
|
||
service="$1"
|
||
elapsed=0
|
||
detail "Waiting for $service"
|
||
|
||
while [ "$elapsed" -lt "$health_timeout" ]; do
|
||
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
|
||
|
||
if [ -n "$container_id" ]; then
|
||
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
|
||
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
|
||
|
||
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
|
||
success "$service is $state ($health)"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
|
||
failure "$service entered state: $state"
|
||
docker compose logs --no-color --tail 60 "$service" || true
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
sleep 2
|
||
elapsed=$((elapsed + 2))
|
||
if [ $((elapsed % 10)) -eq 0 ]; then
|
||
remaining=$((health_timeout - elapsed))
|
||
detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining"
|
||
fi
|
||
done
|
||
|
||
failure "$service did not become healthy within ${health_timeout}s"
|
||
docker compose logs --no-color --tail 60 "$service" || true
|
||
return 1
|
||
}
|
||
|
||
trap rollback EXIT
|
||
trap 'exit 130' INT TERM
|
||
|
||
step 'Checking Docker'
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
failure 'Docker is not installed on the NAS'
|
||
exit 1
|
||
fi
|
||
docker info >/dev/null 2>&1 || {
|
||
failure 'Docker is installed but the daemon is unavailable'
|
||
exit 1
|
||
}
|
||
docker compose version >/dev/null 2>&1 || {
|
||
failure 'The Docker Compose v2 plugin is not installed'
|
||
exit 1
|
||
}
|
||
if [ "$publish_release" -eq 1 ] && ! command -v curl >/dev/null 2>&1; then
|
||
failure 'curl is required on the NAS to publish the signed Android update'
|
||
exit 1
|
||
fi
|
||
success "$(docker --version)"
|
||
success "$(docker compose version)"
|
||
|
||
step 'Extracting the release'
|
||
# Checked before anything is created: the staging directory, the swap and the backup all
|
||
# need write access to the parent, and "can't create directory" from BusyBox halfway
|
||
# through a deployment is a poor way to learn the account cannot write there.
|
||
mkdir -p -- "$parent" 2>/dev/null || true
|
||
if [ ! -d "$parent" ]; then
|
||
failure "$parent does not exist and could not be created by $(id -un)"
|
||
detail 'Pick a path this account can write to with -Destination'
|
||
exit 1
|
||
fi
|
||
if [ ! -w "$parent" ]; then
|
||
failure "$(id -un) cannot write to $parent"
|
||
detail 'Deploy somewhere this account owns, for example:'
|
||
detail ' .\deploy-server.ps1 -Destination /share/Docker/Memby'
|
||
detail "or grant this account write access to $parent"
|
||
exit 1
|
||
fi
|
||
rm -rf -- "$staging"
|
||
mkdir -- "$staging"
|
||
tar -xf - -C "$staging"
|
||
test -f "$staging/docker-compose.yml"
|
||
test -f "$staging/server/Dockerfile"
|
||
test -f "$staging/admin-ui/Dockerfile"
|
||
test -f "$staging/admin-ui/package.json"
|
||
if [ "$publish_release" -eq 1 ]; then
|
||
test -f "$staging/release/version.txt"
|
||
test -f "$staging/release/sha256.txt"
|
||
fi
|
||
success 'Release extracted'
|
||
|
||
step 'Installing configuration from .env.example'
|
||
# The local .env.example is the single source of truth for configuration and carries
|
||
# real values rather than placeholders. Every deployment overwrites the deployed .env
|
||
# with it, so neither a Git push nor an SSH edit is needed.
|
||
previous_password=''
|
||
previous_admin_token=''
|
||
previous_release_token=''
|
||
if [ -f "$destination/.env" ]; then
|
||
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||
# The token of the gateway that is still running, which is the one that can still
|
||
# tell the televisions anything. It is usually the same as the incoming one, but
|
||
# reading it from the release being replaced is what makes that not a requirement.
|
||
previous_admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||
# First file-backed-secret deployment only: migrate the established publish token out
|
||
# of the old environment file rather than making the operator rotate it mid-release.
|
||
previous_release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
||
cp -- "$destination/.env" "$staging/.env.previous"
|
||
# The migrated release credential belongs only in the external secret file. Do not
|
||
# perpetuate it in the recovery copy once it has been captured in memory.
|
||
sed -i '/^MEMBY_RELEASE_PUBLISH_TOKEN=/d' "$staging/.env.previous"
|
||
chmod 600 "$staging/.env.previous" 2>/dev/null || true
|
||
detail 'Previous .env saved as .env.previous'
|
||
fi
|
||
|
||
cp -- "$staging/.env.example" "$staging/.env"
|
||
success 'Installed .env from .env.example'
|
||
|
||
new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||
admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||
emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||
configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||
secrets_dir=$(sed -n 's/^MEMBY_SECRETS_DIR=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
||
if [ -z "$new_password" ]; then
|
||
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
|
||
exit 1
|
||
fi
|
||
if [ -z "$emby_url" ]; then
|
||
failure 'MEMBY_EMBY_URL is empty in .env.example'
|
||
exit 1
|
||
fi
|
||
if [ -z "$admin_token" ]; then
|
||
failure 'MEMBY_ADMIN_TOKEN is empty in .env.example; refusing to deploy with /admin disabled'
|
||
exit 1
|
||
fi
|
||
if [ "$configured_port" != '32768' ]; then
|
||
failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})"
|
||
exit 1
|
||
fi
|
||
if [ -z "$secrets_dir" ] || [ "${secrets_dir#/}" = "$secrets_dir" ]; then
|
||
failure 'MEMBY_SECRETS_DIR must be an absolute NAS path outside the deployment directory'
|
||
exit 1
|
||
fi
|
||
release_token_file="$secrets_dir/memby-release-publish-token"
|
||
if [ ! -s "$release_token_file" ]; then
|
||
if [ -n "$previous_release_token" ]; then
|
||
mkdir -p -- "$secrets_dir"
|
||
chmod 700 "$secrets_dir" 2>/dev/null || true
|
||
old_umask=$(umask)
|
||
umask 077
|
||
printf '%s\n' "$previous_release_token" > "$release_token_file"
|
||
umask "$old_umask"
|
||
success "Migrated the existing release publish token to $release_token_file"
|
||
else
|
||
failure "Release publish secret is missing or empty: $release_token_file"
|
||
detail 'Create it with the existing release token; do not generate a replacement if CI still uses that token'
|
||
fi
|
||
fi
|
||
|
||
if [ ! -s "$release_token_file" ]; then
|
||
failure "Required secret is missing or empty: $release_token_file"
|
||
detail 'Restore the existing gateway release token at the path above, then rerun this deployment'
|
||
exit 1
|
||
fi
|
||
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
|
||
# deliberately runs as uid 65532, so a file created as the SSH user with 0600 would be
|
||
# present but unreadable in that container. The 0700 parent prevents every other NAS
|
||
# account from traversing to it; 0444 makes only the read-only secret mount usable by the
|
||
# non-root container process and also prevents accidental host writes.
|
||
chmod 700 "$secrets_dir"
|
||
chmod 444 "$release_token_file"
|
||
success 'Required gateway configuration is present'
|
||
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
|
||
failure 'POSTGRES_PASSWORD differs from the deployed value'
|
||
detail 'The PostgreSQL volume is always preserved; restore the deployed password in .env.example'
|
||
detail 'Change the database credential separately with an explicit migration if required'
|
||
exit 1
|
||
fi
|
||
|
||
(
|
||
cd "$staging"
|
||
docker compose config --quiet
|
||
)
|
||
success 'Compose configuration is valid'
|
||
|
||
step 'Telling the televisions'
|
||
# Announced here rather than at the swap, and the position is the point of it: the
|
||
# gateway being replaced is still answering, and the build below takes minutes, which is
|
||
# the window in which every open television polls /v1/status and collects the notice.
|
||
# Announcing at the swap would be too late twice over — nothing is left to publish with
|
||
# once the stack is down, and Redis holds the alert list in memory with no volume, so the
|
||
# swap discards anything published but not yet collected.
|
||
#
|
||
# Entirely best-effort: a deployment must never fail over a banner, and a first
|
||
# deployment (or one onto a host with no curl) has nothing to announce with.
|
||
if [ "$quiet_deployment" -eq 1 ]; then
|
||
detail 'Quiet deployment; skipping the television announcement'
|
||
elif [ -z "$previous_admin_token" ]; then
|
||
detail 'No previous release to announce from; skipping'
|
||
elif ! command -v curl >/dev/null 2>&1; then
|
||
detail 'curl is unavailable on the NAS; skipping the announcement'
|
||
else
|
||
announce_status=$(curl --silent --show-error --max-time 5 \
|
||
--output /dev/null \
|
||
--write-out '%{http_code}' \
|
||
-X POST \
|
||
-H "Authorization: Bearer $previous_admin_token" \
|
||
http://127.0.0.1:32768/admin/api/deployment-alert 2>/dev/null || true)
|
||
case "$announce_status" in
|
||
2??) success 'Signed-in televisions have been told a deployment is starting' ;;
|
||
*) detail "Could not announce the deployment (HTTP ${announce_status:-no response})" ;;
|
||
esac
|
||
fi
|
||
|
||
step 'Pulling PostgreSQL and Redis'
|
||
(
|
||
cd "$staging"
|
||
docker compose pull postgres redis
|
||
)
|
||
success 'Dependency images are ready'
|
||
|
||
step 'Building the gateway and admin console'
|
||
(
|
||
cd "$staging"
|
||
# `up` reuses an existing image when one is present. Build both local contexts here,
|
||
# otherwise a new React/nginx console can be packaged and activated while the NAS
|
||
# continues to serve the previous console image (and its old route configuration).
|
||
docker compose build --pull server memby-admin
|
||
)
|
||
success 'Gateway and admin console images built'
|
||
|
||
step 'Activating the release'
|
||
rm -rf -- "$backup"
|
||
if [ -e "$destination" ] || [ -L "$destination" ]; then
|
||
if [ -f "$destination/docker-compose.yml" ]; then
|
||
# Compose projects created by older releases may use a different project
|
||
# name. Stop them from their original directory before moving it so their
|
||
# published ports (especially 32768) are released for the new stack.
|
||
detail 'Stopping the previous Compose application'
|
||
(
|
||
cd "$destination"
|
||
docker compose down --remove-orphans
|
||
)
|
||
previous_stopped=1
|
||
success 'Previous Compose application stopped'
|
||
fi
|
||
|
||
mv -- "$destination" "$backup"
|
||
fi
|
||
mv -- "$staging" "$destination"
|
||
activated=1
|
||
success 'Release activated'
|
||
|
||
step 'Starting the Compose stack'
|
||
cd "$destination"
|
||
if ! docker compose up -d --no-build --remove-orphans; then
|
||
failure 'Compose could not start the complete application'
|
||
detail 'Container state before rollback:'
|
||
docker compose ps --all || true
|
||
detail 'Gateway logs before rollback:'
|
||
docker compose logs --no-color --tail 100 server || true
|
||
exit 1
|
||
fi
|
||
success 'Compose start command completed'
|
||
|
||
step 'Waiting for healthy services'
|
||
wait_for_service postgres
|
||
wait_for_service redis
|
||
wait_for_service memby-admin
|
||
wait_for_service server
|
||
|
||
published_address=$(docker compose port server 32768 | head -n 1)
|
||
actual_port=${published_address##*:}
|
||
if [ "$actual_port" != '32768' ]; then
|
||
failure "Memby published the wrong NAS port: ${published_address:-none}"
|
||
exit 1
|
||
fi
|
||
|
||
server_container_id=$(docker compose ps -q server)
|
||
if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$server_container_id" |
|
||
grep -q '^MEMBY_ADMIN_TOKEN=.'; then
|
||
failure 'MEMBY_ADMIN_TOKEN was not passed into the running server container'
|
||
exit 1
|
||
fi
|
||
success 'Runtime configuration includes the admin token'
|
||
|
||
step 'Publishing the signed Android update'
|
||
if [ "$publish_release" -eq 1 ]; then
|
||
release_version=$(tr -d '\r\n' < "$destination/release/version.txt")
|
||
release_sha256=$(tr -d '\r\n' < "$destination/release/sha256.txt")
|
||
if ! printf '%s' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||
failure 'The packaged Android version is invalid'
|
||
exit 1
|
||
fi
|
||
if ! printf '%s' "$release_sha256" | grep -Eq '^[0-9a-f]{64}$'; then
|
||
failure 'The packaged Android checksum is invalid'
|
||
exit 1
|
||
fi
|
||
release_apk="$destination/release/memby-${release_version}.apk"
|
||
if [ ! -f "$release_apk" ]; then
|
||
failure "Signed APK is missing: $release_apk"
|
||
exit 1
|
||
fi
|
||
|
||
release_response="$destination/release/publish-response.json"
|
||
release_token=$(tr -d '\r\n' < "$release_token_file")
|
||
release_status=$(printf 'header = "Authorization: Bearer %s"\n' "$release_token" | curl --config - --show-error --silent \
|
||
--output "$release_response" \
|
||
--write-out '%{http_code}' \
|
||
-X POST \
|
||
-F "version=$release_version" \
|
||
-F "sha256=$release_sha256" \
|
||
-F "mandatory=$mandatory_update" \
|
||
-F "notes=<$destination/release/notes.txt" \
|
||
-F "apk=@$release_apk;type=application/vnd.android.package-archive" \
|
||
http://127.0.0.1:32768/admin/api/release)
|
||
case "$release_status" in
|
||
2??) ;;
|
||
*)
|
||
failure "Gateway rejected the Android update (HTTP ${release_status:-unknown})"
|
||
if [ -s "$release_response" ]; then
|
||
detail "$(tr -d '\r\n' < "$release_response")"
|
||
fi
|
||
exit 1
|
||
;;
|
||
esac
|
||
rm -rf -- "$destination/release"
|
||
success "Memby $release_version is available to older TVs"
|
||
else
|
||
detail 'Skipped by -SkipAppRelease'
|
||
fi
|
||
|
||
printf '\n'
|
||
docker compose ps
|
||
printf '\n'
|
||
|
||
rm -rf -- "$backup"
|
||
activated=0
|
||
trap - EXIT INT TERM
|
||
success 'Remote deployment is healthy'
|
||
success 'Memby gateway: https://mserver.sublogue.com'
|
||
'@
|
||
|
||
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
|
||
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
|
||
$remoteCommand = $remoteCommand.Replace(
|
||
'__PUBLISH_RELEASE__',
|
||
$(if ($SkipAppRelease) { '0' } else { '1' })
|
||
)
|
||
$remoteCommand = $remoteCommand.Replace(
|
||
'__MANDATORY_UPDATE__',
|
||
$(if ($mandatoryRelease) { '1' } else { '0' })
|
||
)
|
||
$remoteCommand = $remoteCommand.Replace(
|
||
'__QUIET_DEPLOYMENT__',
|
||
$(if ($quietDeployment) { '1' } else { '0' })
|
||
)
|
||
$remoteCommand = $remoteCommand.Replace(
|
||
'__COLOUR_OUTPUT__',
|
||
$(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })
|
||
)
|
||
$remoteCommand = $remoteCommand.Replace('__REMOTE_STEP_OFFSET__', $script:LocalStepCount.ToString())
|
||
$remoteCommand = $remoteCommand.Replace('__TOTAL_STEPS__', $script:TotalSteps.ToString())
|
||
# This file is edited on Windows, so the here-string above arrives with whatever line
|
||
# endings it was saved with. A remote shell reads a trailing carriage return as part
|
||
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
|
||
# script is normalised to LF here rather than depending on how it was saved.
|
||
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
|
||
|
||
Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange
|
||
Write-Detail 'One SSH password prompt will appear'
|
||
Write-Detail 'Remote build output follows; its steps continue the overall counter'
|
||
Write-Host ''
|
||
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
|
||
|
||
Complete-DeploymentPhase
|
||
$deploymentTimer.Stop()
|
||
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
|
||
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
|
||
Write-Host ''
|
||
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
|
||
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
|
||
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
|
||
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
|
||
if (-not $SkipAppRelease) {
|
||
Write-Styled -Message " TV update: Memby $releaseVersion (signed and published)" -Colour White
|
||
}
|
||
}
|
||
catch {
|
||
Complete-DeploymentPhase
|
||
$deploymentTimer.Stop()
|
||
if ($script:UseAnimation) {
|
||
Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed
|
||
Write-Progress -Id 1 -Activity 'Memby deployment' -Completed
|
||
}
|
||
Write-Host ''
|
||
Write-Failure "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))"
|
||
Write-Styled -Message " $($_.Exception.Message)" -Colour Red
|
||
throw
|
||
}
|
||
finally {
|
||
if (Test-Path -LiteralPath $workDirectory) {
|
||
Remove-Item -LiteralPath $workDirectory -Recurse -Force
|
||
}
|
||
}
|