""" Web routes for Ansible management interface """ from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from app.services.ansible_service import AnsibleService from app.models import Device, AnsibleExecution, PlaybookExecution, ExecutionFailureReport from config.database_config import get_db import logging # Create blueprint ansible_web_bp = Blueprint('ansible_web', __name__, url_prefix='/ansible') # Initialize service ansible_service = AnsibleService() @ansible_web_bp.route('/') def index(): """Redirect ansible root to playbooks""" return redirect(url_for('ansible_web.playbooks')) @ansible_web_bp.route('/devices') def devices(): """Ansible inventory management interface""" try: # Load current inventory from file inventory_data = ansible_service.get_inventory_data() # Load all DB devices so the user can see which haven't been synced yet with get_db().get_session() as session: db_devices = session.query(Device).all() db_devices_list = [ { 'hostname': d.hostname, 'device_ip': d.device_ip, 'status': d.status, 'device_type': d.device_type, 'location': d.location } for d in db_devices ] # Collect all hostnames already present in inventory all_inv_hosts = set() for group_data in inventory_data.get('groups', {}).values(): for h in group_data.get('hosts', []): all_inv_hosts.add(h['hostname']) # Mark which DB devices are in inventory for d in db_devices_list: d['in_inventory'] = d['hostname'] in all_inv_hosts return render_template( 'ansible/devices.html', inventory=inventory_data, db_devices=db_devices_list, all_inv_hosts=all_inv_hosts ) except Exception as e: logging.error(f"Error loading inventory page: {e}") flash(f'Error loading inventory: {e}', 'error') return render_template( 'ansible/devices.html', inventory={'groups': {}, 'raw_yaml': ''}, db_devices=[], all_inv_hosts=set() ) @ansible_web_bp.route('/playbooks') def playbooks(): """Playbook management interface""" try: # Get available playbooks 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) }) # 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 render_template('ansible/playbooks.html', playbooks=playbooks, builtin_playbooks=builtin_playbooks) except Exception as e: logging.error(f"Error loading playbooks: {e}") flash(f'Error loading playbooks: {e}', 'error') return render_template('ansible/playbooks.html', playbooks=[], builtin_playbooks=[]) @ansible_web_bp.route('/execute', methods=['GET', 'POST']) def execute(): """Execute playbook interface""" if request.method == 'GET': try: preselect = request.args.get('playbook', '') inventory_data = ansible_service.get_inventory_data() # Flatten all unique hosts from inventory for the host picker seen = set() all_inv_hosts = [] for group in inventory_data.get('groups', {}).values(): for h in group.get('hosts', []): if h['hostname'] not in seen: all_inv_hosts.append({ 'hostname': h['hostname'], 'ip': h.get('ansible_host', '') }) seen.add(h['hostname']) settings = ansible_service.load_settings() return render_template('ansible/execute.html', inventory=inventory_data, all_inv_hosts=all_inv_hosts, preselect_playbook=preselect, use_password_auth=settings.get('use_password_auth', False)) except Exception as e: logging.error(f"Error loading execute form: {e}") flash(f'Error loading form: {e}', 'error') return render_template('ansible/execute.html', inventory={'groups': {}}, all_inv_hosts=[], preselect_playbook='', use_password_auth=False) elif request.method == 'POST': # Execute playbook try: import json as _json is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' playbook_name = request.form.get('playbook') selected_hosts = request.form.getlist('hosts') priority = int(request.form.get('priority', 5)) max_retries = int(request.form.get('max_retries', 0)) check_mode = bool(request.form.get('check_mode')) extra_vars = {} # Parse extra variables if provided extra_vars_str = request.form.get('extra_vars', '').strip() if extra_vars_str: try: extra_vars = _json.loads(extra_vars_str) except _json.JSONDecodeError: if is_ajax: return jsonify({'success': False, 'error': 'Invalid JSON in extra variables'}), 400 flash('Invalid JSON format for extra variables', 'error') return redirect(url_for('ansible_web.execute')) # Add check mode to extra vars if enabled if check_mode: extra_vars['check_mode'] = True if not playbook_name: if is_ajax: return jsonify({'success': False, 'error': 'Playbook selection is required'}), 400 flash('Playbook selection is required', 'error') return redirect(url_for('ansible_web.execute')) if not selected_hosts: if is_ajax: return jsonify({'success': False, 'error': 'At least one device must be selected'}), 400 flash('At least one device must be selected', 'error') return redirect(url_for('ansible_web.execute')) # Create builtin playbooks if needed if playbook_name == 'update_devices': ansible_service.create_update_playbook() elif playbook_name == 'restart_service': ansible_service.create_restart_service_playbook() elif playbook_name == 'system_health': ansible_service.create_system_health_playbook() elif playbook_name == 'distribute_ssh_keys': ansible_service.create_distribute_ssh_keys_playbook() # Add controller IP for callbacks extra_vars['ansible_controller_ip'] = request.host # Force password auth for key distribution, or honour the form toggle force_password = (playbook_name == 'distribute_ssh_keys') or \ bool(request.form.get('force_password_auth')) # Use async execution (returns immediately with execution_id) result = ansible_service.execute_playbook_async( playbook_name=playbook_name, limit_hosts=selected_hosts, extra_vars=extra_vars, priority=priority, max_retries=max_retries, force_password_auth=force_password, ) if result['success']: if is_ajax: return jsonify({'success': True, 'execution_id': result['execution_id']}) flash(f'Playbook "{playbook_name}" started! Monitoring execution...', 'success') return redirect(url_for('ansible_web.execution_details', execution_id=result['execution_id'])) else: error_msg = result.get('error', 'Unknown error') if is_ajax: return jsonify({'success': False, 'error': error_msg}), 500 flash(f'Playbook execution failed: {error_msg}', 'error') return redirect(url_for('ansible_web.execute')) except Exception as e: logging.error(f"Error executing playbook: {e}") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({'success': False, 'error': str(e)}), 500 flash(f'Error executing playbook: {e}', 'error') return redirect(url_for('ansible_web.execute')) @ansible_web_bp.route('/executions') def executions(): """Execution history interface""" try: executions = ansible_service.get_execution_history(limit=100) return render_template('ansible/executions.html', executions=executions) except Exception as e: logging.error(f"Error loading executions: {e}") flash(f'Error loading executions: {e}', 'error') return render_template('ansible/executions.html', executions=[]) @ansible_web_bp.route('/executions/') def execution_details(execution_id): """View detailed execution results""" try: with get_db().get_session() as session: execution = session.query(PlaybookExecution).filter_by( execution_id=execution_id ).first() if not execution: flash('Execution not found', 'error') return redirect(url_for('ansible_web.executions')) # Read log file if available log_content = None if execution.ansible_log_file: try: with open(execution.ansible_log_file, 'r') as f: log_content = f.read() except FileNotFoundError: log_content = "Log file not found" return render_template('ansible/execution_details.html', execution=execution, log_content=log_content) except Exception as e: logging.error(f"Error loading execution details: {e}") flash(f'Error loading execution details: {e}', 'error') return redirect(url_for('ansible_web.executions')) @ansible_web_bp.route('/executions//live-popup') def execution_live_popup(execution_id): """Standalone popup window for live execution output""" return render_template('ansible/live_popup.html', execution_id=execution_id) @ansible_web_bp.route('/ssh/setup') def ssh_setup(): """SSH key setup interface""" try: # Check if SSH key exists key_exists = ansible_service.ssh_key_path.exists() public_key = None if key_exists: public_key_path = ansible_service.ssh_key_path.with_suffix('.pub') if public_key_path.exists(): with open(public_key_path, 'r') as f: public_key = f.read().strip() settings = ansible_service.load_settings() return render_template('ansible/ssh_setup.html', key_exists=key_exists, public_key=public_key, settings=settings) except Exception as e: logging.error(f"Error in SSH setup page: {e}") flash(f'Error loading SSH setup: {e}', 'error') return render_template('ansible/ssh_setup.html', key_exists=False, public_key=None, settings={}) @ansible_web_bp.route('/ssh/settings', methods=['POST']) def save_ssh_settings(): """Save SSH settings (fallback password etc.)""" try: fallback_password = request.form.get('ssh_fallback_password', '').strip() if not fallback_password: flash('Password cannot be empty.', 'error') return redirect(url_for('ansible_web.ssh_setup')) use_password_auth = request.form.get('use_password_auth') == 'on' ansible_service.save_settings({ 'ssh_fallback_password': fallback_password, 'use_password_auth': use_password_auth, }) flash('SSH settings saved successfully.', 'success') except Exception as e: logging.error(f"Error saving SSH settings: {e}") flash(f'Error saving SSH settings: {e}', 'error') return redirect(url_for('ansible_web.ssh_setup')) @ansible_web_bp.route('/ssh/generate', methods=['POST']) def generate_ssh_keys(): """Generate new SSH keys""" try: result = ansible_service.setup_ssh_keys() if result['success']: flash('SSH keys generated successfully!', 'success') else: flash(f'Error generating SSH keys: {result.get("error")}', 'error') return redirect(url_for('ansible_web.ssh_setup')) except Exception as e: logging.error(f"Error generating SSH keys: {e}") flash(f'Error generating SSH keys: {e}', 'error') return redirect(url_for('ansible_web.ssh_setup')) @ansible_web_bp.route('/ssh/test', methods=['POST']) def test_ssh(): """Test SSH connectivity to selected devices""" try: selected_ips = request.form.getlist('device_ips') if not selected_ips: flash('Please select at least one device to test', 'error') return redirect(url_for('ansible_web.devices')) # Test connectivity results = ansible_service.bulk_ssh_test(selected_ips) # Count results successful = sum(1 for r in results.values() if r.get('success')) total = len(results) flash(f'SSH test completed: {successful}/{total} devices reachable', 'success' if successful == total else 'warning') return render_template('ansible/ssh_test_results.html', results=results) except Exception as e: logging.error(f"Error testing SSH: {e}") flash(f'Error testing SSH: {e}', 'error') return redirect(url_for('ansible_web.devices')) # API endpoints for AJAX calls @ansible_web_bp.route('/api/refresh_inventory', methods=['POST']) def api_refresh_inventory(): """AJAX endpoint to refresh inventory""" try: inventory = ansible_service.generate_dynamic_inventory() device_count = len(inventory.get('all', {}).get('children', {}).get('monitoring_devices', {}).get('hosts', {})) return jsonify({ 'success': True, 'message': f'Inventory refreshed with {device_count} devices' }) except Exception as e: logging.error(f"Error refreshing inventory: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @ansible_web_bp.route('/api/execution_status/') def api_execution_status(execution_id): """AJAX endpoint to get execution status""" try: with get_db().get_session() as session: execution = session.query(PlaybookExecution).filter_by( execution_id=execution_id ).first() if not execution: return jsonify({'error': 'Execution not found'}), 404 return jsonify({ 'id': execution.id, 'status': execution.status, 'start_time': execution.start_time.isoformat() if execution.start_time else None, 'end_time': execution.end_time.isoformat() if execution.end_time else None, 'exit_code': execution.exit_code, 'successful_hosts': execution.successful_hosts, 'failed_hosts': execution.failed_hosts, 'unreachable_hosts': execution.unreachable_hosts }) except Exception as e: logging.error(f"Error getting execution status: {e}") return jsonify({'error': str(e)}), 500 @ansible_web_bp.route('/upload_playbook', methods=['POST']) def upload_playbook(): """Upload a custom playbook file""" try: if 'playbook_file' not in request.files: flash('No playbook file selected', 'error') return redirect(url_for('ansible_web.playbooks')) file = request.files['playbook_file'] if file.filename == '': flash('No playbook file selected', 'error') return redirect(url_for('ansible_web.playbooks')) if file and file.filename.lower().endswith(('.yml', '.yaml')): # Get playbook name playbook_name = request.form.get('playbook_name', '').strip() if not playbook_name: playbook_name = file.filename.rsplit('.', 1)[0] # Clean filename import re safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', playbook_name) filename = f"{safe_filename}.yml" # Save file playbook_path = ansible_service.playbook_dir / filename file.save(str(playbook_path)) flash(f'Playbook "{filename}" uploaded successfully!', 'success') else: flash('Invalid file type. Please upload a .yml or .yaml file.', 'error') return redirect(url_for('ansible_web.playbooks')) except Exception as e: logging.error(f"Error uploading playbook: {e}") flash(f'Error uploading playbook: {e}', 'error') return redirect(url_for('ansible_web.playbooks')) @ansible_web_bp.route('/playbook/content') def playbook_content(): """Get playbook content for viewing""" try: playbook_path = request.args.get('path') if not playbook_path: return "No playbook path provided", 400 # Security check - ensure path is within playbooks directory from pathlib import Path requested_path = Path(playbook_path) if not requested_path.is_absolute(): requested_path = ansible_service.playbook_dir / requested_path # Ensure path is within playbook directory try: requested_path.resolve().relative_to(ansible_service.playbook_dir.resolve()) except ValueError: return "Invalid playbook path", 400 if not requested_path.exists(): return "Playbook file not found", 404 with open(requested_path, 'r', encoding='utf-8') as f: content = f.read() return content, 200, {'Content-Type': 'text/plain'} except Exception as e: logging.error(f"Error reading playbook content: {e}") return f"Error reading playbook: {e}", 500 @ansible_web_bp.route('/playbook/save', methods=['POST']) def save_playbook(): """Save a new or existing playbook""" try: data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 name = data.get('name', '').strip() content = data.get('content', '').strip() is_new = data.get('is_new', False) if not name: return jsonify({'error': 'Playbook name is required'}), 400 if not content: return jsonify({'error': 'Playbook content is required'}), 400 # Validate YAML syntax try: import yaml yaml.safe_load(content) except yaml.YAMLError as e: return jsonify({'error': f'Invalid YAML syntax: {e}'}), 400 # Clean filename import re safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', name) filename = f"{safe_filename}.yml" # Save file playbook_path = ansible_service.playbook_dir / filename # Check if file exists and not updating if is_new and playbook_path.exists(): return jsonify({'error': f'Playbook {filename} already exists'}), 400 with open(playbook_path, 'w', encoding='utf-8') as f: f.write(content) return jsonify({ 'success': True, 'message': f'Playbook {filename} saved successfully', 'filename': filename }) except Exception as e: logging.error(f"Error saving playbook: {e}") return jsonify({'error': str(e)}), 500 @ansible_web_bp.route('/playbook/validate', methods=['POST']) def validate_playbook(): """Validate playbook YAML syntax and structure""" try: data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 content = data.get('content', '').strip() if not content: return jsonify({'error': 'No content to validate'}), 400 # Validate YAML syntax try: import yaml parsed = yaml.safe_load(content) except yaml.YAMLError as e: return jsonify({'valid': False, 'error': f'YAML syntax error: {e}'}) # Basic Ansible playbook structure validation if not isinstance(parsed, list): return jsonify({'valid': False, 'error': 'Playbook must be a list of plays'}) for i, play in enumerate(parsed): if not isinstance(play, dict): return jsonify({'valid': False, 'error': f'Play {i+1} must be a dictionary'}) if 'hosts' not in play: return jsonify({'valid': False, 'error': f'Play {i+1} is missing required "hosts" field'}) if 'tasks' not in play and 'roles' not in play: return jsonify({'valid': False, 'error': f'Play {i+1} must have either "tasks" or "roles"'}) return jsonify({'valid': True, 'message': 'Playbook is valid'}) except Exception as e: logging.error(f"Error validating playbook: {e}") return jsonify({'valid': False, 'error': str(e)}) @ansible_web_bp.route('/playbook/delete', methods=['POST']) def delete_playbook(): """Delete a custom playbook""" try: data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 playbook_name = data.get('playbook_name', '').strip() if not playbook_name: return jsonify({'error': 'Playbook name is required'}), 400 # Find the playbook file import re safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', playbook_name) # Try both with and without .yml extension possible_files = [ ansible_service.playbook_dir / f"{safe_filename}.yml", ansible_service.playbook_dir / f"{safe_filename}.yaml", ansible_service.playbook_dir / f"{playbook_name}.yml", ansible_service.playbook_dir / f"{playbook_name}.yaml" ] playbook_path = None for path in possible_files: if path.exists(): playbook_path = path break if not playbook_path: return jsonify({'error': f'Playbook {playbook_name} not found'}), 404 # Security check - ensure path is within playbooks directory try: playbook_path.resolve().relative_to(ansible_service.playbook_dir.resolve()) except ValueError: return jsonify({'error': 'Invalid playbook path'}), 400 # Delete the file playbook_path.unlink() return jsonify({ 'success': True, 'message': f'Playbook {playbook_name} deleted successfully' }) except Exception as e: logging.error(f"Error deleting playbook: {e}") return jsonify({'error': str(e)}), 500 @ansible_web_bp.route('/failure-reports') def failure_reports(): """View all saved execution failure reports.""" try: with get_db().get_session() as session: reports = session.query(ExecutionFailureReport)\ .order_by(ExecutionFailureReport.saved_at.desc())\ .all() reports_data = [r.to_dict() for r in reports] return render_template('ansible/failure_reports.html', reports=reports_data) except Exception as e: logging.error(f"Error loading failure reports: {e}") flash(f'Error loading failure reports: {e}', 'error') return render_template('ansible/failure_reports.html', reports=[])