Files
Aletheia/scripts/publish-argus-release.ps1
T

354 lines
12 KiB
PowerShell

param(
[string]$ManifestPath,
[string]$SshHost = "192.168.0.25",
[string]$SshUser = "sevenhill",
[string]$ArgusDataPath = "/opt/argus/data",
[switch]$RestartArgus
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-ProjectRoot {
return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
function Resolve-LatestManifestPath {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectRoot
)
$argusRoot = Join-Path (Join-Path $ProjectRoot "artifacts") "argus"
if (-not (Test-Path -LiteralPath $argusRoot)) {
throw "Argus artifacts directory not found: $argusRoot"
}
$latest = Get-ChildItem -LiteralPath $argusRoot -Recurse -Filter "argus-release.json" -File |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $latest) {
throw "No argus-release.json files found under $argusRoot"
}
return $latest.FullName
}
function Assert-RequiredString {
param(
[Parameter(Mandatory = $true)]
[object]$Manifest,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
if (-not ($Manifest.PSObject.Properties.Name -contains $PropertyName)) {
throw "Argus manifest is missing required property: $PropertyName"
}
$value = [string]$Manifest.$PropertyName
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Argus manifest property is blank: $PropertyName"
}
return $value
}
function ConvertTo-BashSingleQuoted {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return "''"
}
return "'" + ($Value -replace "'", "'\''") + "'"
}
function Get-PublicCatalogFlag {
param([object]$Manifest)
if ($Manifest.PSObject.Properties.Name -contains "publicCatalog") {
if ([bool]$Manifest.publicCatalog) {
return "1"
}
return "0"
}
return "1"
}
function Invoke-CheckedNativeCommand {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string[]]$Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath exited with code $LASTEXITCODE."
}
}
$projectRoot = Resolve-ProjectRoot
$resolvedManifestPath = if ($ManifestPath) {
(Resolve-Path -LiteralPath $ManifestPath).Path
}
else {
Resolve-LatestManifestPath -ProjectRoot $projectRoot
}
$manifestDirectory = Split-Path -Parent $resolvedManifestPath
$manifest = Get-Content -LiteralPath $resolvedManifestPath -Raw | ConvertFrom-Json
$slug = Assert-RequiredString -Manifest $manifest -PropertyName "slug"
$name = Assert-RequiredString -Manifest $manifest -PropertyName "name"
$summary = Assert-RequiredString -Manifest $manifest -PropertyName "summary"
$description = Assert-RequiredString -Manifest $manifest -PropertyName "description"
$version = Assert-RequiredString -Manifest $manifest -PropertyName "version"
$channel = Assert-RequiredString -Manifest $manifest -PropertyName "channel"
$platform = Assert-RequiredString -Manifest $manifest -PropertyName "platform"
$packageKind = Assert-RequiredString -Manifest $manifest -PropertyName "packageKind"
$packageFile = Assert-RequiredString -Manifest $manifest -PropertyName "packageFile"
$packageSha256 = Assert-RequiredString -Manifest $manifest -PropertyName "packageSha256"
$releaseNotes = if ($manifest.PSObject.Properties.Name -contains "releaseNotes") { [string]$manifest.releaseNotes } else { "" }
$repositoryUrl = if ($manifest.PSObject.Properties.Name -contains "repositoryUrl") { [string]$manifest.repositoryUrl } else { "" }
$homepageUrl = if ($manifest.PSObject.Properties.Name -contains "homepageUrl") { [string]$manifest.homepageUrl } else { "" }
$isListed = Get-PublicCatalogFlag -Manifest $manifest
if ($slug -notmatch "^[a-z0-9][a-z0-9-]{0,99}$") {
throw "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $slug"
}
if ($packageSha256 -notmatch "^[0-9a-f]{64}$") {
throw "packageSha256 must be a lowercase SHA-256 hex digest: $packageSha256"
}
$packagePath = Join-Path $manifestDirectory $packageFile
if (-not (Test-Path -LiteralPath $packagePath)) {
throw "Package from Argus manifest not found: $packagePath"
}
if ([IO.Path]::GetFileName($packageFile) -ne $packageFile) {
throw "packageFile must be a file name without directories: $packageFile"
}
$actualSha256 = ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash).ToLowerInvariant()
if ($actualSha256 -ne $packageSha256) {
throw "Package SHA-256 mismatch. Manifest: $packageSha256 Actual: $actualSha256"
}
$remoteTarget = "$SshUser@$SshHost"
$remoteSourceFile = "/tmp/$packageFile"
Invoke-CheckedNativeCommand -FilePath "scp" -Arguments @(
"-O",
$packagePath,
"$remoteTarget`:$remoteSourceFile"
)
$pythonBlock = @'
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}")
'@
$remoteScriptLines = @(
"set -euo pipefail",
"export ARGUS_DATA=$(ConvertTo-BashSingleQuoted $ArgusDataPath)",
"export SOURCE_FILE=$(ConvertTo-BashSingleQuoted $remoteSourceFile)",
"export ARGUS_SLUG=$(ConvertTo-BashSingleQuoted $slug)",
"export ARGUS_NAME=$(ConvertTo-BashSingleQuoted $name)",
"export ARGUS_SUMMARY=$(ConvertTo-BashSingleQuoted $summary)",
"export ARGUS_DESCRIPTION=$(ConvertTo-BashSingleQuoted $description)",
"export ARGUS_REPOSITORY_URL=$(ConvertTo-BashSingleQuoted $repositoryUrl)",
"export ARGUS_HOMEPAGE_URL=$(ConvertTo-BashSingleQuoted $homepageUrl)",
"export ARGUS_IS_LISTED=$(ConvertTo-BashSingleQuoted $isListed)",
"export ARGUS_VERSION=$(ConvertTo-BashSingleQuoted $version)",
"export ARGUS_CHANNEL=$(ConvertTo-BashSingleQuoted $channel)",
"export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)",
"export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)",
"export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)",
'trap ''rm -f -- "$SOURCE_FILE"'' EXIT',
'python3 - "$SOURCE_FILE" <<''PY''',
$pythonBlock,
"PY",
'curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"',
"# end"
)
if ($RestartArgus) {
$remoteScriptLines += "docker restart argus"
}
$remoteScript = $remoteScriptLines -join "`n"
$tempRemoteScript = New-TemporaryFile
try {
Set-Content -LiteralPath $tempRemoteScript -Value $remoteScript -Encoding utf8 -NoNewline
Get-Content -LiteralPath $tempRemoteScript -Raw | & ssh $remoteTarget "bash" "-s"
if ($LASTEXITCODE -ne 0) {
throw "ssh exited with code $LASTEXITCODE."
}
}
finally {
Remove-Item -LiteralPath $tempRemoteScript -Force -ErrorAction SilentlyContinue
}
$publicManifestUrl = "https://argus.kusoft.xyz/api/apps/$slug/manifest?platform=$platform&channel=$channel"
$publicManifest = Invoke-RestMethod -Uri $publicManifestUrl -TimeoutSec 30
if ([string]$publicManifest.release.version -ne $version) {
throw "Public Argus manifest version mismatch. Expected $version, got $($publicManifest.release.version)."
}
if ([string]$publicManifest.release.sha256 -ne $packageSha256) {
throw "Public Argus manifest SHA-256 mismatch. Expected $packageSha256, got $($publicManifest.release.sha256)."
}
Write-Host "Argus publication verified:"
Write-Host "Manifest: $publicManifestUrl"
Write-Host "Version: $($publicManifest.release.version)"
Write-Host "SHA-256: $($publicManifest.release.sha256)"