Files
IKAR/tool/publish_argus_release.py
T

195 lines
9.7 KiB
Python

"""Publish the verified first IKAR APK without restarting Argus or other services."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import uuid
from datetime import datetime, timezone
DATA = Path('/mnt/data/argus')
DB = DATA / 'argus.db'
SOURCE = Path('/mnt/data/ikar_server/shared/data/AppUpdates/Android/ikar-android-1.apk')
REPORT = Path('/mnt/data/ikar_server/argus-publication.json')
EXPECTED_SHA256 = '37388bf8a8b85413c9c31030def07c4377c67259d46594f900699d9f56f38212'
EXPECTED_SIZE = 89832793
SLUG = 'ikar'
def digest(path: Path) -> str:
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def integrity(connection: sqlite3.Connection) -> str:
checks = connection.execute('PRAGMA quick_check').fetchall()
if checks != [('ok',)]:
raise RuntimeError(f'Database integrity failed: {checks}')
if connection.execute('PRAGMA foreign_key_check').fetchall():
raise RuntimeError('Foreign key integrity failed')
return 'ok'
def protected_rows(connection: sqlite3.Connection) -> str:
apps = connection.execute('SELECT * FROM Apps WHERE Slug != ? ORDER BY Id', (SLUG,)).fetchall()
releases = connection.execute(
'SELECT r.* FROM Releases r JOIN Apps a ON a.Id = r.CatalogAppId '
'WHERE a.Slug != ? ORDER BY r.Id', (SLUG,)
).fetchall()
return hashlib.sha256(json.dumps([apps, releases], ensure_ascii=False).encode()).hexdigest()
def service_snapshot() -> dict:
identifiers = subprocess.check_output(['docker', 'ps', '-q'], text=True).split()
inspected = json.loads(subprocess.check_output(['docker', 'inspect', *identifiers], text=True))
containers = {
item['Name']: {'id': item['Id'], 'startedAt': item['State']['StartedAt']}
for item in inspected
}
pid = subprocess.check_output(['systemctl', 'show', 'caddy', '-p', 'MainPID', '--value'], text=True).strip()
return {'containers': containers, 'caddyPid': pid}
def save_report(report: dict) -> None:
staged = REPORT.with_suffix('.json.next')
staged.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
os.replace(staged, REPORT)
def inspect() -> None:
with sqlite3.connect(f'file:{DB}?mode=ro', uri=True) as connection:
apps = connection.execute('SELECT Id, Slug, Name, IsListed, AndroidPackageName FROM Apps ORDER BY Slug').fetchall()
matches = [row for row in apps if 'ikar' in row[1].lower() or 'икар' in row[2].lower() or row[4] == 'ru.ikar.app']
print(json.dumps({
'databaseIntegrity': integrity(connection),
'appColumns': [row[1] for row in connection.execute('PRAGMA table_info(Apps)')],
'releaseColumns': [row[1] for row in connection.execute('PRAGMA table_info(Releases)')],
'existingIkarApps': matches,
'sourceSize': SOURCE.stat().st_size,
'sourceSha256': digest(SOURCE),
}, ensure_ascii=False, indent=2))
def publish() -> None:
if not os.path.ismount('/mnt/data') or DATA.resolve() != DATA:
raise RuntimeError('Expected external disk or Argus data root is missing')
if SOURCE.stat().st_size != EXPECTED_SIZE or digest(SOURCE) != EXPECTED_SHA256:
raise RuntimeError('Source APK differs from the verified release')
connection = sqlite3.connect(DB, timeout=30)
target = None
staged = None
committed = False
try:
connection.execute('PRAGMA foreign_keys = ON')
integrity(connection)
existing = connection.execute(
'SELECT r.Id, r.Sha256, r.AndroidVersionCode FROM Releases r '
'JOIN Apps a ON a.Id = r.CatalogAppId '
'WHERE a.Slug = ? AND r.Version = ? AND r.Channel = ? AND r.Platform = ?',
(SLUG, '1.0.0', 'stable', 'android')
).fetchall()
if existing:
if len(existing) != 1 or existing[0][1:] != (EXPECTED_SHA256, 1):
raise RuntimeError('A different release already occupies IKAR 1.0.0')
print(json.dumps({'alreadyPublished': True, 'releaseId': existing[0][0]}))
return
if connection.execute('SELECT Id FROM Apps WHERE Slug = ?', (SLUG,)).fetchone():
raise RuntimeError('Existing IKAR card needs review before publication')
services_before = service_snapshot()
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
backup = DATA / 'backups' / f'argus-before-ikar-{stamp}-{uuid.uuid4().hex[:8]}.db'
with sqlite3.connect(backup) as backup_connection:
connection.backup(backup_connection)
integrity(backup_connection)
backup.chmod(0o600)
app_id = str(uuid.uuid4()).upper()
release_id = str(uuid.uuid4()).upper()
target_dir = DATA / 'Packages' / SLUG
target_dir.mkdir(exist_ok=True)
if not target_dir.resolve().is_relative_to(DATA / 'Packages'):
raise RuntimeError('Package directory is outside Argus storage')
relative = f'{SLUG}/{stamp}-1.0.0-{release_id.replace("-", "")}.apk'
target = DATA / 'Packages' / relative
staged = target.with_suffix('.apk.next')
shutil.copyfile(SOURCE, staged)
if staged.stat().st_size != EXPECTED_SIZE or digest(staged) != EXPECTED_SHA256:
raise RuntimeError('Staged APK checksum mismatch')
staged.chmod(0o644)
with staged.open('rb') as stream:
os.fsync(stream.fileno())
os.replace(staged, target)
now = datetime.now(timezone.utc).isoformat(timespec='microseconds')
summary = 'Самостоятельный Android-мессенджер с собственным сервером ИКАР.'
description = (
'ИКАР — самостоятельный мессенджер для Android. Личные сообщения, группы и каналы, '
'фото, видео, файлы и голосовые сообщения, реакции, опросы, стикеры и отложенная отправка. '
'Сервер ИКАР сохраняет историю и вложения. Аккаунты MAX автоматически не переносятся. '
'Регистрация привязана к ключу устройства, подтверждение номера по SMS не используется. '
'Звонки между разными сетями пока требуют настройки ретрансляции на сервере.'
)
notes = (
'Первый выпуск ИКАР с собственным сервером ikar_server. '
'Сообщения, группы, каналы, файлы, голосовые сообщения и их расшифровка, '
'стикеры, реакции, опросы и отложенная отправка. '
'Русский интерфейс и иконка ИКАР. Пакет ru.ikar.app, версия Android 1.0.0 (1).'
)
connection.execute('BEGIN IMMEDIATE')
untouched_before = protected_rows(connection)
connection.execute(
'INSERT INTO Apps (Id, Slug, Name, Summary, Description, RepositoryUrl, HomepageUrl, '
'AndroidPackageName, IsListed, CreatedAt, UpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(app_id, SLUG, 'ИКАР', summary, description, None, None, 'ru.ikar.app', 1, now, now)
)
connection.execute(
'INSERT INTO Releases (Id, CatalogAppId, Version, Channel, Platform, PackageKind, '
'AndroidVersionCode, OriginalFileName, StoredRelativePath, ContentType, PackageSizeBytes, '
'Sha256, Notes, PublishedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(release_id, app_id, '1.0.0', 'stable', 'android', 'apk', 1, 'ikar-1.0.0+1.apk', relative,
'application/vnd.android.package-archive', EXPECTED_SIZE, EXPECTED_SHA256, notes, now)
)
integrity(connection)
if protected_rows(connection) != untouched_before:
raise RuntimeError('Unrelated catalog rows changed')
connection.commit()
committed = True
services_after = service_snapshot()
report = {
'slug': SLUG, 'name': 'ИКАР', 'version': '1.0.0', 'androidVersionCode': 1,
'package': 'ru.ikar.app', 'releaseId': release_id, 'sha256': EXPECTED_SHA256,
'bytes': EXPECTED_SIZE, 'publishedAt': now, 'databaseBackup': str(backup),
'databaseIntegrity': integrity(connection), 'backupIntegrity': 'ok',
'unrelatedCatalogRowsUnchanged': protected_rows(connection) == untouched_before,
'servicesUnchanged': services_after == services_before,
'servicesBefore': services_before, 'servicesAfter': services_after,
'cardUrl': 'https://argus.kusoft.xyz/#app=ikar',
'manifestUrl': 'https://argus.kusoft.xyz/api/apps/ikar/manifest?platform=android&channel=stable',
'downloadUrl': f'https://argus.kusoft.xyz/api/apps/ikar/releases/{release_id.lower()}/download',
}
save_report(report)
print(json.dumps(report, ensure_ascii=False, indent=2))
except Exception:
if not committed:
connection.rollback()
if target is not None:
target.unlink(missing_ok=True)
if staged is not None:
staged.unlink(missing_ok=True)
raise
finally:
connection.close()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['inspect', 'publish'])
args = parser.parse_args()
inspect() if args.mode == 'inspect' else publish()