Files
QMAX/ARGUS_PUBLICATION.md
2026-07-03 22:39:48 +03:00

12 KiB
Raw Permalink Blame History

Argus Publication And Update Integration

Эта инструкция является единой точкой входа для любого приложения, которое нужно публиковать через Argus или подключить к системе обновлений Argus.

Argus в текущей архитектуре является read-only web catalog. У него нет HTTP admin API, upload endpoint, токена публикации или браузерной формы публикации. Публикация выполняется только на сервере через SSH: файл релиза кладётся в Data/Packages, а метаданные записываются в SQLite Data/argus.db.

Production Argus

  • Public base URL: https://argus.kusoft.xyz
  • SSH host: 192.168.0.185
  • SSH user: sevenhill
  • Server project path: /home/sevenhill/argus
  • Data path: /srv/argus-data
  • SQLite database: /srv/argus-data/argus.db
  • Package root: /srv/argus-data/Packages

Do not put SSH passwords, private keys, or local .env files into application repositories.

App Values

Every app must choose stable values:

  • slug: public Argus id. Use lowercase letters, digits, and hyphens only, for example keeper-android or my-desktop-app.
  • name: display name.
  • summary: short catalog text.
  • description: full catalog text.
  • version: release version. Semantic versions are recommended, for example 1.2.3.
  • channel: release channel, normally stable.
  • platform: target platform, for example windows-x64, linux-arm64, android, web.
  • packageKind: package type, for example zip, msi, deb, apk, binary.

Argus selects the latest release by semantic version first, then by publish time. The running service currently prunes to one retained release per app on service startup.

Publish A Release Over SSH

From the development machine, copy the built artifact to the Pi:

scp ./path/to/my-app-1.2.3.zip sevenhill@192.168.0.185:/tmp/my-app-1.2.3.zip

Then connect to the Pi:

ssh sevenhill@192.168.0.185

Run this publication block on the Pi after editing the variables at the top:

set -euo pipefail

export ARGUS_DATA="/srv/argus-data"
export SOURCE_FILE="/tmp/my-app-1.2.3.zip"

export ARGUS_SLUG="my-app"
export ARGUS_NAME="My App"
export ARGUS_SUMMARY="Short summary for the Argus catalog."
export ARGUS_DESCRIPTION="Full description shown in the Argus catalog."
export ARGUS_REPOSITORY_URL=""
export ARGUS_HOMEPAGE_URL=""
export ARGUS_IS_LISTED="1"

export ARGUS_VERSION="1.2.3"
export ARGUS_CHANNEL="stable"
export ARGUS_PLATFORM="windows-x64"
export ARGUS_PACKAGE_KIND="zip"
export ARGUS_NOTES="What changed in this release."

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=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
PY

Verify publication from the Pi:

curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"

Verify publication from outside the Pi:

curl -sS "https://argus.kusoft.xyz/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"

If you want Argus to apply its one-release retention rule immediately after manual publication, restart the container:

docker restart argus

The restart is optional for making a newer semantic version visible. It is only needed to force startup pruning immediately.

Public API Contract For Update Clients

Update clients must use the public read-only API. They must not call /api/admin/*; those routes are not part of this system.

Check for an update:

GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}

A 404 response means one of these states:

  • the app slug is not listed;
  • there is no release for the requested platform;
  • there is no release for the requested channel.

A successful manifest response contains:

{
  "slug": "my-app",
  "name": "My App",
  "summary": "Short summary.",
  "description": "Full description.",
  "repositoryUrl": null,
  "homepageUrl": null,
  "release": {
    "id": "release-guid",
    "version": "1.2.3",
    "channel": "stable",
    "platform": "windows-x64",
    "packageKind": "zip",
    "downloadPath": "/api/apps/my-app/releases/release-guid/download",
    "originalFileName": "my-app-1.2.3.zip",
    "contentType": "application/zip",
    "packageSizeBytes": 123456,
    "sha256": "hex-encoded-sha256",
    "publishedAt": "2026-06-13T18:00:00+00:00",
    "notes": "What changed."
  }
}

Download URL:

https://argus.kusoft.xyz{release.downloadPath}

There is also a latest-download endpoint:

GET https://argus.kusoft.xyz/api/apps/{slug}/download/latest?platform={platform}&channel={channel}

The manifest path is preferred for update clients because it provides sha256, packageSizeBytes, version, and release metadata before downloading.

Required Update Client Behavior

Every app integrating with Argus must implement this flow:

  1. Store its current installed version locally.
  2. Request the manifest for its slug, platform, and channel.
  3. Treat HTTP 404 as "no update available for this app/platform/channel".
  4. Compare local version to release.version.
  5. If update is needed, download baseUrl + release.downloadPath to a temporary file.
  6. Calculate SHA-256 of the downloaded file.
  7. Compare the calculated SHA-256 to release.sha256.
  8. Reject and delete the temporary file if SHA-256 does not match.
  9. Apply the update with the app platform's own installer or replacement mechanism.
  10. Store the new installed version only after a successful install or successful staged update.

Argus does not install files on client devices. It only publishes metadata and serves package bytes. Installation logic belongs to each app.

Versioning Rule

Use semantic versions when possible:

1.2.3
1.2.4
1.3.0
2.0.0

Argus latest selection is semantic-version aware. If an app uses a non-semantic version string, Argus falls back to case-insensitive string comparison for ordering. For predictable updates, keep release versions semantic.

When asking another app project to integrate with Argus, use this instruction:

Read ARGUS_PUBLICATION.md from the Argus repository.
Implement the "Required Update Client Behavior" section for this app.
Use:
- baseUrl: https://argus.kusoft.xyz
- slug: <app-slug>
- platform: <platform>
- channel: stable
Do not implement publishing over HTTP. Publishing is SSH-only and happens on the Argus server.
Always verify release.sha256 before installing or applying an update.