344 lines
12 KiB
Markdown
344 lines
12 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
ssh sevenhill@192.168.0.185
|
||
```
|
||
|
||
Run this publication block on the Pi after editing the variables at the top:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```text
|
||
https://argus.kusoft.xyz{release.downloadPath}
|
||
```
|
||
|
||
There is also a latest-download endpoint:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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.
|
||
|
||
## Recommended Prompt For App Repositories
|
||
|
||
When asking another app project to integrate with Argus, use this instruction:
|
||
|
||
```text
|
||
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.
|
||
```
|