Initial Argus web catalog
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
# 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.
|
||||
```
|
||||
Reference in New Issue
Block a user