115 lines
5.3 KiB
Python
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))
|