Align Keeper Argus publication flow

This commit is contained in:
Курнат Андрей
2026-06-20 19:53:04 +03:00
parent cdd442ebf7
commit 39d65fcede
5 changed files with 305 additions and 105 deletions
+210 -78
View File
@@ -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"