599 lines
23 KiB
Python
599 lines
23 KiB
Python
"""
|
|
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
|
|
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
|
|
}
|
|
]
|
|
|
|
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'])
|
|
|
|
return render_template('ansible/execute.html',
|
|
inventory=inventory_data,
|
|
all_inv_hosts=all_inv_hosts,
|
|
preselect_playbook=preselect)
|
|
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='')
|
|
|
|
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()
|
|
|
|
# Add controller IP for callbacks
|
|
extra_vars['ansible_controller_ip'] = request.host
|
|
|
|
# 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
|
|
)
|
|
|
|
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/<execution_id>')
|
|
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('/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('Fallback password cannot be empty.', 'error')
|
|
return redirect(url_for('ansible_web.ssh_setup'))
|
|
|
|
ansible_service.save_settings({'ssh_fallback_password': fallback_password})
|
|
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/<execution_id>')
|
|
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 |