353 lines
12 KiB
PowerShell
353 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 @(
|
|
$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
|
|
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)"
|