956 lines
33 KiB
PowerShell
956 lines
33 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.
|
|
|
|
.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
|
|
#>
|
|
|
|
#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,
|
|
|
|
# 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:TotalSteps = if ($SkipAppRelease) { 5 } else { 6 }
|
|
|
|
function Write-Banner {
|
|
Write-Host ''
|
|
Write-Host '╭─ Memby deployment' -ForegroundColor Magenta
|
|
Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray
|
|
Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray
|
|
if ($mandatoryRelease) {
|
|
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
|
|
}
|
|
if ($quietDeployment) {
|
|
Write-Host '│ Notice quiet (no advance television announcement)' -ForegroundColor DarkGray
|
|
}
|
|
Write-Host '╰─' -ForegroundColor Magenta
|
|
Write-Host ''
|
|
}
|
|
|
|
function Write-Step {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string] $Message
|
|
)
|
|
|
|
$script:CurrentStep++
|
|
Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan
|
|
}
|
|
|
|
function Write-Detail {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string] $Message
|
|
)
|
|
|
|
Write-Host " ↳ $Message" -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Write-Success {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string] $Message
|
|
)
|
|
|
|
Write-Host "✓ $Message" -ForegroundColor Green
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
# Everything under server/ is streamed over one SSH connection, so a build
|
|
# artefact left in the tree is paid for on the wire. The exclusions mirror
|
|
# server/.dockerignore: a GOCACHE pointed inside server/ (see the note there)
|
|
# is gitignored and therefore silent, and turned a 1.2MB source tree into a
|
|
# 774MB deployment. Both the directory entry and its contents are named
|
|
# because bsdtar — which is what tar.exe is on Windows — matches --exclude
|
|
# against each entry path rather than pruning the walk.
|
|
$arguments = @(
|
|
'-cf', $ArchivePath,
|
|
'--exclude', 'server/.tmp-go-cache', '--exclude', 'server/.tmp-go-cache/*',
|
|
'--exclude', 'server/bin', '--exclude', 'server/bin/*',
|
|
'-C', $RepositoryDirectory,
|
|
'server', '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 {
|
|
$archiveStream.CopyTo($process.StandardInput.BaseStream)
|
|
}
|
|
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."
|
|
}
|
|
}
|
|
|
|
Write-Banner
|
|
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
|
|
Write-Step 'Checking local prerequisites and settings'
|
|
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'
|
|
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
|
|
Write-Success "Using $checkoutDirectory"
|
|
Write-Host ''
|
|
|
|
Write-Step 'Validating the Compose deployment payload'
|
|
$requiredPaths = @(
|
|
(Join-Path $checkoutDirectory 'server'),
|
|
(Join-Path $checkoutDirectory 'server/Dockerfile'),
|
|
(Join-Path $checkoutDirectory 'server/go.mod'),
|
|
(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 '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"
|
|
}
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace(
|
|
(Get-DotEnvValue -Path (Join-Path $checkoutDirectory '.env.example') `
|
|
-Name 'MEMBY_RELEASE_PUBLISH_TOKEN')
|
|
)) {
|
|
throw 'MEMBY_RELEASE_PUBLISH_TOKEN is empty in .env.example; a signed APK cannot be published.'
|
|
}
|
|
}
|
|
Write-Success 'Deployment payload is complete'
|
|
Write-Host ''
|
|
|
|
if (-not $SkipAppRelease) {
|
|
Write-Step 'Building and verifying the signed Android update'
|
|
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'
|
|
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__
|
|
parent=$(dirname "$destination")
|
|
staging="${destination}.new.$$"
|
|
backup="${destination}.previous.$$"
|
|
activated=0
|
|
previous_stopped=0
|
|
|
|
step() {
|
|
printf '\033[36m ● %s\033[0m\n' "$1"
|
|
}
|
|
|
|
detail() {
|
|
printf '\033[90m ↳ %s\033[0m\n' "$1"
|
|
}
|
|
|
|
success() {
|
|
printf '\033[32m ✓ %s\033[0m\n' "$1"
|
|
}
|
|
|
|
failure() {
|
|
printf '\033[31m ✗ %s\033[0m\n' "$1" >&2
|
|
}
|
|
|
|
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"
|
|
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
|
) || 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"
|
|
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
|
) || 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))
|
|
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 '[remote 1/10] 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 '[remote 2/10] 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"
|
|
if [ "$publish_release" -eq 1 ]; then
|
|
test -f "$staging/release/version.txt"
|
|
test -f "$staging/release/sha256.txt"
|
|
fi
|
|
success 'Release extracted'
|
|
|
|
step '[remote 3/10] 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=''
|
|
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')
|
|
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
|
cp -- "$destination/.env" "$staging/.env.previous"
|
|
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')
|
|
release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//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 [ "$publish_release" -eq 1 ] && [ -z "$release_token" ]; then
|
|
failure 'MEMBY_RELEASE_PUBLISH_TOKEN is empty; the signed APK cannot be published'
|
|
exit 1
|
|
fi
|
|
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 '[remote 4/10] 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 '[remote 5/10] Pulling PostgreSQL and Redis'
|
|
(
|
|
cd "$staging"
|
|
docker compose pull postgres redis
|
|
)
|
|
success 'Dependency images are ready'
|
|
|
|
step '[remote 6/10] Building memby-server'
|
|
(
|
|
cd "$staging"
|
|
docker compose build --pull server
|
|
)
|
|
success 'Server image built'
|
|
|
|
step '[remote 7/10] 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 '[remote 8/10] Starting the Compose stack'
|
|
cd "$destination"
|
|
docker compose up -d --remove-orphans
|
|
success 'Compose start command completed'
|
|
|
|
step '[remote 9/10] Waiting for healthy services'
|
|
wait_for_service postgres
|
|
wait_for_service redis
|
|
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 '[remote 10/10] 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_status=$(curl --show-error --silent \
|
|
--output "$release_response" \
|
|
--write-out '%{http_code}' \
|
|
-X POST \
|
|
-H "Authorization: Bearer $release_token" \
|
|
-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' })
|
|
)
|
|
# 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")
|
|
|
|
Write-Step "Deploying to $RemoteHost"
|
|
Write-Detail 'One SSH password prompt will appear'
|
|
Write-Detail 'Remote build output follows'
|
|
Write-Host ''
|
|
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
|
|
|
|
$deploymentTimer.Stop()
|
|
Write-Host ''
|
|
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
|
|
Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White
|
|
Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray
|
|
Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray
|
|
if (-not $SkipAppRelease) {
|
|
Write-Host " TV update: Memby $releaseVersion (signed and published)" -ForegroundColor White
|
|
}
|
|
}
|
|
catch {
|
|
$deploymentTimer.Stop()
|
|
Write-Host ''
|
|
Write-Host "✗ Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
|
|
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
|
|
throw
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $workDirectory) {
|
|
Remove-Item -LiteralPath $workDirectory -Recurse -Force
|
|
}
|
|
}
|