diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 58020db5..fbd69e6b 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -37,6 +37,8 @@ Date: 2026-06-08. - Windows/Desktop update channel defaults now match the build channel: stable builds default to `stable`, beta builds default to `beta`. - Windows/Desktop CI now validates production release invariants before Windows packaging and requires signing variables for stable tag releases. - Windows/Desktop now has a configured HTTPS update feed at `https://qsfera.kusoft.xyz/desktop/updates/stable.xml`; the feed returned HTTP `200` on 2026-06-08 and contains the XML elements parsed by `Desktop/src/gui/updater/updateinfo.cpp`. +- Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed. +- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-08: server `edition=stable`/`productversion=7.0.0`, Android `1.3.6`/`34`, downloaded APK size `12217032`, APK SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`, and valid Desktop no-update feed. ## Raspberry Pi 5 Check diff --git a/scripts/verify-production-state.ps1 b/scripts/verify-production-state.ps1 new file mode 100644 index 00000000..97779541 --- /dev/null +++ b/scripts/verify-production-state.ps1 @@ -0,0 +1,201 @@ +param( + [string]$ServerStatusUrl = "https://qsfera.kusoft.xyz/status.php", + [string]$ExpectedServerVersion = "7.0.0", + [string]$ExpectedServerEdition = "stable", + + [string]$AndroidManifestUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable", + [string]$ExpectedAndroidSlug = "qsfera-mobile", + [string]$ExpectedAndroidPackageName = "eu.qsfera.android", + [string]$ExpectedAndroidVersion = "1.3.6", + [int]$ExpectedAndroidVersionCode = 34, + [string]$ExpectedAndroidChannel = "stable", + [string]$ExpectedAndroidPlatform = "android", + [string]$ExpectedAndroidPackageKind = "apk", + [string]$ExpectedAndroidSha256 = "921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428", + [string]$AndroidDownloadUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable", + [switch]$VerifyAndroidDownload, + + [string]$DesktopUpdateFeedUrl = "https://qsfera.kusoft.xyz/desktop/updates/stable.xml", + [switch]$RequireDesktopUpdate +) + +$ErrorActionPreference = "Stop" + +function Fail($Message) { + Write-Error $Message + exit 1 +} + +function Assert-AbsoluteHttpsUrl($Name, $Url) { + $parsedUrl = $null + if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$parsedUrl)) { + Fail "$Name must be an absolute URL: $Url" + } + + if ($parsedUrl.Scheme -ne "https") { + Fail "$Name must use HTTPS: $Url" + } +} + +function Assert-Equal($Name, $Actual, $Expected) { + if ([string]$Actual -ne [string]$Expected) { + Fail "$Name expected '$Expected' but got '$Actual'." + } + + Write-Host "OK: $Name = $Actual" +} + +function Assert-True($Name, [bool]$Value) { + if (-not $Value) { + Fail "$Name expected True but got False." + } + + Write-Host "OK: $Name = True" +} + +function Invoke-CheckedWebRequest($Name, $Url) { + Assert-AbsoluteHttpsUrl $Name $Url + + try { + $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30 + } catch { + Fail "$Name request failed for ${Url}: $($_.Exception.Message)" + } + + if ($response.StatusCode -ne 200) { + Fail "$Name must return HTTP 200. Actual status: $($response.StatusCode)." + } + + Write-Host "OK: $Name returned HTTP 200" + return $response +} + +function Convert-JsonContent($Name, $Content) { + try { + return $Content | ConvertFrom-Json -ErrorAction Stop + } catch { + Fail "$Name returned invalid JSON: $($_.Exception.Message)" + } +} + +function Get-RequiredProperty($Object, $Name, $Context) { + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + Fail "$Context is missing '$Name'." + } + + return $property.Value +} + +function Test-Sha256String($Value) { + return [regex]::IsMatch($Value, "^[0-9a-f]{64}$") +} + +Write-Host "Checking QSfera production server status..." +$serverResponse = Invoke-CheckedWebRequest "Server status endpoint" $ServerStatusUrl +$serverStatus = Convert-JsonContent "Server status endpoint" $serverResponse.Content +Assert-True "Server installed" ([bool](Get-RequiredProperty $serverStatus "installed" "Server status")) +Assert-Equal "Server maintenance" (Get-RequiredProperty $serverStatus "maintenance" "Server status") "False" +Assert-Equal "Server needsDbUpgrade" (Get-RequiredProperty $serverStatus "needsDbUpgrade" "Server status") "False" +Assert-Equal "Server edition" (Get-RequiredProperty $serverStatus "edition" "Server status") $ExpectedServerEdition +Assert-Equal "Server productversion" (Get-RequiredProperty $serverStatus "productversion" "Server status") $ExpectedServerVersion + +Write-Host "Checking QSfera Android Argus manifest..." +$manifestResponse = Invoke-CheckedWebRequest "Android Argus manifest" $AndroidManifestUrl +$manifest = Convert-JsonContent "Android Argus manifest" $manifestResponse.Content +Assert-Equal "Android app slug" (Get-RequiredProperty $manifest "slug" "Android manifest") $ExpectedAndroidSlug +Assert-Equal "Android package name" (Get-RequiredProperty $manifest "androidPackageName" "Android manifest") $ExpectedAndroidPackageName + +$release = Get-RequiredProperty $manifest "release" "Android manifest" +if ($null -eq $release) { + Fail "Android manifest release must not be null." +} + +Assert-Equal "Android release version" (Get-RequiredProperty $release "version" "Android release") $ExpectedAndroidVersion +Assert-Equal "Android versionCode" (Get-RequiredProperty $release "androidVersionCode" "Android release") $ExpectedAndroidVersionCode +Assert-Equal "Android release channel" (Get-RequiredProperty $release "channel" "Android release") $ExpectedAndroidChannel +Assert-Equal "Android release platform" (Get-RequiredProperty $release "platform" "Android release") $ExpectedAndroidPlatform +Assert-Equal "Android package kind" (Get-RequiredProperty $release "packageKind" "Android release") $ExpectedAndroidPackageKind +$releaseSha256 = Get-RequiredProperty $release "sha256" "Android release" +Assert-True "Android SHA-256 shape" (Test-Sha256String $releaseSha256) +Assert-Equal "Android SHA-256" $releaseSha256 $ExpectedAndroidSha256 + +if ($VerifyAndroidDownload) { + Write-Host "Checking QSfera Android latest APK download..." + Assert-AbsoluteHttpsUrl "Android download URL" $AndroidDownloadUrl + $tempFile = [System.IO.Path]::GetTempFileName() + try { + try { + Invoke-WebRequest -Uri $AndroidDownloadUrl -UseBasicParsing -TimeoutSec 120 -OutFile $tempFile | Out-Null + } catch { + Fail "Android download request failed for ${AndroidDownloadUrl}: $($_.Exception.Message)" + } + + $downloadSize = (Get-Item -LiteralPath $tempFile).Length + $expectedSize = Get-RequiredProperty $release "packageSizeBytes" "Android release" + Assert-Equal "Android APK size" $downloadSize $expectedSize + + $downloadSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempFile).Hash.ToLowerInvariant() + Assert-Equal "Android APK download SHA-256" $downloadSha256 $ExpectedAndroidSha256 + } finally { + Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "Checking QSfera Desktop update feed..." +$desktopResponse = Invoke-CheckedWebRequest "Desktop update feed" $DesktopUpdateFeedUrl +try { + [xml]$desktopFeed = $desktopResponse.Content +} catch { + Fail "Desktop update feed returned invalid XML: $($_.Exception.Message)" +} + +if ($desktopFeed.DocumentElement.Name -ne "QSfera") { + Fail "Desktop update feed root element must be ." +} + +foreach ($elementName in @("version", "versionstring", "web", "downloadurl")) { + if ($null -eq $desktopFeed.QSfera.$elementName) { + Fail "Desktop update feed is missing <$elementName>." + } +} + +$desktopWeb = [string]$desktopFeed.QSfera.web +if (-not [string]::IsNullOrWhiteSpace($desktopWeb)) { + Assert-AbsoluteHttpsUrl "Desktop update feed " $desktopWeb +} + +$desktopVersion = [string]$desktopFeed.QSfera.version +$desktopVersionString = [string]$desktopFeed.QSfera.versionstring +$desktopDownloadUrl = [string]$desktopFeed.QSfera.downloadurl + +if ([string]::IsNullOrWhiteSpace($desktopVersion)) { + if ($RequireDesktopUpdate) { + Fail "Desktop update feed must advertise a concrete version when -RequireDesktopUpdate is set." + } + + if (-not [string]::IsNullOrWhiteSpace($desktopDownloadUrl)) { + Fail "Desktop no-update feed must leave empty when is empty." + } + + Write-Host "OK: Desktop update feed is a valid no-update feed" +} else { + $parsedVersion = $null + if (-not [System.Version]::TryParse($desktopVersion, [ref]$parsedVersion)) { + Fail "Desktop update feed must be parseable as a version number." + } + + if ([string]::IsNullOrWhiteSpace($desktopVersionString)) { + Fail "Desktop update feed must set when is present." + } + + Assert-AbsoluteHttpsUrl "Desktop update feed " $desktopDownloadUrl + $downloadUri = [System.Uri]$desktopDownloadUrl + if (-not $downloadUri.AbsolutePath.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) { + Fail "Desktop Windows updater expects an MSI download URL." + } + + Write-Host "OK: Desktop update feed advertises version $desktopVersionString" +} + +Write-Host "QSfera production endpoint verification passed."