""" Ansible and SSH management API endpoints """ from flask import Blueprint, request, jsonify import json import os from datetime import datetime from app.services.ansible_service import AnsibleService from app.models import Device, AnsibleExecution, ExecutionFailureReport from config.database_config import get_db import logging # Create blueprint ansible_bp = Blueprint('ansible', __name__, url_prefix='/api/ansible') # Initialize service ansible_service = AnsibleService() @ansible_bp.route('/inventory', methods=['GET']) def get_inventory(): """Get current Ansible inventory (structured)""" try: data = ansible_service.get_inventory_data() return jsonify({'success': True, 'inventory': data}) except Exception as e: logging.error(f"Error getting inventory: {e}") return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/raw', methods=['GET']) def get_inventory_raw(): """Get raw YAML inventory text""" try: data = ansible_service.get_inventory_data() return jsonify({'success': True, 'yaml': data.get('raw_yaml', '')}) except Exception as e: return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/sync', methods=['POST']) def sync_inventory(): """Sync all active app devices into monitoring_devices inventory group""" try: result = ansible_service.sync_devices_to_inventory() status = 200 if result.get('success') else 400 return jsonify(result), status except Exception as e: logging.error(f"Error syncing inventory: {e}") return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/group/add', methods=['POST']) def add_inventory_group(): """Add a new inventory group""" try: data = request.get_json() group_name = (data or {}).get('group_name', '').strip() if not group_name: return jsonify({'success': False, 'error': 'group_name is required'}), 400 result = ansible_service.add_group_to_inventory(group_name) return jsonify(result), 200 if result.get('success') else 400 except Exception as e: return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/group/remove', methods=['POST']) def remove_inventory_group(): """Remove an inventory group""" try: data = request.get_json() group_name = (data or {}).get('group_name', '').strip() if not group_name: return jsonify({'success': False, 'error': 'group_name is required'}), 400 result = ansible_service.remove_group_from_inventory(group_name) return jsonify(result), 200 if result.get('success') else 400 except Exception as e: return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/host/add', methods=['POST']) def add_inventory_host(): """Add a host to an inventory group""" try: data = request.get_json() if not data: return jsonify({'success': False, 'error': 'JSON body required'}), 400 group = data.get('group', '').strip() hostname = data.get('hostname', '').strip() ip = data.get('ip', '').strip() ssh_user = data.get('ssh_user', 'pi').strip() or 'pi' ssh_port = int(data.get('ssh_port', 22)) use_key = bool(data.get('use_key', True)) password = data.get('password', None) if not group or not hostname or not ip: return jsonify({'success': False, 'error': 'group, hostname and ip are required'}), 400 result = ansible_service.add_host_to_inventory( group=group, hostname=hostname, ip=ip, ssh_user=ssh_user, ssh_port=ssh_port, use_key=use_key, password=password ) return jsonify(result), 200 if result.get('success') else 400 except Exception as e: return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/host/remove', methods=['POST']) def remove_inventory_host(): """Remove a host from an inventory group""" try: data = request.get_json() group = (data or {}).get('group', '').strip() hostname = (data or {}).get('hostname', '').strip() if not group or not hostname: return jsonify({'success': False, 'error': 'group and hostname are required'}), 400 result = ansible_service.remove_host_from_inventory(group=group, hostname=hostname) return jsonify(result), 200 if result.get('success') else 400 except Exception as e: return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/inventory/refresh', methods=['POST']) def refresh_inventory(): """Refresh Ansible inventory from database (legacy alias for /sync)""" try: result = ansible_service.sync_devices_to_inventory() return jsonify(result), 200 if result.get('success') else 400 except Exception as e: logging.error(f"Error refreshing inventory: {e}") return jsonify({'error': str(e), 'success': False}), 500 @ansible_bp.route('/playbooks', methods=['GET']) def list_playbooks(): """List available Ansible playbooks""" try: playbook_dir = ansible_service.playbook_dir playbooks = [] if playbook_dir.exists(): for file in playbook_dir.glob('*.yml'): playbooks.append({ 'name': file.stem, 'filename': file.name, 'path': str(file), 'modified': datetime.fromtimestamp(file.stat().st_mtime).isoformat() }) # Add built-in playbooks builtin_playbooks = [ { 'name': 'update_devices', 'description': 'Update all packages on monitoring devices', 'builtin': True }, { 'name': 'restart_service', 'description': 'Restart monitoring services on devices', 'builtin': True }, { 'name': 'distribute_ssh_keys', 'description': 'Push server public key to devices using password auth', 'builtin': True }, ] return jsonify({ 'success': True, 'playbooks': playbooks, 'builtin_playbooks': builtin_playbooks }) except Exception as e: logging.error(f"Error listing playbooks: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/execute', methods=['POST']) def execute_playbook(): """ Execute Ansible playbook Expected JSON: { "playbook": "update_devices", "limit_hosts": ["device-01", "device-02"], # optional "extra_vars": {"key": "value"}, # optional "create_builtin": True # optional, create builtin playbook if needed } """ try: data = request.get_json() if not data or not data.get('playbook'): return jsonify({ 'error': 'Playbook name is required', 'success': False }), 400 playbook_name = data['playbook'] limit_hosts = data.get('limit_hosts') extra_vars = data.get('extra_vars', {}) create_builtin = data.get('create_builtin', True) # Create builtin playbooks if they don't exist if create_builtin: if playbook_name == 'update_devices': ansible_service.create_update_playbook() elif playbook_name == 'restart_service': ansible_service.create_restart_service_playbook() # Add controller IP to extra vars for callbacks extra_vars['ansible_controller_ip'] = request.host # Execute playbook result = ansible_service.execute_playbook( playbook_name=playbook_name, limit_hosts=limit_hosts, extra_vars=extra_vars ) if result['success']: return jsonify({ 'success': True, 'message': 'Playbook execution started', 'execution_id': result['execution_id'], 'log_file': result['log_file'] }) else: return jsonify(result), 500 except Exception as e: logging.error(f"Error executing playbook: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/executions', methods=['GET']) def get_executions(): """Get Ansible execution history""" try: limit = min(int(request.args.get('limit', 50)), 200) executions = ansible_service.get_execution_history(limit=limit) return jsonify({ 'success': True, 'executions': executions }) except Exception as e: logging.error(f"Error getting executions: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/executions//live', methods=['GET']) def get_execution_live(execution_id): """Poll current log + status for a running or finished execution (UUID).""" result = ansible_service.get_live_execution(execution_id) if not result.get('success'): return jsonify(result), 404 return jsonify(result), 200 @ansible_bp.route('/executions/', methods=['GET']) def get_execution_details(execution_id): """Get detailed execution information""" try: with get_db().get_session() as session: execution = session.query(AnsibleExecution).get(execution_id) if not execution: return jsonify({ 'error': 'Execution not found', 'success': False }), 404 execution_data = { 'id': execution.id, 'playbook_name': execution.playbook_name, 'target_devices': json.loads(execution.target_devices) if execution.target_devices else [], 'command_line': execution.command_line, 'start_time': execution.start_time.isoformat() if execution.start_time else None, 'end_time': execution.end_time.isoformat() if execution.end_time else None, 'status': execution.status, 'exit_code': execution.exit_code, 'stdout_log': execution.stdout_log, 'stderr_log': execution.stderr_log, 'successful_hosts': execution.successful_hosts, 'failed_hosts': execution.failed_hosts, 'unreachable_hosts': execution.unreachable_hosts } # Read log file if it exists if execution.ansible_log_file and os.path.exists(execution.ansible_log_file): with open(execution.ansible_log_file, 'r') as f: execution_data['full_log'] = f.read() return jsonify({ 'success': True, 'execution': execution_data }) except Exception as e: logging.error(f"Error getting execution details: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/ssh/test', methods=['POST']) def test_ssh_connectivity(): """ Test SSH connectivity to devices Expected JSON: { "device_ips": ["192.168.1.100", "192.168.1.101"], "username": "pi" # optional, defaults to "pi" } """ try: data = request.get_json() if not data or not data.get('device_ips'): return jsonify({ 'error': 'device_ips list is required', 'success': False }), 400 device_ips = data['device_ips'] username = data.get('username', 'pi') # Test connectivity if len(device_ips) == 1: # Single device test result = ansible_service.test_ssh_connectivity(device_ips[0], username) return jsonify({ 'success': True, 'results': {device_ips[0]: result} }) else: # Bulk test results = ansible_service.bulk_ssh_test(device_ips) # Summary successful = sum(1 for r in results.values() if r.get('success')) total = len(results) return jsonify({ 'success': True, 'results': results, 'summary': { 'successful': successful, 'failed': total - successful, 'total': total } }) except Exception as e: logging.error(f"Error testing SSH connectivity: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/ssh/test-password', methods=['POST']) def test_password_auth(): """ Test password-only SSH authentication to a single device. Use this to verify the configured password is correct before deploying keys. Expected JSON: { "device_ip": "10.76.157.145", "password": "raspberry", # optional — uses saved setting if omitted "username": "pi", # optional "port": 22 # optional } """ try: data = request.get_json() or {} device_ip = (data.get('device_ip') or '').strip() if not device_ip: return jsonify({'success': False, 'error': 'device_ip is required'}), 400 # Use provided password, fall back to saved setting password = data.get('password') or ansible_service.load_settings().get('ssh_fallback_password', '') if not password: return jsonify({'success': False, 'error': 'No password provided and none saved in SSH Settings'}), 400 username = data.get('username', 'pi') port = int(data.get('port', 22)) result = ansible_service.test_password_auth(device_ip, password, username, port) status = 200 if result.get('success') else 400 return jsonify(result), status except Exception as e: logging.error(f"Error testing password auth: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @ansible_bp.route('/ssh/distribute-keys', methods=['POST']) def distribute_ssh_keys(): """ Push the server's public SSH key to all (or selected) devices using password auth. After this completes, all other playbooks can use key-based authentication. Optional JSON body: { "limit_hosts": ["RPI-ABC1", "RPI-ABC2"] # omit to target all devices } """ try: settings = ansible_service.load_settings() if not settings.get('ssh_fallback_password'): return jsonify({ 'success': False, 'error': 'No SSH password configured. Set it in SSH Settings before distributing keys.' }), 400 # Make sure the public key exists public_key_path = ansible_service.ssh_key_path.with_suffix('.pub') if not public_key_path.exists(): return jsonify({ 'success': False, 'error': 'Public key not found at ansible/ssh_keys/app_key.pub. Generate SSH keys first.' }), 400 data = request.get_json() or {} limit_hosts = data.get('limit_hosts') or None ansible_service.create_distribute_ssh_keys_playbook() result = ansible_service.execute_playbook_async( playbook_name='distribute_ssh_keys', limit_hosts=limit_hosts, force_password_auth=True, ) return jsonify(result), 200 if result.get('success') else 500 except Exception as e: logging.error(f"Error distributing SSH keys: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @ansible_bp.route('/ssh/keys/setup', methods=['POST']) def setup_ssh_keys(): """Setup SSH keys for Ansible authentication""" try: result = ansible_service.setup_ssh_keys() return jsonify(result) except Exception as e: logging.error(f"Error setting up SSH keys: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/ssh/keys/public', methods=['GET']) def get_public_key(): """Get public key for distribution to devices""" try: public_key_path = ansible_service.ssh_key_path.with_suffix('.pub') if not public_key_path.exists(): return jsonify({ 'error': 'Public key not found. Run SSH setup first.', 'success': False }), 404 with open(public_key_path, 'r') as f: public_key = f.read().strip() return jsonify({ 'success': True, 'public_key': public_key, 'key_path': str(public_key_path) }) except Exception as e: logging.error(f"Error getting public key: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 @ansible_bp.route('/devices/status', methods=['GET']) def get_devices_status(): """Get status of all devices for Ansible operations""" try: with get_db().get_session() as session: devices = session.query(Device).all() device_data = [] for device in devices: device_data.append({ 'id': device.id, 'hostname': device.hostname, 'device_ip': device.device_ip, 'nume_masa': device.nume_masa, 'status': device.status, 'last_seen': device.last_seen.isoformat() if device.last_seen else None, 'device_type': device.device_type, 'os_version': device.os_version, 'location': device.location }) return jsonify({ 'success': True, 'devices': device_data, 'total_count': len(device_data) }) except Exception as e: logging.error(f"Error getting devices status: {e}") return jsonify({ 'error': str(e), 'success': False }), 500 # Callback endpoints for Ansible playbooks @ansible_bp.route('/callback/update_complete', methods=['POST']) def update_complete_callback(): """Callback endpoint for update completion""" try: data = request.get_json() logging.info(f"Update completed for {data.get('hostname')}: {data}") # You could update device status, send notifications, etc. return jsonify({'success': True}) except Exception as e: logging.error(f"Error in update callback: {e}") return jsonify({'error': str(e)}), 500 @ansible_bp.route('/callback/service_restarted', methods=['POST']) def service_restart_callback(): """Callback endpoint for service restart completion""" try: data = request.get_json() logging.info(f"Service restarted for {data.get('hostname')}: {data}") return jsonify({'success': True}) except Exception as e: logging.error(f"Error in service restart callback: {e}") return jsonify({'error': str(e)}), 500 # ── Failure Reports ────────────────────────────────────────────────────── # @ansible_bp.route('/failure-reports', methods=['GET']) def list_failure_reports(): """Return all saved execution failure reports, newest first.""" try: with get_db().get_session() as session: reports = session.query(ExecutionFailureReport)\ .order_by(ExecutionFailureReport.saved_at.desc())\ .all() return jsonify({'success': True, 'reports': [r.to_dict() for r in reports]}) except Exception as e: logging.error(f"Error listing failure reports: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @ansible_bp.route('/failure-reports', methods=['POST']) def save_failure_report(): """ Save a failure report for an execution. Parses the PLAY RECAP from the execution log to extract per-host failure reasons. Expected JSON: { "execution_id": "uuid", "note": "optional free-text note" # optional } """ import re try: data = request.get_json() or {} execution_id = (data.get('execution_id') or '').strip() if not execution_id: return jsonify({'success': False, 'error': 'execution_id is required'}), 400 with get_db().get_session() as session: from app.models import PlaybookExecution execution = session.query(PlaybookExecution).filter_by( execution_id=execution_id ).first() if not execution: return jsonify({'success': False, 'error': 'Execution not found'}), 404 # Parse PLAY RECAP for per-host stats log_text = execution.stdout_log or '' recap_re = re.compile( r'^(\S.*?)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)', re.MULTILINE ) failed_hosts = [] failed_count = 0 unreachable_count = 0 for m in recap_re.finditer(log_text): hostname = m.group(1).strip() unreachable = int(m.group(4)) failed = int(m.group(5)) if unreachable > 0: failed_hosts.append({'hostname': hostname, 'status': 'unreachable', 'reason': 'Host unreachable via SSH'}) unreachable_count += 1 elif failed > 0: failed_hosts.append({'hostname': hostname, 'status': 'failed', 'reason': f'{failed} task(s) failed'}) failed_count += 1 if not failed_hosts: return jsonify({'success': False, 'error': 'No failed or unreachable hosts found in this execution'}), 400 # Avoid duplicate reports for the same execution existing = session.query(ExecutionFailureReport).filter_by( execution_id=execution_id ).first() if existing: return jsonify({'success': False, 'error': 'A report for this execution already exists', 'report_id': existing.id}), 409 report = ExecutionFailureReport( execution_id=execution_id, playbook_name=execution.playbook_name, saved_at=datetime.utcnow(), failed_count=failed_count, unreachable_count=unreachable_count, failed_hosts=json.dumps(failed_hosts), note=data.get('note', ''), ) session.add(report) session.flush() report_id = report.id return jsonify({'success': True, 'report_id': report_id, 'failed_count': failed_count, 'unreachable_count': unreachable_count}), 201 except Exception as e: logging.error(f"Error saving failure report: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @ansible_bp.route('/failure-reports/', methods=['DELETE']) def delete_failure_report(report_id): """Delete a saved failure report.""" try: with get_db().get_session() as session: report = session.query(ExecutionFailureReport).get(report_id) if not report: return jsonify({'success': False, 'error': 'Report not found'}), 404 session.delete(report) return jsonify({'success': True}) except Exception as e: logging.error(f"Error deleting failure report {report_id}: {e}") return jsonify({'success': False, 'error': str(e)}), 500