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.artifacts_registry import record_apk_artifact from backend.projects_registry import find_project_by_workspace from backend.pubspec_tools import read_pubspec_version from backend.runtime_logs import add_log def _safe_name(value: str) -> str: return value.strip().replace('/', '-').replace(' ', '-').lower() def _apply_pubspec_version(settings: dict, workspace: str) -> dict: synced = dict(settings) pubspec = read_pubspec_version(workspace) if pubspec: synced['version'] = pubspec.get('version') or synced.get('version') synced['build'] = pubspec.get('build') or synced.get('build') return synced 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 = _apply_pubspec_version(project.get('ota') or {}, workspace) 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}') add_log(f'OTA version synced from pubspec: {version}+{build}') 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() artifact = record_apk_artifact( workspace=workspace, apk_path=apk_path, project_name=project.get('name'), version=version, build=build, ota_result={ 'success': True, 'latest_json': latest_json, 'uploaded': [latest_apk_name, versioned_apk_name, latest_json_name], }, source='ota_publish', ) add_log(f'APK artifact saved: {artifact.get("apk_name")}') 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], 'artifact': artifact, 'published_at': datetime.utcnow().isoformat() + 'Z', }