Align Keeper Argus publication flow
This commit is contained in:
@@ -1,99 +1,231 @@
|
||||
param(
|
||||
[string]$SshHost = $(if ($env:ARGUS_SSH_HOST) { $env:ARGUS_SSH_HOST } else { "192.168.0.185" }),
|
||||
[string]$SshUser = $(if ($env:ARGUS_SSH_USER) { $env:ARGUS_SSH_USER } else { "sevenhill" }),
|
||||
[string]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }),
|
||||
[string]$Username = $env:ARGUS_USERNAME,
|
||||
[string]$Password = $env:ARGUS_PASSWORD,
|
||||
[string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }),
|
||||
[string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk",
|
||||
[string]$Slug = "keeper-android",
|
||||
[string]$Name = "QKeep",
|
||||
[string]$Summary = "Cloud notes app for QSfera with local storage and sync.",
|
||||
[string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs Argus updates from inside the app.",
|
||||
[string]$AndroidPackageName = "eu.qsfera.keeper",
|
||||
[string]$Version = "0.1.21",
|
||||
[int]$AndroidVersionCode = 22,
|
||||
[string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs SHA-256 verified Argus updates.",
|
||||
[string]$RepositoryUrl = "",
|
||||
[string]$HomepageUrl = "",
|
||||
[string]$Version = "0.1.22",
|
||||
[string]$Channel = "stable",
|
||||
[string]$Platform = "android",
|
||||
[string]$PackageKind = "apk",
|
||||
[string]$Notes = "Adds Keep-like list behavior settings for new and checked checklist items."
|
||||
[string]$Notes = "Aligns the Android updater and publication flow with ARGUS_PUBLICATION.md."
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) {
|
||||
throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing."
|
||||
function ConvertTo-Base64Utf8([string]$Value) {
|
||||
return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value))
|
||||
}
|
||||
|
||||
function Assert-LastExitCode([string]$Action) {
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Action failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path
|
||||
$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/"))
|
||||
$slugEscaped = [Uri]::EscapeDataString($Slug)
|
||||
$sshTarget = "$SshUser@$SshHost"
|
||||
$remoteFile = "/tmp/$Slug-$Version.apk"
|
||||
$manifestUrl = "$($ArgusBaseUrl.TrimEnd("/"))/api/apps/$Slug/manifest?platform=$Platform&channel=$Channel"
|
||||
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
& scp "$resolvedApk" "${sshTarget}:$remoteFile"
|
||||
Assert-LastExitCode "Copy APK to Argus host"
|
||||
|
||||
$handler = [System.Net.Http.HttpClientHandler]::new()
|
||||
$handler.CookieContainer = [System.Net.CookieContainer]::new()
|
||||
$client = [System.Net.Http.HttpClient]::new($handler)
|
||||
$client.BaseAddress = $baseUri
|
||||
$nameB64 = ConvertTo-Base64Utf8 $Name
|
||||
$summaryB64 = ConvertTo-Base64Utf8 $Summary
|
||||
$descriptionB64 = ConvertTo-Base64Utf8 $Description
|
||||
$repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl
|
||||
$homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl
|
||||
$notesB64 = ConvertTo-Base64Utf8 $Notes
|
||||
|
||||
function New-JsonContent([object]$Value) {
|
||||
$json = $Value | ConvertTo-Json -Depth 8
|
||||
return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json")
|
||||
$remoteScript = @"
|
||||
set -euo pipefail
|
||||
|
||||
export ARGUS_DATA="$ArgusData"
|
||||
export SOURCE_FILE="$remoteFile"
|
||||
|
||||
export ARGUS_SLUG="$Slug"
|
||||
export ARGUS_NAME="`$(printf '%s' '$nameB64' | base64 -d)"
|
||||
export ARGUS_SUMMARY="`$(printf '%s' '$summaryB64' | base64 -d)"
|
||||
export ARGUS_DESCRIPTION="`$(printf '%s' '$descriptionB64' | base64 -d)"
|
||||
export ARGUS_REPOSITORY_URL="`$(printf '%s' '$repositoryUrlB64' | base64 -d)"
|
||||
export ARGUS_HOMEPAGE_URL="`$(printf '%s' '$homepageUrlB64' | base64 -d)"
|
||||
export ARGUS_IS_LISTED="1"
|
||||
|
||||
export ARGUS_VERSION="$Version"
|
||||
export ARGUS_CHANNEL="$Channel"
|
||||
export ARGUS_PLATFORM="$Platform"
|
||||
export ARGUS_PACKAGE_KIND="$PackageKind"
|
||||
export ARGUS_NOTES="`$(printf '%s' '$notesB64' | base64 -d)"
|
||||
|
||||
trap 'rm -f "$SOURCE_FILE"' EXIT
|
||||
|
||||
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=$ArgusBaseUrl/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
|
||||
PY
|
||||
|
||||
curl -sS "http://127.0.0.1:5105/api/apps/`$ARGUS_SLUG/manifest?platform=`$ARGUS_PLATFORM&channel=`$ARGUS_CHANNEL" >/dev/null
|
||||
"@
|
||||
|
||||
$remoteScript | & ssh "$sshTarget" "bash -s"
|
||||
Assert-LastExitCode "Publish release on Argus host"
|
||||
|
||||
$manifest = Invoke-RestMethod -Uri $manifestUrl -Headers @{ Accept = "application/json" }
|
||||
if ($manifest.release.version -ne $Version) {
|
||||
throw "Argus manifest verification failed: expected version $Version, got $($manifest.release.version)."
|
||||
}
|
||||
|
||||
function Read-ResponseBody($Response) {
|
||||
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
|
||||
function Assert-Success($Response, [string]$Action) {
|
||||
if (-not $Response.IsSuccessStatusCode) {
|
||||
$body = Read-ResponseBody $Response
|
||||
throw "$Action failed: HTTP $([int]$Response.StatusCode) $body"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$loginPayload = @{
|
||||
username = $Username
|
||||
password = $Password
|
||||
}
|
||||
$loginResponse = $client.PostAsync("/api/admin/session/login", (New-JsonContent $loginPayload)).GetAwaiter().GetResult()
|
||||
Assert-Success $loginResponse "Argus login"
|
||||
|
||||
$metadataPayload = @{
|
||||
name = $Name
|
||||
summary = $Summary
|
||||
description = $Description
|
||||
androidPackageName = $AndroidPackageName
|
||||
repositoryUrl = $null
|
||||
homepageUrl = $null
|
||||
isListed = $true
|
||||
}
|
||||
$metadataResponse = $client.PutAsync("/api/admin/apps/$slugEscaped", (New-JsonContent $metadataPayload)).GetAwaiter().GetResult()
|
||||
Assert-Success $metadataResponse "Argus app metadata update"
|
||||
|
||||
$form = [System.Net.Http.MultipartFormDataContent]::new()
|
||||
$form.Add([System.Net.Http.StringContent]::new($Version), "Version")
|
||||
$form.Add([System.Net.Http.StringContent]::new($Channel), "Channel")
|
||||
$form.Add([System.Net.Http.StringContent]::new($Platform), "Platform")
|
||||
$form.Add([System.Net.Http.StringContent]::new($PackageKind), "PackageKind")
|
||||
$form.Add([System.Net.Http.StringContent]::new($AndroidVersionCode.ToString()), "AndroidVersionCode")
|
||||
$form.Add([System.Net.Http.StringContent]::new($Notes), "Notes")
|
||||
|
||||
$fileStream = [IO.File]::OpenRead($resolvedApk)
|
||||
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
|
||||
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("application/vnd.android.package-archive")
|
||||
$form.Add($fileContent, "Package", [IO.Path]::GetFileName($resolvedApk))
|
||||
|
||||
$releaseResponse = $client.PostAsync("/api/admin/apps/$slugEscaped/releases", $form).GetAwaiter().GetResult()
|
||||
Assert-Success $releaseResponse "Argus release upload"
|
||||
|
||||
$releaseBody = Read-ResponseBody $releaseResponse
|
||||
Write-Output $releaseBody
|
||||
} finally {
|
||||
if ($fileStream) {
|
||||
$fileStream.Dispose()
|
||||
}
|
||||
if ($form) {
|
||||
$form.Dispose()
|
||||
}
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
}
|
||||
Write-Output "Published $Slug $Version to $manifestUrl"
|
||||
|
||||
Reference in New Issue
Block a user