373 lines
14 KiB
PowerShell
373 lines
14 KiB
PowerShell
param(
|
|
[string]$MetadataPath,
|
|
[string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
|
|
[string]$RemoteHost = '192.168.0.185',
|
|
[string]$RemoteUser = 'sevenhill',
|
|
[int]$RemotePort = 22,
|
|
[string]$RemoteTempDirectory = '/tmp',
|
|
[string]$ArgusData = '/srv/argus-data',
|
|
[string]$GeneratedScriptPath,
|
|
[switch]$PrepareOnly,
|
|
[switch]$RestartArgus
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Fail([string]$Message) {
|
|
Write-Error $Message
|
|
exit 1
|
|
}
|
|
|
|
function Get-LatestMetadataPath {
|
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
$publicationRoot = Join-Path $repoRoot 'build\argus-publication'
|
|
if (-not (Test-Path -LiteralPath $publicationRoot)) {
|
|
Fail "Argus publication directory was not found: $publicationRoot"
|
|
}
|
|
|
|
$metadata = Get-ChildItem -LiteralPath $publicationRoot -Recurse -File -Filter 'argus-publication.json' |
|
|
Sort-Object LastWriteTimeUtc -Descending |
|
|
Select-Object -First 1
|
|
if (-not $metadata) {
|
|
Fail "No argus-publication.json file was found under $publicationRoot"
|
|
}
|
|
|
|
return $metadata.FullName
|
|
}
|
|
|
|
function Get-RequiredString($Object, [string]$Name) {
|
|
$property = $Object.PSObject.Properties[$Name]
|
|
if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
|
|
Fail "Publication metadata is missing '$Name'."
|
|
}
|
|
|
|
return [string]$property.Value
|
|
}
|
|
|
|
function Quote-BashSingle([string]$Value) {
|
|
if ($null -eq $Value) {
|
|
$Value = ''
|
|
}
|
|
|
|
return "'" + ($Value -replace "'", "'\''") + "'"
|
|
}
|
|
|
|
function Join-RemotePath([string]$Directory, [string]$Leaf) {
|
|
return $Directory.TrimEnd('/') + '/' + $Leaf
|
|
}
|
|
|
|
function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) {
|
|
& $Program @Arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Fail "$Program failed with exit code $LASTEXITCODE."
|
|
}
|
|
}
|
|
|
|
function Assert-Slug([string]$Slug) {
|
|
if ($Slug -notmatch '^[a-z0-9][a-z0-9-]{0,99}$') {
|
|
Fail "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $Slug"
|
|
}
|
|
}
|
|
|
|
function Assert-ManifestValue($Manifest, [string]$Name, $Actual, $Expected) {
|
|
if ([string]$Actual -ne [string]$Expected) {
|
|
Fail "Published manifest $Name expected '$Expected' but got '$Actual'."
|
|
}
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($MetadataPath)) {
|
|
$MetadataPath = Get-LatestMetadataPath
|
|
}
|
|
|
|
$resolvedMetadataPath = (Resolve-Path -LiteralPath $MetadataPath).Path
|
|
$metadata = Get-Content -LiteralPath $resolvedMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
$packagePath = (Resolve-Path -LiteralPath (Get-RequiredString $metadata 'packagePath')).Path
|
|
$slug = Get-RequiredString $metadata 'slug'
|
|
$name = Get-RequiredString $metadata 'name'
|
|
$summary = Get-RequiredString $metadata 'summary'
|
|
$description = Get-RequiredString $metadata 'description'
|
|
$version = Get-RequiredString $metadata 'version'
|
|
$channel = (Get-RequiredString $metadata 'channel').ToLowerInvariant()
|
|
$platform = (Get-RequiredString $metadata 'platform').ToLowerInvariant()
|
|
$packageKind = (Get-RequiredString $metadata 'packageKind').ToLowerInvariant()
|
|
$notes = if ($metadata.PSObject.Properties['notes']) { [string]$metadata.notes } else { '' }
|
|
$repositoryUrl = if ($metadata.PSObject.Properties['repositoryUrl']) { [string]$metadata.repositoryUrl } else { '' }
|
|
$homepageUrl = if ($metadata.PSObject.Properties['homepageUrl']) { [string]$metadata.homepageUrl } else { '' }
|
|
$isListed = if ($metadata.PSObject.Properties['isListed'] -and [bool]$metadata.isListed) { '1' } else { '0' }
|
|
|
|
Assert-Slug $slug
|
|
if ($platform -ne 'android') {
|
|
Fail "QSfera Android publication requires platform=android. Actual platform: $platform"
|
|
}
|
|
if ($packageKind -ne 'apk') {
|
|
Fail "QSfera Android publication requires packageKind=apk. Actual packageKind: $packageKind"
|
|
}
|
|
if (-not $packagePath.EndsWith('.apk', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
Fail "QSfera Android publication requires an APK package: $packagePath"
|
|
}
|
|
|
|
$scp = Get-Command scp -ErrorAction SilentlyContinue
|
|
$ssh = Get-Command ssh -ErrorAction SilentlyContinue
|
|
if (-not $scp -or -not $ssh) {
|
|
Fail 'Both scp and ssh must be available for SSH-only Argus publication.'
|
|
}
|
|
|
|
$localSize = (Get-Item -LiteralPath $packagePath).Length
|
|
$localSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagePath).Hash.ToLowerInvariant()
|
|
$packageFileName = Split-Path -Leaf $packagePath
|
|
$remotePackagePath = Join-RemotePath $RemoteTempDirectory $packageFileName
|
|
$token = [guid]::NewGuid().ToString('N')
|
|
$remoteScriptPath = Join-RemotePath $RemoteTempDirectory "$slug-publish-$token.sh"
|
|
$localScriptPath = if ([string]::IsNullOrWhiteSpace($GeneratedScriptPath)) {
|
|
Join-Path ([System.IO.Path]::GetTempPath()) "$slug-publish-$token.sh"
|
|
} else {
|
|
$GeneratedScriptPath
|
|
}
|
|
$remote = "$RemoteUser@$RemoteHost"
|
|
$sshPort = [string]$RemotePort
|
|
|
|
$restartCommand = if ($RestartArgus) {
|
|
'docker restart argus >/dev/null'
|
|
} else {
|
|
':'
|
|
}
|
|
|
|
$scriptTemplate = @'
|
|
set -euo pipefail
|
|
|
|
cleanup() {
|
|
rm -f "$SOURCE_FILE" "$0"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
export ARGUS_DATA=__ARGUS_DATA__
|
|
export SOURCE_FILE=__SOURCE_FILE__
|
|
|
|
export ARGUS_SLUG=__ARGUS_SLUG__
|
|
export ARGUS_NAME=__ARGUS_NAME__
|
|
export ARGUS_SUMMARY=__ARGUS_SUMMARY__
|
|
export ARGUS_DESCRIPTION=__ARGUS_DESCRIPTION__
|
|
export ARGUS_REPOSITORY_URL=__ARGUS_REPOSITORY_URL__
|
|
export ARGUS_HOMEPAGE_URL=__ARGUS_HOMEPAGE_URL__
|
|
export ARGUS_IS_LISTED=__ARGUS_IS_LISTED__
|
|
|
|
export ARGUS_VERSION=__ARGUS_VERSION__
|
|
export ARGUS_CHANNEL=__ARGUS_CHANNEL__
|
|
export ARGUS_PLATFORM=__ARGUS_PLATFORM__
|
|
export ARGUS_PACKAGE_KIND=__ARGUS_PACKAGE_KIND__
|
|
export ARGUS_NOTES=__ARGUS_NOTES__
|
|
|
|
python3 - "$SOURCE_FILE" <<'PY'
|
|
import hashlib
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
source = Path(sys.argv[1]).resolve()
|
|
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
|
|
db_path = data_root / "argus.db"
|
|
packages_root = data_root / "Packages"
|
|
|
|
slug = os.environ["ARGUS_SLUG"].strip()
|
|
name = os.environ["ARGUS_NAME"].strip()
|
|
summary = os.environ["ARGUS_SUMMARY"].strip()
|
|
description = os.environ["ARGUS_DESCRIPTION"].strip()
|
|
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
|
|
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
|
|
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
|
|
|
|
version = os.environ["ARGUS_VERSION"].strip()
|
|
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
|
|
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
|
|
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
|
|
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
|
|
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
|
|
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
|
|
if not source.is_file():
|
|
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
|
|
if not db_path.is_file():
|
|
raise SystemExit(f"Argus database does not exist: {db_path}")
|
|
for key, value in {
|
|
"ARGUS_NAME": name,
|
|
"ARGUS_SUMMARY": summary,
|
|
"ARGUS_DESCRIPTION": description,
|
|
"ARGUS_VERSION": version,
|
|
}.items():
|
|
if not value:
|
|
raise SystemExit(f"{key} is required.")
|
|
|
|
release_id = str(uuid.uuid4()).upper()
|
|
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
|
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
|
|
extension = source.suffix or ".bin"
|
|
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
|
|
stored_relative_path = f"{slug}/{stored_name}"
|
|
target_dir = packages_root / slug
|
|
target_path = target_dir / stored_name
|
|
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, target_path)
|
|
|
|
size_bytes = target_path.stat().st_size
|
|
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
|
|
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
|
|
if source.suffix.lower() == ".apk":
|
|
content_type = "application/vnd.android.package-archive"
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("BEGIN")
|
|
|
|
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
|
|
if row is None:
|
|
app_id = str(uuid.uuid4()).upper()
|
|
conn.execute(
|
|
'''
|
|
INSERT INTO "Apps"
|
|
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''',
|
|
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
|
|
)
|
|
else:
|
|
app_id = row[0]
|
|
conn.execute(
|
|
'''
|
|
UPDATE "Apps"
|
|
SET "Name" = ?,
|
|
"Summary" = ?,
|
|
"Description" = ?,
|
|
"RepositoryUrl" = ?,
|
|
"HomepageUrl" = ?,
|
|
"IsListed" = ?,
|
|
"UpdatedAt" = ?
|
|
WHERE "Id" = ?
|
|
''',
|
|
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
|
|
)
|
|
|
|
duplicate = conn.execute(
|
|
'''
|
|
SELECT "Id"
|
|
FROM "Releases"
|
|
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
|
|
''',
|
|
(app_id, version, channel, platform),
|
|
).fetchone()
|
|
if duplicate is not None:
|
|
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
|
|
|
|
conn.execute(
|
|
'''
|
|
INSERT INTO "Releases"
|
|
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
|
|
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
|
|
"Sha256", "Notes", "PublishedAt")
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''',
|
|
(
|
|
release_id,
|
|
app_id,
|
|
version,
|
|
channel,
|
|
platform,
|
|
package_kind,
|
|
source.name,
|
|
stored_relative_path,
|
|
content_type,
|
|
size_bytes,
|
|
sha256,
|
|
notes,
|
|
now,
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
try:
|
|
target_path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
|
|
print(f"StoredRelativePath={stored_relative_path}")
|
|
print(f"Size={size_bytes}")
|
|
print(f"Sha256={sha256}")
|
|
print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
|
|
PY
|
|
|
|
curl -fsS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
|
__RESTART_COMMAND__
|
|
'@
|
|
|
|
$publicationScript = $scriptTemplate.
|
|
Replace('__ARGUS_DATA__', (Quote-BashSingle $ArgusData)).
|
|
Replace('__SOURCE_FILE__', (Quote-BashSingle $remotePackagePath)).
|
|
Replace('__ARGUS_SLUG__', (Quote-BashSingle $slug)).
|
|
Replace('__ARGUS_NAME__', (Quote-BashSingle $name)).
|
|
Replace('__ARGUS_SUMMARY__', (Quote-BashSingle $summary)).
|
|
Replace('__ARGUS_DESCRIPTION__', (Quote-BashSingle $description)).
|
|
Replace('__ARGUS_REPOSITORY_URL__', (Quote-BashSingle $repositoryUrl)).
|
|
Replace('__ARGUS_HOMEPAGE_URL__', (Quote-BashSingle $homepageUrl)).
|
|
Replace('__ARGUS_IS_LISTED__', (Quote-BashSingle $isListed)).
|
|
Replace('__ARGUS_VERSION__', (Quote-BashSingle $version)).
|
|
Replace('__ARGUS_CHANNEL__', (Quote-BashSingle $channel)).
|
|
Replace('__ARGUS_PLATFORM__', (Quote-BashSingle $platform)).
|
|
Replace('__ARGUS_PACKAGE_KIND__', (Quote-BashSingle $packageKind)).
|
|
Replace('__ARGUS_NOTES__', (Quote-BashSingle $notes)).
|
|
Replace('__RESTART_COMMAND__', $restartCommand)
|
|
|
|
[System.IO.File]::WriteAllText($localScriptPath, $publicationScript, [System.Text.UTF8Encoding]::new($false))
|
|
|
|
if ($PrepareOnly) {
|
|
Write-Output 'QSfera Android Argus SSH publication script prepared.'
|
|
Write-Output "Script: $localScriptPath"
|
|
Write-Output "APK: $packagePath"
|
|
Write-Output "Remote APK: $remotePackagePath"
|
|
Write-Output "Size: $localSize"
|
|
Write-Output "SHA-256: $localSha256"
|
|
exit 0
|
|
}
|
|
|
|
try {
|
|
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $packagePath, "${remote}:$remotePackagePath")
|
|
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $localScriptPath, "${remote}:$remoteScriptPath")
|
|
Invoke-CheckedNative $ssh.Source @('-p', $sshPort, $remote, "bash $(Quote-BashSingle $remoteScriptPath)")
|
|
} finally {
|
|
Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
$manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel"
|
|
$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30
|
|
$release = $manifest.release
|
|
if ($null -eq $release) {
|
|
Fail "Published manifest did not return a release: $manifestUrl"
|
|
}
|
|
|
|
Assert-ManifestValue $manifest 'slug' $manifest.slug $slug
|
|
Assert-ManifestValue $release 'version' $release.version $version
|
|
Assert-ManifestValue $release 'channel' $release.channel $channel
|
|
Assert-ManifestValue $release 'platform' $release.platform $platform
|
|
Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind
|
|
Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName
|
|
Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize
|
|
Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256
|
|
|
|
Write-Output 'QSfera Android Argus SSH publication completed.'
|
|
Write-Output "Manifest: $manifestUrl"
|
|
Write-Output "Size: $localSize"
|
|
Write-Output "SHA-256: $localSha256"
|