diff --git a/backend/ota_publish.py b/backend/ota_publish.py new file mode 100644 index 0000000..8d89ec6 --- /dev/null +++ b/backend/ota_publish.py @@ -0,0 +1,108 @@ +import json +from datetime import datetime +from pathlib import Path +from urllib.parse import urljoin + +import paramiko + +from backend.android_tools import find_apks +from backend.projects_registry import find_project_by_workspace +from backend.runtime_logs import add_log + + +def _safe_name(value: str) -> str: + return value.strip().replace('/', '-').replace(' ', '-').lower() + + +def _latest_json(settings: dict) -> dict: + notes_raw = settings.get('notes') or '' + notes = [line.strip() for line in notes_raw.splitlines() if line.strip()] + if not notes: + notes = ['Немного нового'] + + version = settings.get('version') or '0.0.1' + build = int(settings.get('build') or 1) + public_base_url = settings.get('public_base_url') or '' + latest_apk_name = settings.get('latest_apk_name') or 'pulse-latest.apk' + versioned_apk_name = settings.get('versioned_apk_name') or f'pulse-{version}+{build}.apk' + + return { + 'version': version, + 'build': build, + 'notes': notes, + 'apk_url': urljoin(public_base_url.rstrip('/') + '/', latest_apk_name), + 'versioned_apk_url': urljoin(public_base_url.rstrip('/') + '/', versioned_apk_name), + } + + +def _mkdir_p_sftp(sftp, remote_path: str) -> None: + path = remote_path.strip('/') + if not path: + return + current = '' + for part in path.split('/'): + current += '/' + part + try: + sftp.stat(current) + except IOError: + sftp.mkdir(current) + + +def publish_project_ota(workspace: str) -> dict: + project = find_project_by_workspace(workspace) + if not project: + return {'success': False, 'message': 'Project not found in registry'} + + settings = project.get('ota') or {} + if not settings.get('enabled'): + return {'success': False, 'message': 'OTA publish disabled'} + + apks = find_apks(workspace) + if not apks: + return {'success': False, 'message': 'APK not found'} + + host = settings.get('sftp_host') or '' + username = settings.get('sftp_username') or '' + password = settings.get('sftp_password') or '' + remote_path = settings.get('remote_path') or '' + + if not host or not username or not password or not remote_path: + return {'success': False, 'message': 'SFTP settings are incomplete'} + + latest_apk_name = settings.get('latest_apk_name') or 'pulse-latest.apk' + version = settings.get('version') or '0.0.1' + build = int(settings.get('build') or 1) + versioned_apk_name = settings.get('versioned_apk_name') or f'pulse-{version}+{build}.apk' + latest_json_name = settings.get('latest_json_name') or 'latest.json' + + latest_json = _latest_json(settings) + tmp_json = Path('/tmp') / f'devconsole-{_safe_name(project.get("name", "project"))}-latest.json' + tmp_json.write_text(json.dumps(latest_json, ensure_ascii=False, indent=4), encoding='utf-8') + + apk_path = apks[0] + + add_log(f'OTA publish started: {project.get("name")} -> {host}:{remote_path}') + + transport = paramiko.Transport((host, int(settings.get('sftp_port') or 22))) + try: + transport.connect(username=username, password=password) + sftp = paramiko.SFTPClient.from_transport(transport) + remote_dir = '/' + remote_path.strip('/') + _mkdir_p_sftp(sftp, remote_dir) + + sftp.put(apk_path, f'{remote_dir}/{latest_apk_name}') + sftp.put(apk_path, f'{remote_dir}/{versioned_apk_name}') + sftp.put(tmp_json.as_posix(), f'{remote_dir}/{latest_json_name}') + sftp.close() + finally: + transport.close() + + add_log(f'OTA publish completed: {latest_json.get("apk_url")}') + + return { + 'success': True, + 'apk': apk_path, + 'latest_json': latest_json, + 'uploaded': [latest_apk_name, versioned_apk_name, latest_json_name], + 'published_at': datetime.utcnow().isoformat() + 'Z', + }