2026-07-29 15:26:40 +12:00
|
|
|
|
<#
|
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
|
Deploys the complete Memby Docker Compose stack to MATT-NAS.
|
|
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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.
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-11 23:41:10 +12:00
|
|
|
|
- unless --Quiet is supplied, tells every signed-in television a deployment
|
|
|
|
|
|
is starting through the gateway that is still running before the build begins;
|
2026-07-29 15:26:40 +12:00
|
|
|
|
- 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.
|
2026-08-17 13:13:10 +12:00
|
|
|
|
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The
|
|
|
|
|
|
console is its own container with its own health check and nothing depends on it, so
|
|
|
|
|
|
only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one
|
|
|
|
|
|
container is restarted. The gateway, PostgreSQL and Redis keep running throughout,
|
|
|
|
|
|
.env and docker-compose.yml are left exactly as deployed, no APK is built, and no
|
|
|
|
|
|
television is told anything because nothing they use goes away. It amends an existing
|
|
|
|
|
|
deployment and refuses to create one.
|
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1
|
|
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates'
|
|
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
|
2026-08-11 23:41:10 +12:00
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 --Quiet
|
2026-08-15 09:23:26 +12:00
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 -EstimateOnly
|
|
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
2026-08-17 13:13:10 +12:00
|
|
|
|
.\deploy-server.ps1 -SkipAppRelease
|
2026-08-18 14:59:29 +12:00
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 --Admin
|
|
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
|
.\deploy-server.ps1 -AdminOnly
|
2026-07-29 15:26:40 +12:00
|
|
|
|
#>
|
|
|
|
|
|
|
|
|
|
|
|
#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)]
|
2026-08-02 22:10:19 +12:00
|
|
|
|
[int] $HealthTimeoutSeconds = 120,
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
|
|
|
|
|
[string] $AppVersion,
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[string] $ReleaseNotes = '',
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[switch] $SkipAppTests,
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter()]
|
2026-08-15 09:23:26 +12:00
|
|
|
|
[switch] $SkipAppRelease,
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
# Replace the operations console and nothing else. The console is its own container
|
|
|
|
|
|
# with its own health check and nothing depends on it, so it can be rebuilt and
|
|
|
|
|
|
# restarted while the gateway, PostgreSQL and Redis keep running — a console change is
|
|
|
|
|
|
# then seconds rather than the several minutes a full stack swap costs, and no
|
|
|
|
|
|
# television notices anything at all.
|
|
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[Alias('Admin')]
|
|
|
|
|
|
[switch] $AdminOnly,
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[Alias('m')]
|
|
|
|
|
|
[switch] $MandatoryUpdate,
|
|
|
|
|
|
|
2026-08-11 23:41:10 +12:00
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[switch] $Quiet,
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[switch] $NoAnimation,
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[Alias('Preview')]
|
|
|
|
|
|
[switch] $EstimateOnly,
|
|
|
|
|
|
|
2026-08-11 23:41:10 +12:00
|
|
|
|
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
|
|
|
|
|
|
# Accept the two documented flags in either positional order instead.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
[Parameter(Position = 0)]
|
2026-08-11 23:41:10 +12:00
|
|
|
|
[string] $TrailingFlag0,
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter(Position = 1)]
|
|
|
|
|
|
[string] $TrailingFlag1
|
2026-07-29 15:26:40 +12:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
Set-StrictMode -Version Latest
|
|
|
|
|
|
$ErrorActionPreference = 'Stop'
|
2026-08-11 23:41:10 +12:00
|
|
|
|
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
2026-08-18 14:59:29 +12:00
|
|
|
|
$unknownFlags = @($trailingFlags | Where-Object {
|
|
|
|
|
|
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin')
|
|
|
|
|
|
})
|
2026-08-11 23:41:10 +12:00
|
|
|
|
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'
|
2026-08-18 14:59:29 +12:00
|
|
|
|
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
|
|
|
|
|
|
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
|
2026-08-02 22:10:19 +12:00
|
|
|
|
}
|
2026-08-18 14:59:29 +12:00
|
|
|
|
# Nothing about the gateway is rebuilt, so there is nothing for a television to be told
|
|
|
|
|
|
# about. Accepting --Quiet here would imply the announcement was a choice on this path.
|
|
|
|
|
|
if ($consoleOnly -and $quietDeployment) {
|
|
|
|
|
|
throw '--Quiet has no meaning with --Admin: no televisions are affected, so none are told.'
|
|
|
|
|
|
}
|
|
|
|
|
|
# The console-only path never packages an APK. Stated rather than silently ignored, so a
|
|
|
|
|
|
# combined invocation cannot look as though it published one.
|
|
|
|
|
|
if ($consoleOnly) {
|
|
|
|
|
|
$SkipAppRelease = $true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Timings are kept per kind of deployment, because the two are not the same operation
|
|
|
|
|
|
# measured twice: a console run rebuilds one small image and a full one swaps the whole
|
|
|
|
|
|
# stack. Averaged together, each estimate would be wrong for both. A record written before
|
|
|
|
|
|
# this existed carries no kind and is read as 'full', which is what all of them were.
|
|
|
|
|
|
$script:DeploymentKind = if ($consoleOnly) { 'console' } else { 'full' }
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
|
|
$script:CurrentStep = 0
|
2026-08-15 09:23:26 +12:00
|
|
|
|
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
|
2026-08-18 14:59:29 +12:00
|
|
|
|
# A console deployment skips the .env install, the dependency pull, the gateway build, the
|
|
|
|
|
|
# stack swap and the APK publish: what is left is check, extract, swap, build, restart, wait.
|
|
|
|
|
|
$script:RemoteStepCount = if ($consoleOnly) { 6 } else { 10 }
|
2026-08-15 09:23:26 +12:00
|
|
|
|
$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
|
2026-08-18 14:59:29 +12:00
|
|
|
|
# A console deployment uploads one small build context and rebuilds one image, where a
|
|
|
|
|
|
# full one ships the Go tree as well and rebuilds the whole stack.
|
|
|
|
|
|
'remote-deployment' = if ($consoleOnly) { 90 } else { 330 }
|
2026-08-15 09:23:26 +12:00
|
|
|
|
}
|
|
|
|
|
|
$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) {
|
2026-08-18 14:59:29 +12:00
|
|
|
|
$kindProperty = $run.PSObject.Properties['kind']
|
|
|
|
|
|
$kind = if ($kindProperty -and $kindProperty.Value) { [string]$kindProperty.Value } else { 'full' }
|
|
|
|
|
|
if ($kind -ne $script:DeploymentKind) { continue }
|
2026-08-15 09:23:26 +12:00
|
|
|
|
$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
|
2026-08-18 14:59:29 +12:00
|
|
|
|
kind = $script:DeploymentKind
|
2026-08-15 09:23:26 +12:00
|
|
|
|
}
|
|
|
|
|
|
$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
|
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
|
|
function Write-Banner {
|
|
|
|
|
|
Write-Host ''
|
2026-08-15 09:23:26 +12:00
|
|
|
|
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
|
2026-08-02 22:10:19 +12:00
|
|
|
|
if ($mandatoryRelease) {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Styled -Message '│ UPDATE mandatory (viewers cannot skip it)' -Colour Yellow
|
2026-08-02 22:10:19 +12:00
|
|
|
|
}
|
2026-08-11 23:41:10 +12:00
|
|
|
|
if ($quietDeployment) {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
2026-08-11 23:41:10 +12:00
|
|
|
|
}
|
2026-08-15 09:23:26 +12:00
|
|
|
|
$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
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Host ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Write-Step {
|
|
|
|
|
|
param(
|
|
|
|
|
|
[Parameter(Mandatory)]
|
2026-08-15 09:23:26 +12:00
|
|
|
|
[string] $Message,
|
|
|
|
|
|
[Parameter(Mandatory)]
|
|
|
|
|
|
[string] $Key
|
2026-07-29 15:26:40 +12:00
|
|
|
|
)
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Start-DeploymentPhase -Key $Key -Message $Message
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Write-Detail {
|
|
|
|
|
|
param(
|
|
|
|
|
|
[Parameter(Mandatory)]
|
|
|
|
|
|
[string] $Message
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Styled -Message " ↳ $Message" -Colour Gray
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Write-Success {
|
|
|
|
|
|
param(
|
|
|
|
|
|
[Parameter(Mandatory)]
|
|
|
|
|
|
[string] $Message
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Styled -Message "✓ $Message" -Colour Green
|
|
|
|
|
|
Update-DeploymentEta -WriteLine
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Write-Failure {
|
|
|
|
|
|
param([Parameter(Mandatory)][string] $Message)
|
|
|
|
|
|
Write-Styled -Message "✗ $Message" -Colour Red
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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)]
|
2026-08-02 22:10:19 +12:00
|
|
|
|
[string] $ArchivePath,
|
|
|
|
|
|
|
|
|
|
|
|
[Parameter()]
|
2026-08-18 14:59:29 +12:00
|
|
|
|
[string] $ReleaseDirectory,
|
|
|
|
|
|
|
|
|
|
|
|
# Pack the console's build context alone. Everything else the remote side needs —
|
|
|
|
|
|
# the Compose file, the environment — is already deployed and is deliberately left
|
|
|
|
|
|
# alone, so it must not be shipped and cannot be changed by accident.
|
|
|
|
|
|
[Parameter()]
|
|
|
|
|
|
[switch] $ConsoleOnly
|
2026-07-29 15:26:40 +12:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-14 09:40:03 +12:00
|
|
|
|
# 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.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
$arguments = @(
|
|
|
|
|
|
'-cf', $ArchivePath,
|
2026-08-06 22:33:56 +12:00
|
|
|
|
'--exclude', 'server/.tmp-go-cache', '--exclude', 'server/.tmp-go-cache/*',
|
|
|
|
|
|
'--exclude', 'server/bin', '--exclude', 'server/bin/*',
|
2026-08-14 09:40:03 +12:00
|
|
|
|
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
|
|
|
|
|
|
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
|
2026-08-18 14:59:29 +12:00
|
|
|
|
'-C', $RepositoryDirectory
|
|
|
|
|
|
) + $(if ($ConsoleOnly) {
|
|
|
|
|
|
@('admin-ui')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
@('server', 'admin-ui', 'docker-compose.yml', '.env.example')
|
|
|
|
|
|
})
|
2026-08-02 22:10:19 +12:00
|
|
|
|
if ($ReleaseDirectory) {
|
|
|
|
|
|
$arguments += @(
|
|
|
|
|
|
'-C', (Split-Path -Parent $ReleaseDirectory),
|
|
|
|
|
|
(Split-Path -Leaf $ReleaseDirectory)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
& $script:TarCommand @arguments
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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 {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
$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 }))
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
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."
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Import-DeploymentHistory
|
|
|
|
|
|
if ($EstimateOnly) {
|
|
|
|
|
|
Show-DeploymentEstimate
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Banner
|
|
|
|
|
|
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Step 'Checking local prerequisites and settings' -Key 'prerequisites'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Step 'Selecting the local deployment source' -Key 'source'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
|
|
|
|
|
|
Write-Success "Using $checkoutDirectory"
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
Write-Step $(if ($consoleOnly) {
|
|
|
|
|
|
'Validating the console deployment payload'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
'Validating the Compose deployment payload'
|
|
|
|
|
|
}) -Key 'payload'
|
|
|
|
|
|
$consolePaths = @(
|
2026-08-14 09:40:03 +12:00
|
|
|
|
(Join-Path $checkoutDirectory 'admin-ui'),
|
|
|
|
|
|
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
|
|
|
|
|
|
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
|
2026-08-18 14:59:29 +12:00
|
|
|
|
(Join-Path $checkoutDirectory 'admin-ui/src')
|
|
|
|
|
|
)
|
|
|
|
|
|
# A console deployment reuses the deployed docker-compose.yml and .env rather than
|
|
|
|
|
|
# shipping its own. That is the whole safety of it: the running gateway's configuration
|
|
|
|
|
|
# is left exactly as it was, so nothing can be changed here that would need it to
|
|
|
|
|
|
# restart to take effect.
|
|
|
|
|
|
$requiredPaths = $consolePaths + $(if ($consoleOnly) { @() } else { @(
|
|
|
|
|
|
(Join-Path $checkoutDirectory 'server'),
|
|
|
|
|
|
(Join-Path $checkoutDirectory 'server/Dockerfile'),
|
|
|
|
|
|
(Join-Path $checkoutDirectory 'server/go.mod'),
|
2026-07-29 15:26:40 +12:00
|
|
|
|
(Join-Path $checkoutDirectory 'docker-compose.yml'),
|
2026-08-17 13:13:10 +12:00
|
|
|
|
(Join-Path $checkoutDirectory '.env.example')
|
2026-08-18 14:59:29 +12:00
|
|
|
|
) })
|
2026-07-29 15:26:40 +12:00
|
|
|
|
foreach ($requiredPath in $requiredPaths) {
|
|
|
|
|
|
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
|
|
|
|
|
throw "Required deployment file is missing: $requiredPath"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-14 09:40:03 +12:00
|
|
|
|
Write-Detail 'admin-ui/ build context'
|
2026-08-18 14:59:29 +12:00
|
|
|
|
if (-not $consoleOnly) {
|
|
|
|
|
|
Write-Detail 'server/ build context'
|
|
|
|
|
|
Write-Detail 'docker-compose.yml'
|
|
|
|
|
|
Write-Detail '.env.example'
|
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
|
$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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Success 'Deployment payload is complete'
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
if (-not $SkipAppRelease) {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Step 'Building and verifying the signed Android update' -Key 'android-release'
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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 ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
Write-Step $(if ($consoleOnly) { 'Packaging the console' } else { 'Packaging the release' }) -Key 'packaging'
|
2026-08-02 22:10:19 +12:00
|
|
|
|
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
|
2026-08-18 14:59:29 +12:00
|
|
|
|
-ReleaseDirectory $releaseDirectory -ConsoleOnly:$consoleOnly
|
2026-07-29 15:26:40 +12:00
|
|
|
|
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
|
|
|
|
|
|
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
|
|
|
|
|
|
Write-Success 'Release archive is ready'
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
# The reporting shared by both remote scripts. Kept in one place because the step
|
|
|
|
|
|
# counter, the wording and the health wait are what make a deployment readable, and two
|
|
|
|
|
|
# copies of them are two things to keep in step. Every template that uses this defines
|
|
|
|
|
|
# colour_output, remote_step, remote_step_offset, total_steps and health_timeout above
|
|
|
|
|
|
# the point it is inserted.
|
|
|
|
|
|
$remoteHelpers = @'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
step() {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
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
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
detail() {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
if [ "$colour_output" -eq 1 ]; then printf '\033[37m ↳ %s\033[0m\n' "$1"; else printf ' - %s\n' "$1"; fi
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
success() {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
if [ "$colour_output" -eq 1 ]; then printf '\033[32m ✓ %s\033[0m\n' "$1"; else printf ' OK %s\n' "$1"; fi
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
failure() {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
'@
|
|
|
|
|
|
|
|
|
|
|
|
# Replacing the console alone.
|
|
|
|
|
|
#
|
|
|
|
|
|
# The console is its own container: nothing depends on it, it holds no state and it has
|
|
|
|
|
|
# a health check of its own, so it can be rebuilt and restarted while the gateway,
|
|
|
|
|
|
# PostgreSQL and Redis carry on serving. That is what makes this path safe enough to be
|
|
|
|
|
|
# worth having — and it is only safe while it stays this narrow. Three things it must
|
|
|
|
|
|
# never do: touch .env or docker-compose.yml (the running gateway's configuration would
|
|
|
|
|
|
# then differ from the file describing it, with no restart to reconcile them), name any
|
|
|
|
|
|
# service but memby-admin, or omit --no-deps (Compose would otherwise be free to
|
|
|
|
|
|
# recreate the gateway as a dependency and take the house down for a CSS change).
|
|
|
|
|
|
$consoleRemoteCommand = @'
|
|
|
|
|
|
set -eu
|
|
|
|
|
|
|
|
|
|
|
|
destination='__DESTINATION__'
|
|
|
|
|
|
health_timeout=__HEALTH_TIMEOUT__
|
|
|
|
|
|
colour_output=__COLOUR_OUTPUT__
|
|
|
|
|
|
remote_step_offset=__REMOTE_STEP_OFFSET__
|
|
|
|
|
|
total_steps=__TOTAL_STEPS__
|
|
|
|
|
|
# Staged and backed up *inside* the deployment directory, so the swap is a rename on one
|
|
|
|
|
|
# filesystem rather than a copy that can be interrupted half way.
|
|
|
|
|
|
staging="${destination}/admin-ui.new.$$"
|
|
|
|
|
|
backup="${destination}/admin-ui.previous.$$"
|
|
|
|
|
|
swapped=0
|
|
|
|
|
|
remote_step=0
|
|
|
|
|
|
|
|
|
|
|
|
__SHELL_HELPERS__
|
|
|
|
|
|
|
|
|
|
|
|
restore_console() {
|
|
|
|
|
|
status=$?
|
|
|
|
|
|
trap - EXIT
|
|
|
|
|
|
|
|
|
|
|
|
if [ "$status" -eq 0 ]; then
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
failure 'Console deployment failed; cleaning up'
|
|
|
|
|
|
rm -rf -- "$staging"
|
|
|
|
|
|
|
|
|
|
|
|
if [ "$swapped" -eq 1 ] && [ -d "$backup" ]; then
|
|
|
|
|
|
detail 'Restoring the previous console'
|
|
|
|
|
|
rm -rf -- "$destination/admin-ui"
|
|
|
|
|
|
mv -- "$backup" "$destination/admin-ui"
|
|
|
|
|
|
# Rebuilt as well as restored: the image that is running is the one that was just
|
|
|
|
|
|
# built from the files being thrown away, so putting the directory back without
|
|
|
|
|
|
# rebuilding would leave the NAS serving exactly what failed.
|
|
|
|
|
|
(
|
|
|
|
|
|
cd "$destination"
|
|
|
|
|
|
docker compose build memby-admin >/dev/null 2>&1 &&
|
|
|
|
|
|
docker compose up -d --no-deps --no-build memby-admin >/dev/null 2>&1
|
|
|
|
|
|
) || true
|
|
|
|
|
|
failure 'Previous console files were restored'
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
exit "$status"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
trap restore_console EXIT
|
|
|
|
|
|
trap 'exit 130' INT TERM
|
|
|
|
|
|
|
|
|
|
|
|
step 'Checking Docker and the deployed stack'
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
# A console deployment amends a deployment that already exists. It cannot create one:
|
|
|
|
|
|
# there is no .env and no Compose file in this archive, by design.
|
|
|
|
|
|
if [ ! -f "$destination/docker-compose.yml" ]; then
|
|
|
|
|
|
failure "No deployment found at $destination"
|
|
|
|
|
|
detail 'Run a full deployment first; --Admin only replaces the console of an existing one'
|
|
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
if ! (cd "$destination" && docker compose config --services 2>/dev/null | grep -qx 'memby-admin'); then
|
|
|
|
|
|
failure 'The deployed docker-compose.yml has no memby-admin service'
|
|
|
|
|
|
detail 'That release predates the separate console; run a full deployment'
|
|
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
success "$(docker --version)"
|
|
|
|
|
|
success 'Existing deployment found'
|
|
|
|
|
|
|
|
|
|
|
|
step 'Extracting the console'
|
|
|
|
|
|
rm -rf -- "$staging"
|
|
|
|
|
|
mkdir -- "$staging"
|
|
|
|
|
|
tar -xf - -C "$staging"
|
|
|
|
|
|
test -f "$staging/admin-ui/Dockerfile"
|
|
|
|
|
|
test -f "$staging/admin-ui/package.json"
|
|
|
|
|
|
test -d "$staging/admin-ui/src"
|
|
|
|
|
|
success 'Console sources extracted'
|
|
|
|
|
|
|
|
|
|
|
|
step 'Replacing the console files'
|
|
|
|
|
|
rm -rf -- "$backup"
|
|
|
|
|
|
if [ -d "$destination/admin-ui" ]; then
|
|
|
|
|
|
mv -- "$destination/admin-ui" "$backup"
|
|
|
|
|
|
fi
|
|
|
|
|
|
mv -- "$staging/admin-ui" "$destination/admin-ui"
|
|
|
|
|
|
rm -rf -- "$staging"
|
|
|
|
|
|
swapped=1
|
|
|
|
|
|
success 'Console files replaced'
|
|
|
|
|
|
|
|
|
|
|
|
step 'Building the console image'
|
|
|
|
|
|
# Built before anything is restarted, so a console that does not compile leaves the one
|
|
|
|
|
|
# that is running untouched. The type check runs inside this build.
|
|
|
|
|
|
(
|
|
|
|
|
|
cd "$destination"
|
|
|
|
|
|
docker compose build memby-admin
|
|
|
|
|
|
)
|
|
|
|
|
|
success 'Console image built'
|
|
|
|
|
|
|
|
|
|
|
|
step 'Restarting the console'
|
|
|
|
|
|
(
|
|
|
|
|
|
cd "$destination"
|
|
|
|
|
|
# --no-deps is what keeps this to one container; --no-build because it was just built.
|
|
|
|
|
|
if ! docker compose up -d --no-deps --no-build memby-admin; then
|
|
|
|
|
|
failure 'Compose could not start the console'
|
|
|
|
|
|
docker compose logs --no-color --tail 60 memby-admin || true
|
|
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
)
|
|
|
|
|
|
success 'Console container restarted'
|
|
|
|
|
|
|
|
|
|
|
|
step 'Waiting for the console'
|
|
|
|
|
|
cd "$destination"
|
|
|
|
|
|
wait_for_service memby-admin
|
|
|
|
|
|
|
|
|
|
|
|
# The gateway is what serves /admin, and it was never restarted — so this is a check that
|
|
|
|
|
|
# the console is reachable the way an operator actually reaches it, not merely that its own
|
|
|
|
|
|
# container is up.
|
|
|
|
|
|
if command -v curl >/dev/null 2>&1; then
|
|
|
|
|
|
console_status=$(curl --silent --show-error --max-time 5 \
|
|
|
|
|
|
--output /dev/null --write-out '%{http_code}' \
|
|
|
|
|
|
http://127.0.0.1:32768/admin/ 2>/dev/null || true)
|
|
|
|
|
|
case "$console_status" in
|
|
|
|
|
|
2??|3??) success 'Console is being served through the gateway' ;;
|
|
|
|
|
|
*) detail "Console health is good but the gateway returned HTTP ${console_status:-no response} for /admin/" ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
printf '\n'
|
|
|
|
|
|
docker compose ps
|
|
|
|
|
|
printf '\n'
|
|
|
|
|
|
|
|
|
|
|
|
rm -rf -- "$backup"
|
|
|
|
|
|
swapped=0
|
|
|
|
|
|
trap - EXIT INT TERM
|
|
|
|
|
|
success 'Console deployment is healthy'
|
|
|
|
|
|
success 'Memby console: https://mserver.sublogue.com/admin/'
|
|
|
|
|
|
'@
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
__SHELL_HELPERS__
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
start_restored_stack() {
|
2026-08-17 13:13:10 +12:00
|
|
|
|
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-08-17 13:13:10 +12:00
|
|
|
|
docker compose down --remove-orphans >/dev/null 2>&1
|
2026-07-29 15:26:40 +12:00
|
|
|
|
) || true
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
rm -rf -- "$destination"
|
|
|
|
|
|
if [ -e "$backup" ] || [ -L "$backup" ]; then
|
|
|
|
|
|
detail "Restoring the previous application release"
|
|
|
|
|
|
mv -- "$backup" "$destination"
|
|
|
|
|
|
(
|
|
|
|
|
|
cd "$destination"
|
2026-08-15 09:23:26 +12:00
|
|
|
|
start_restored_stack
|
2026-07-29 15:26:40 +12:00
|
|
|
|
) || 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"
|
2026-08-15 09:23:26 +12:00
|
|
|
|
start_restored_stack
|
2026-07-29 15:26:40 +12:00
|
|
|
|
) || true
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
exit "$status"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
trap rollback EXIT
|
|
|
|
|
|
trap 'exit 130' INT TERM
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Checking Docker'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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
|
2026-07-29 15:26:40 +12:00
|
|
|
|
success "$(docker --version)"
|
|
|
|
|
|
success "$(docker compose version)"
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Extracting the release'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
# 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"
|
2026-08-14 09:40:03 +12:00
|
|
|
|
test -f "$staging/admin-ui/Dockerfile"
|
|
|
|
|
|
test -f "$staging/admin-ui/package.json"
|
2026-08-02 22:10:19 +12:00
|
|
|
|
if [ "$publish_release" -eq 1 ]; then
|
|
|
|
|
|
test -f "$staging/release/version.txt"
|
|
|
|
|
|
test -f "$staging/release/sha256.txt"
|
|
|
|
|
|
fi
|
2026-07-29 15:26:40 +12:00
|
|
|
|
success 'Release extracted'
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Installing configuration from .env.example'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
# 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=''
|
2026-08-06 22:33:56 +12:00
|
|
|
|
previous_admin_token=''
|
2026-08-15 09:23:26 +12:00
|
|
|
|
previous_release_token=''
|
2026-07-29 15:26:40 +12:00
|
|
|
|
if [ -f "$destination/.env" ]; then
|
|
|
|
|
|
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
2026-08-06 22:33:56 +12:00
|
|
|
|
# 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')
|
2026-08-17 13:13:10 +12:00
|
|
|
|
# First file-backed-secret deployment only: migrate the established publish token out
|
2026-08-15 09:23:26 +12:00
|
|
|
|
# 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')
|
2026-07-29 15:26:40 +12:00
|
|
|
|
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
|
|
|
|
|
cp -- "$destination/.env" "$staging/.env.previous"
|
2026-08-15 09:23:26 +12:00
|
|
|
|
# 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
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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')
|
2026-08-15 09:23:26 +12:00
|
|
|
|
secrets_dir=$(sed -n 's/^MEMBY_SECRETS_DIR=//p' "$staging/.env" | head -n 1 | tr -d '\r')
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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
|
2026-08-15 09:23:26 +12:00
|
|
|
|
if [ -z "$secrets_dir" ] || [ "${secrets_dir#/}" = "$secrets_dir" ]; then
|
|
|
|
|
|
failure 'MEMBY_SECRETS_DIR must be an absolute NAS path outside the deployment directory'
|
2026-08-02 22:10:19 +12:00
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
2026-08-15 09:23:26 +12:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-17 13:13:10 +12:00
|
|
|
|
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'
|
2026-08-15 09:23:26 +12:00
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
|
2026-08-17 13:13:10 +12:00
|
|
|
|
# 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.
|
2026-08-15 09:23:26 +12:00
|
|
|
|
chmod 700 "$secrets_dir"
|
|
|
|
|
|
chmod 444 "$release_token_file"
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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'
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Telling the televisions'
|
2026-08-06 22:33:56 +12:00
|
|
|
|
# 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.
|
2026-08-11 23:41:10 +12:00
|
|
|
|
if [ "$quiet_deployment" -eq 1 ]; then
|
|
|
|
|
|
detail 'Quiet deployment; skipping the television announcement'
|
|
|
|
|
|
elif [ -z "$previous_admin_token" ]; then
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Pulling PostgreSQL and Redis'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
(
|
|
|
|
|
|
cd "$staging"
|
|
|
|
|
|
docker compose pull postgres redis
|
|
|
|
|
|
)
|
|
|
|
|
|
success 'Dependency images are ready'
|
|
|
|
|
|
|
2026-08-17 13:13:10 +12:00
|
|
|
|
step 'Building the gateway and admin console'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
(
|
|
|
|
|
|
cd "$staging"
|
2026-08-14 09:40:03 +12:00
|
|
|
|
# `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).
|
2026-08-17 13:13:10 +12:00
|
|
|
|
docker compose build --pull server memby-admin
|
2026-07-29 15:26:40 +12:00
|
|
|
|
)
|
2026-08-17 13:13:10 +12:00
|
|
|
|
success 'Gateway and admin console images built'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Activating the release'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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.
|
2026-08-17 13:13:10 +12:00
|
|
|
|
detail 'Stopping the previous Compose application'
|
|
|
|
|
|
(
|
|
|
|
|
|
cd "$destination"
|
|
|
|
|
|
docker compose down --remove-orphans
|
|
|
|
|
|
)
|
2026-07-29 15:26:40 +12:00
|
|
|
|
previous_stopped=1
|
|
|
|
|
|
success 'Previous Compose application stopped'
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
mv -- "$destination" "$backup"
|
|
|
|
|
|
fi
|
|
|
|
|
|
mv -- "$staging" "$destination"
|
|
|
|
|
|
activated=1
|
|
|
|
|
|
success 'Release activated'
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Starting the Compose stack'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
cd "$destination"
|
2026-08-17 13:13:10 +12:00
|
|
|
|
if ! docker compose up -d --no-build --remove-orphans; then
|
2026-08-15 09:23:26 +12:00
|
|
|
|
failure 'Compose could not start the complete application'
|
|
|
|
|
|
detail 'Container state before rollback:'
|
|
|
|
|
|
docker compose ps --all || true
|
2026-08-17 13:13:10 +12:00
|
|
|
|
detail 'Gateway logs before rollback:'
|
|
|
|
|
|
docker compose logs --no-color --tail 100 server || true
|
2026-08-15 09:23:26 +12:00
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
2026-07-29 15:26:40 +12:00
|
|
|
|
success 'Compose start command completed'
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Waiting for healthy services'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
wait_for_service postgres
|
|
|
|
|
|
wait_for_service redis
|
2026-08-14 09:40:03 +12:00
|
|
|
|
wait_for_service memby-admin
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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'
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
step 'Publishing the signed Android update'
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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"
|
2026-08-15 09:23:26 +12:00
|
|
|
|
release_token=$(tr -d '\r\n' < "$release_token_file")
|
|
|
|
|
|
release_status=$(printf 'header = "Authorization: Bearer %s"\n' "$release_token" | curl --config - --show-error --silent \
|
2026-08-02 22:10:19 +12:00
|
|
|
|
--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
|
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
|
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'
|
|
|
|
|
|
'@
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
if ($consoleOnly) { $remoteCommand = $consoleRemoteCommand }
|
|
|
|
|
|
# The helpers go in before anything else, so a token inside them is substituted too.
|
|
|
|
|
|
$remoteCommand = $remoteCommand.Replace('__SHELL_HELPERS__', $remoteHelpers)
|
2026-07-29 15:26:40 +12:00
|
|
|
|
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
|
|
|
|
|
|
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
|
2026-08-02 22:10:19 +12:00
|
|
|
|
$remoteCommand = $remoteCommand.Replace(
|
|
|
|
|
|
'__PUBLISH_RELEASE__',
|
|
|
|
|
|
$(if ($SkipAppRelease) { '0' } else { '1' })
|
|
|
|
|
|
)
|
|
|
|
|
|
$remoteCommand = $remoteCommand.Replace(
|
|
|
|
|
|
'__MANDATORY_UPDATE__',
|
|
|
|
|
|
$(if ($mandatoryRelease) { '1' } else { '0' })
|
|
|
|
|
|
)
|
2026-08-11 23:41:10 +12:00
|
|
|
|
$remoteCommand = $remoteCommand.Replace(
|
|
|
|
|
|
'__QUIET_DEPLOYMENT__',
|
|
|
|
|
|
$(if ($quietDeployment) { '1' } else { '0' })
|
|
|
|
|
|
)
|
2026-08-15 09:23:26 +12:00
|
|
|
|
$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())
|
2026-07-29 15:26:40 +12:00
|
|
|
|
# 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")
|
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
Start-DeploymentPhase -Key 'remote-deployment' -Message $(if ($consoleOnly) {
|
|
|
|
|
|
"Deploying the console to $RemoteHost"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"Deploying to $RemoteHost"
|
|
|
|
|
|
}) -RemoteRange
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Detail 'One SSH password prompt will appear'
|
2026-08-18 14:59:29 +12:00
|
|
|
|
if ($consoleOnly) {
|
|
|
|
|
|
Write-Detail 'The gateway, PostgreSQL and Redis keep running throughout'
|
|
|
|
|
|
}
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Detail 'Remote build output follows; its steps continue the overall counter'
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Host ''
|
|
|
|
|
|
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
|
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Complete-DeploymentPhase
|
2026-07-29 15:26:40 +12:00
|
|
|
|
$deploymentTimer.Stop()
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
|
|
|
|
|
|
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Host ''
|
2026-08-18 14:59:29 +12:00
|
|
|
|
Write-Success ($(if ($consoleOnly) {
|
|
|
|
|
|
"Console deployment complete in {0:mm\:ss}"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"Deployment complete in {0:mm\:ss}"
|
|
|
|
|
|
}) -f $deploymentTimer.Elapsed)
|
|
|
|
|
|
if ($consoleOnly) {
|
|
|
|
|
|
Write-Styled -Message ' Memby console: https://mserver.sublogue.com/admin/' -Colour White
|
|
|
|
|
|
Write-Styled -Message ' Gateway: untouched and still serving' -Colour Gray
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
|
|
|
|
|
|
}
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
|
|
|
|
|
|
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
|
2026-08-02 22:10:19 +12:00
|
|
|
|
if (-not $SkipAppRelease) {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Styled -Message " TV update: Memby $releaseVersion (signed and published)" -Colour White
|
2026-08-02 22:10:19 +12:00
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
}
|
|
|
|
|
|
catch {
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Complete-DeploymentPhase
|
2026-07-29 15:26:40 +12:00
|
|
|
|
$deploymentTimer.Stop()
|
2026-08-15 09:23:26 +12:00
|
|
|
|
if ($script:UseAnimation) {
|
|
|
|
|
|
Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed
|
|
|
|
|
|
Write-Progress -Id 1 -Activity 'Memby deployment' -Completed
|
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
|
Write-Host ''
|
2026-08-15 09:23:26 +12:00
|
|
|
|
Write-Failure "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))"
|
|
|
|
|
|
Write-Styled -Message " $($_.Exception.Message)" -Colour Red
|
2026-07-29 15:26:40 +12:00
|
|
|
|
throw
|
|
|
|
|
|
}
|
|
|
|
|
|
finally {
|
|
|
|
|
|
if (Test-Path -LiteralPath $workDirectory) {
|
|
|
|
|
|
Remove-Item -LiteralPath $workDirectory -Recurse -Force
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|