This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
param(
|
||||
[string]$ManifestPath,
|
||||
[string]$SshHost = "192.168.0.185",
|
||||
[string]$SshUser = "sevenhill",
|
||||
[string]$ArgusDataPath = "/srv/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") {
|
||||
return if ([bool]$Manifest.publicCatalog) { "1" } else { "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)",
|
||||
'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"',
|
||||
""
|
||||
)
|
||||
|
||||
if ($RestartArgus) {
|
||||
$remoteScriptLines += "docker restart argus"
|
||||
}
|
||||
|
||||
$remoteScript = $remoteScriptLines -join [Environment]::NewLine
|
||||
$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)"
|
||||
Reference in New Issue
Block a user