0.2.76 - Icon Packs

This commit is contained in:
ponzischeme89
2026-08-18 14:59:29 +12:00
parent 36d171e51b
commit 8c847c59b8
70 changed files with 5267 additions and 673 deletions
+331 -81
View File
@@ -28,6 +28,14 @@ by this script.
This deploys the current local working tree, including uncommitted server changes.
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
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.
.EXAMPLE
.\deploy-server.ps1
@@ -48,6 +56,12 @@ Use -SkipAppRelease for an admin/server-only deployment: no APK is built or publ
.EXAMPLE
.\deploy-server.ps1 -SkipAppRelease
.EXAMPLE
.\deploy-server.ps1 --Admin
.EXAMPLE
.\deploy-server.ps1 -AdminOnly
#>
#Requires -Version 7.2
@@ -87,6 +101,15 @@ param(
[Parameter()]
[switch] $SkipAppRelease,
# 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,
[Parameter()]
[Alias('m')]
[switch] $MandatoryUpdate,
@@ -113,20 +136,41 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') })
$unknownFlags = @($trailingFlags | Where-Object {
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin')
})
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'
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
if ($mandatoryRelease -and $SkipAppRelease) {
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
}
# 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' }
$script:CurrentStep = 0
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
$script:RemoteStepCount = 10
# 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 }
$script:TotalSteps = $script:LocalStepCount + $script:RemoteStepCount
$script:PhaseOrder = @(
'prerequisites',
@@ -142,7 +186,9 @@ $script:FallbackSeconds = @{
'payload' = 4
'android-release' = 230
'packaging' = 12
'remote-deployment' = 330
# 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 }
}
$script:PhaseDurations = [ordered]@{}
$script:CurrentPhaseKey = $null
@@ -205,6 +251,9 @@ function Import-DeploymentHistory {
function Get-PhaseHistory {
param([Parameter(Mandatory)][string] $Key)
$values = foreach ($run in $script:History) {
$kindProperty = $run.PSObject.Properties['kind']
$kind = if ($kindProperty -and $kindProperty.Value) { [string]$kindProperty.Value } else { 'full' }
if ($kind -ne $script:DeploymentKind) { continue }
$phasesProperty = $run.PSObject.Properties['phases']
if (-not $phasesProperty -or -not $phasesProperty.Value) { continue }
$property = $phasesProperty.Value.PSObject.Properties[$Key]
@@ -360,6 +409,7 @@ function Save-DeploymentHistory {
durationSeconds = [Math]::Round($DurationSeconds, 2)
phases = $script:PhaseDurations
appRelease = -not [bool]$SkipAppRelease
kind = $script:DeploymentKind
}
$runs = @($script:History) + @([pscustomobject]$record) | Select-Object -Last 30
$directory = Split-Path -Parent $script:HistoryPath
@@ -607,7 +657,13 @@ function New-DeploymentArchive {
[string] $ArchivePath,
[Parameter()]
[string] $ReleaseDirectory
[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
)
# Build artefacts left in either build context are paid for on the wire. Both
@@ -620,9 +676,12 @@ function New-DeploymentArchive {
'--exclude', 'server/bin', '--exclude', 'server/bin/*',
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
'-C', $RepositoryDirectory,
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
)
'-C', $RepositoryDirectory
) + $(if ($ConsoleOnly) {
@('admin-ui')
} else {
@('server', 'admin-ui', 'docker-compose.yml', '.env.example')
})
if ($ReleaseDirectory) {
$arguments += @(
'-C', (Split-Path -Parent $ReleaseDirectory),
@@ -770,27 +829,39 @@ try {
Write-Success "Using $checkoutDirectory"
Write-Host ''
Write-Step 'Validating the Compose deployment payload' -Key 'payload'
$requiredPaths = @(
(Join-Path $checkoutDirectory 'server'),
(Join-Path $checkoutDirectory 'server/Dockerfile'),
(Join-Path $checkoutDirectory 'server/go.mod'),
Write-Step $(if ($consoleOnly) {
'Validating the console deployment payload'
} else {
'Validating the Compose deployment payload'
}) -Key 'payload'
$consolePaths = @(
(Join-Path $checkoutDirectory 'admin-ui'),
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
(Join-Path $checkoutDirectory 'admin-ui/src'),
(Join-Path $checkoutDirectory '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'),
(Join-Path $checkoutDirectory 'docker-compose.yml'),
(Join-Path $checkoutDirectory '.env.example')
)
) })
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Required deployment file is missing: $requiredPath"
}
}
Write-Detail 'server/ build context'
Write-Detail 'admin-ui/ build context'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
if (-not $consoleOnly) {
Write-Detail 'server/ build context'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
}
$releaseDirectory = ''
$releaseVersion = ''
$releaseSHA256 = ''
@@ -882,34 +953,20 @@ try {
Write-Host ''
}
Write-Step 'Packaging the release' -Key 'packaging'
Write-Step $(if ($consoleOnly) { 'Packaging the console' } else { 'Packaging the release' }) -Key 'packaging'
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
-ReleaseDirectory $releaseDirectory
-ReleaseDirectory $releaseDirectory -ConsoleOnly:$consoleOnly
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
Write-Success 'Release archive is ready'
Write-Host ''
# This template is single-quoted so PowerShell does not expand the remote
# shell's variables. Replacement values are validated before insertion.
$remoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
activated=0
previous_stopped=0
remote_step=0
# 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 = @'
step() {
remote_step=$((remote_step + 1))
overall_step=$((remote_step_offset + remote_step))
@@ -932,6 +989,217 @@ failure() {
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
}
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__
start_restored_stack() {
docker compose up -d --build --remove-orphans >/dev/null 2>&1
}
@@ -977,43 +1245,6 @@ rollback() {
exit "$status"
}
wait_for_service() {
service="$1"
elapsed=0
detail "Waiting for $service"
while [ "$elapsed" -lt "$health_timeout" ]; do
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
success "$service is $state ($health)"
return 0
fi
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
failure "$service entered state: $state"
docker compose logs --no-color --tail 60 "$service" || true
return 1
fi
fi
sleep 2
elapsed=$((elapsed + 2))
if [ $((elapsed % 10)) -eq 0 ]; then
remaining=$((health_timeout - elapsed))
detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining"
fi
done
failure "$service did not become healthy within ${health_timeout}s"
docker compose logs --no-color --tail 60 "$service" || true
return 1
}
trap rollback EXIT
trap 'exit 130' INT TERM
@@ -1320,6 +1551,9 @@ success 'Remote deployment is healthy'
success 'Memby gateway: https://mserver.sublogue.com'
'@
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)
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
$remoteCommand = $remoteCommand.Replace(
@@ -1346,8 +1580,15 @@ success 'Memby gateway: https://mserver.sublogue.com'
# script is normalised to LF here rather than depending on how it was saved.
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange
Start-DeploymentPhase -Key 'remote-deployment' -Message $(if ($consoleOnly) {
"Deploying the console to $RemoteHost"
} else {
"Deploying to $RemoteHost"
}) -RemoteRange
Write-Detail 'One SSH password prompt will appear'
if ($consoleOnly) {
Write-Detail 'The gateway, PostgreSQL and Redis keep running throughout'
}
Write-Detail 'Remote build output follows; its steps continue the overall counter'
Write-Host ''
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
@@ -1357,8 +1598,17 @@ success 'Memby gateway: https://mserver.sublogue.com'
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
Write-Host ''
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
Write-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
}
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
if (-not $SkipAppRelease) {