Files
IKAR/tool/publish_messaging_update.py
T

115 lines
5.3 KiB
Python

"""Publish a verified IKAR maintenance APK and preserve previous releases."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import shutil
import sqlite3
import uuid
from publish_argus_release import digest, integrity, protected_rows, service_snapshot
def publish(args):
root = Path('/mnt/data/ikar_server')
data = Path('/mnt/data/argus')
source = root / 'shared/data/AppUpdates/Android' / f'ikar-android-{args.code}.apk.next'
if not os.path.ismount('/mnt/data') or root.resolve() != root or data.resolve() != data:
raise RuntimeError('Expected mounted data roots are missing')
if source.stat().st_size != args.size or digest(source) != args.sha256:
raise RuntimeError('Uploaded APK differs from verified artifact')
before = service_snapshot()
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
now = datetime.now(timezone.utc).isoformat(timespec='microseconds')
db = sqlite3.connect(data / 'argus.db', timeout=30)
package = None
committed = False
try:
db.execute('PRAGMA foreign_keys=ON')
integrity(db)
app = db.execute('SELECT Id, AndroidPackageName FROM Apps WHERE Slug=?', ('ikar',)).fetchone()
if app is None or app[1] != 'ru.ikar.app':
raise RuntimeError('Expected IKAR card is missing')
latest = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (app[0],)).fetchone()[0]
if args.code <= latest:
raise RuntimeError('Version code must exceed the published version')
backup = data / 'backups' / f'argus-before-ikar-{args.version}-{stamp}.db'
with sqlite3.connect(backup) as copy:
db.backup(copy)
integrity(copy)
backup.chmod(0o600)
release_id = str(uuid.uuid4()).upper()
relative = f'ikar/{stamp}-{args.version}-{release_id.replace("-", "")}.apk'
package = data / 'Packages' / relative
if not package.resolve().is_relative_to(data / 'Packages/ikar'):
raise RuntimeError('Unexpected package destination')
staged = package.with_suffix('.apk.next')
shutil.copyfile(source, staged)
if digest(staged) != args.sha256:
raise RuntimeError('Staged package checksum mismatch')
staged.chmod(0o644)
with staged.open('rb') as stream:
os.fsync(stream.fileno())
os.replace(staged, package)
untouched = protected_rows(db)
db.execute('BEGIN IMMEDIATE')
db.execute(
'INSERT INTO Releases (Id,CatalogAppId,Version,Channel,Platform,PackageKind,AndroidVersionCode,'
'OriginalFileName,StoredRelativePath,ContentType,PackageSizeBytes,Sha256,Notes,PublishedAt) '
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
(release_id, app[0], args.version, 'stable', 'android', 'apk', args.code,
f'ikar-{args.version}+{args.code}.apk', relative, 'application/vnd.android.package-archive',
args.size, args.sha256, args.notes, now),
)
db.execute('UPDATE Apps SET UpdatedAt=? WHERE Id=?', (now, app[0]))
integrity(db)
if protected_rows(db) != untouched:
raise RuntimeError('Unrelated catalog rows changed')
db.commit()
committed = True
os.chown(source, 1000, 1000)
apk = source.with_suffix('')
os.replace(source, apk)
metadata = apk.parent / 'latest.json'
metadata_backup = apk.parent / f'latest-before-{args.code}-{stamp}.json'
if metadata.exists():
shutil.copyfile(metadata, metadata_backup)
os.chown(metadata_backup, 1000, 1000)
pending = metadata.with_suffix('.json.next')
pending.write_text(json.dumps({
'versionCode': args.code, 'versionName': args.version, 'packageFileName': apk.name,
'publishedAt': now, 'notes': args.notes,
}, ensure_ascii=False), encoding='utf-8')
os.chown(pending, 1000, 1000)
os.replace(pending, metadata)
report = {
'version': args.version, 'androidVersionCode': args.code, 'package': 'ru.ikar.app',
'releaseId': release_id, 'sha256': args.sha256, 'bytes': args.size,
'databaseBackup': str(backup), 'databaseIntegrity': integrity(db), 'backupIntegrity': 'ok',
'unrelatedCatalogRowsUnchanged': protected_rows(db) == untouched,
'servicesUnchanged': service_snapshot() == before,
'downloadUrl': f'https://argus.kusoft.xyz/api/apps/ikar/releases/{release_id.lower()}/download',
'cardUrl': 'https://argus.kusoft.xyz/#app=ikar', 'timestamp': now,
}
(root / f'argus-publication-{args.version}.json').write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8',
)
print(json.dumps(report, ensure_ascii=False))
finally:
if not committed:
db.rollback()
if package is not None:
package.unlink(missing_ok=True)
package.with_suffix('.apk.next').unlink(missing_ok=True)
db.close()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--request-file', required=True)
request = json.loads(Path(parser.parse_args().request_file).read_text(encoding='utf-8'))
publish(argparse.Namespace(**request))