Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
"""
|
||||
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/<execution_id>/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/<int:execution_id>', 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/<int:report_id>', 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
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Enhanced API endpoints for logs with compression and file support
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from app.services.log_service import LogCompressionService
|
||||
from app.services.file_service import FileUploadService
|
||||
from app.models import Device, LogEntry, FileUpload
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
# Create blueprint
|
||||
logs_bp = Blueprint('logs', __name__, url_prefix='/api/logs')
|
||||
|
||||
# Initialize services
|
||||
log_service = LogCompressionService()
|
||||
file_service = FileUploadService()
|
||||
|
||||
@logs_bp.route('/', methods=['POST'])
|
||||
@logs_bp.route('/submit', methods=['POST'])
|
||||
def submit_log():
|
||||
"""
|
||||
Enhanced log submission with compression support
|
||||
|
||||
Expected JSON:
|
||||
{
|
||||
"hostname": "device-01",
|
||||
"device_ip": "192.168.1.100",
|
||||
"nume_masa": "Masa-01",
|
||||
"log_message": "Card detected: ABC123",
|
||||
"severity": "info", # optional: debug, info, warning, error, critical
|
||||
"source_file": "/path/to/logfile.log", # optional
|
||||
"metadata": {} # optional additional metadata
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Validate content type
|
||||
if not request.is_json:
|
||||
return jsonify({
|
||||
'error': 'Content-Type must be application/json',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['hostname', 'device_ip', 'nume_masa', 'log_message']
|
||||
missing_fields = [field for field in required_fields if not data.get(field)]
|
||||
|
||||
if missing_fields:
|
||||
return jsonify({
|
||||
'error': f'Missing required fields: {", ".join(missing_fields)}',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
# Prepare device info
|
||||
device_info = {
|
||||
'hostname': data['hostname'],
|
||||
'device_ip': data['device_ip'],
|
||||
'nume_masa': data['nume_masa'],
|
||||
# Optional – clients can send these to keep device records up to date
|
||||
'device_type': data.get('device_type') or (data.get('metadata') or {}).get('device_type'),
|
||||
'os_version': data.get('os_version') or (data.get('metadata') or {}).get('os_version'),
|
||||
'location': data.get('location') or (data.get('metadata') or {}).get('location'),
|
||||
'mac_address': data.get('mac_address') or (data.get('metadata') or {}).get('mac_address'),
|
||||
}
|
||||
|
||||
# Process log with compression
|
||||
result = log_service.process_log_message(
|
||||
device_info=device_info,
|
||||
message=data['log_message'],
|
||||
severity=data.get('severity', 'info')
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
# Prepare response with compression info
|
||||
response_data = {
|
||||
'success': True,
|
||||
'message': 'Log processed successfully',
|
||||
'log_id': result['log_id'],
|
||||
'device_id': result['device_id'],
|
||||
'compression_info': result['compression']
|
||||
}
|
||||
|
||||
# Add alias info if template was used
|
||||
if result['compression'].get('used_template'):
|
||||
response_data['template_alias'] = result['compression']['template_alias']
|
||||
|
||||
# For clients: suggest using alias in future requests
|
||||
if result['compression'].get('new_template'):
|
||||
response_data['suggestion'] = f"For similar messages, you can use template alias: {result['compression']['template_alias']}"
|
||||
|
||||
return jsonify(response_data), 201
|
||||
else:
|
||||
return jsonify(result), 500
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in submit_log: {e}")
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'success': False
|
||||
}), 500
|
||||
|
||||
@logs_bp.route('/template/<alias>', methods=['POST'])
|
||||
def submit_templated_log():
|
||||
"""
|
||||
Submit log using template alias (smaller payload)
|
||||
|
||||
Expected JSON:
|
||||
{
|
||||
"alias": "CD001",
|
||||
"variables": {"card_id": "ABC123"},
|
||||
"device_info": {
|
||||
"hostname": "device-01",
|
||||
"device_ip": "192.168.1.100",
|
||||
"nume_masa": "Masa-01"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
alias = request.view_args['alias']
|
||||
|
||||
# Validate required fields
|
||||
if not data.get('device_info'):
|
||||
return jsonify({
|
||||
'error': 'device_info is required',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
# Get template message
|
||||
variables = data.get('variables', {})
|
||||
full_message = log_service.get_message_by_alias(alias, variables)
|
||||
|
||||
if not full_message:
|
||||
return jsonify({
|
||||
'error': f'Template alias {alias} not found',
|
||||
'success': False
|
||||
}), 404
|
||||
|
||||
# Process as regular log
|
||||
result = log_service.process_log_message(
|
||||
device_info=data['device_info'],
|
||||
message=full_message,
|
||||
severity=data.get('severity', 'info')
|
||||
)
|
||||
|
||||
return jsonify(result), 201 if result['success'] else 500
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in submit_templated_log: {e}")
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'success': False
|
||||
}), 500
|
||||
|
||||
@logs_bp.route('/file', methods=['POST'])
|
||||
def upload_log_file():
|
||||
"""
|
||||
Upload log file for processing
|
||||
|
||||
Expects multipart/form-data with:
|
||||
- file: log file
|
||||
- device_info: JSON string with device information
|
||||
"""
|
||||
try:
|
||||
# Check if file was uploaded
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
'error': 'No file uploaded',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'error': 'No file selected',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
# Get device info
|
||||
device_info_str = request.form.get('device_info')
|
||||
if not device_info_str:
|
||||
return jsonify({
|
||||
'error': 'device_info is required',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
import json
|
||||
device_info = json.loads(device_info_str)
|
||||
|
||||
# Process file upload
|
||||
result = file_service.process_uploaded_file(file, device_info)
|
||||
|
||||
return jsonify(result), 201 if result['success'] else 500
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in upload_log_file: {e}")
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'success': False
|
||||
}), 500
|
||||
|
||||
@logs_bp.route('/query', methods=['GET'])
|
||||
def query_logs():
|
||||
"""
|
||||
Query logs with filters and pagination
|
||||
|
||||
Query parameters:
|
||||
- device_id: Filter by device ID
|
||||
- hostname: Filter by hostname
|
||||
- severity: Filter by severity level
|
||||
- start_time: Start time (ISO format)
|
||||
- end_time: End time (ISO format)
|
||||
- limit: Number of results (default 100)
|
||||
- offset: Offset for pagination (default 0)
|
||||
- include_template: Include resolved template messages (default true)
|
||||
"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
# Build query
|
||||
query = session.query(LogEntry).join(Device)
|
||||
|
||||
# Apply filters
|
||||
if request.args.get('device_id'):
|
||||
query = query.filter(LogEntry.device_id == int(request.args.get('device_id')))
|
||||
|
||||
if request.args.get('hostname'):
|
||||
query = query.filter(Device.hostname == request.args.get('hostname'))
|
||||
|
||||
if request.args.get('severity'):
|
||||
query = query.filter(LogEntry.severity == request.args.get('severity'))
|
||||
|
||||
if request.args.get('start_time'):
|
||||
start_time = datetime.fromisoformat(request.args.get('start_time'))
|
||||
query = query.filter(LogEntry.timestamp >= start_time)
|
||||
|
||||
if request.args.get('end_time'):
|
||||
end_time = datetime.fromisoformat(request.args.get('end_time'))
|
||||
query = query.filter(LogEntry.timestamp <= end_time)
|
||||
|
||||
# Pagination
|
||||
limit = min(int(request.args.get('limit', 100)), 1000) # Max 1000
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
# Order by timestamp descending
|
||||
query = query.order_by(LogEntry.timestamp.desc())
|
||||
|
||||
# Get total count
|
||||
total_count = query.count()
|
||||
|
||||
# Apply pagination
|
||||
logs = query.limit(limit).offset(offset).all()
|
||||
|
||||
# Format response
|
||||
include_template = request.args.get('include_template', 'true').lower() == 'true'
|
||||
log_data = []
|
||||
|
||||
for log in logs:
|
||||
log_item = {
|
||||
'id': log.id,
|
||||
'device': {
|
||||
'id': log.device.id,
|
||||
'hostname': log.device.hostname,
|
||||
'device_ip': log.device.device_ip,
|
||||
'nume_masa': log.device.nume_masa
|
||||
},
|
||||
'timestamp': log.timestamp.isoformat(),
|
||||
'severity': log.severity
|
||||
}
|
||||
|
||||
if include_template and log.template:
|
||||
log_item['message'] = log.resolved_message
|
||||
log_item['template_alias'] = log.template.alias
|
||||
log_item['template_category'] = log.template.category
|
||||
else:
|
||||
log_item['message'] = log.full_message or log.resolved_message
|
||||
|
||||
log_data.append(log_item)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'logs': log_data,
|
||||
'pagination': {
|
||||
'total_count': total_count,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'has_more': offset + limit < total_count
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in query_logs: {e}")
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'success': False
|
||||
}), 500
|
||||
|
||||
@logs_bp.route('/stats', methods=['GET'])
|
||||
def get_log_stats():
|
||||
"""Get logging and compression statistics"""
|
||||
try:
|
||||
# Get compression stats
|
||||
compression_stats = log_service.get_compression_stats()
|
||||
|
||||
# Get additional stats
|
||||
with get_db().get_session() as session:
|
||||
# Device stats
|
||||
active_devices = session.query(Device).filter_by(status='active').count()
|
||||
total_devices = session.query(Device).count()
|
||||
|
||||
# Recent activity
|
||||
from datetime import datetime, timedelta
|
||||
last_hour = datetime.utcnow() - timedelta(hours=1)
|
||||
recent_logs = session.query(LogEntry).filter(
|
||||
LogEntry.timestamp >= last_hour
|
||||
).count()
|
||||
|
||||
stats = {
|
||||
'success': True,
|
||||
'compression': compression_stats,
|
||||
'devices': {
|
||||
'active': active_devices,
|
||||
'total': total_devices
|
||||
},
|
||||
'activity': {
|
||||
'logs_last_hour': recent_logs
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify(stats)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in get_log_stats: {e}")
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'success': False
|
||||
}), 500
|
||||
|
||||
@logs_bp.route('/templates', methods=['GET'])
|
||||
def get_templates():
|
||||
"""Get available message templates and aliases"""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
from app.models import MessageTemplate
|
||||
|
||||
templates = session.query(MessageTemplate).order_by(
|
||||
MessageTemplate.usage_count.desc()
|
||||
).all()
|
||||
|
||||
template_data = [{
|
||||
'alias': template.alias,
|
||||
'category': template.category,
|
||||
'template_text': template.template_text,
|
||||
'usage_count': template.usage_count,
|
||||
'created_at': template.created_at.isoformat()
|
||||
} for template in templates]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'templates': template_data,
|
||||
'total_count': len(template_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in get_templates: {e}")
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'success': False
|
||||
}), 500
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
WMT (Workstation Management Terminal) configuration API
|
||||
Handles config distribution and device update requests from WMT clients.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime
|
||||
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
wmt_api_bp = Blueprint('wmt_api', __name__, url_prefix='/api/wmt')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_or_create_global_config(session):
|
||||
"""Return the single WMTGlobalConfig row, creating it with defaults if absent."""
|
||||
cfg = session.query(WMTGlobalConfig).first()
|
||||
if cfg is None:
|
||||
cfg = WMTGlobalConfig()
|
||||
session.add(cfg)
|
||||
session.flush()
|
||||
return cfg
|
||||
|
||||
|
||||
def _latest_config_ts(session, mac_address):
|
||||
"""Return timestamps for global config and this device's admin-reviewed info."""
|
||||
global_cfg = session.query(WMTGlobalConfig).first()
|
||||
global_ts = global_cfg.updated_at if global_cfg and global_cfg.updated_at else datetime(1970, 1, 1)
|
||||
|
||||
device = session.query(Device).filter_by(mac_address=mac_address).first()
|
||||
# Use info_reviewed_at as the authoritative device-level timestamp
|
||||
device_ts = device.info_reviewed_at if device and device.info_reviewed_at else datetime(1970, 1, 1)
|
||||
|
||||
latest = max(global_ts, device_ts)
|
||||
return global_ts, device_ts, latest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@wmt_api_bp.route('/config/timestamp', methods=['GET'])
|
||||
def get_config_timestamp():
|
||||
"""
|
||||
Returns the last-modified timestamps for global config and this device's config.
|
||||
Query param: mac=<mac_address>
|
||||
|
||||
Response:
|
||||
{
|
||||
"global_updated_at": "2026-04-22T10:00:00",
|
||||
"device_updated_at": "2026-04-22T09:00:00", // null if device unknown
|
||||
"latest_updated_at": "2026-04-22T10:00:00"
|
||||
}
|
||||
"""
|
||||
mac = request.args.get('mac', '').strip().lower()
|
||||
if not mac:
|
||||
return jsonify({'error': 'mac query parameter is required'}), 400
|
||||
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
global_ts, device_info_reviewed_ts, latest = _latest_config_ts(session, mac)
|
||||
|
||||
return jsonify({
|
||||
'global_updated_at': global_ts.isoformat() if global_ts != datetime(1970, 1, 1) else None,
|
||||
'device_info_reviewed_at': device_info_reviewed_ts.isoformat() if device_info_reviewed_ts != datetime(1970, 1, 1) else None,
|
||||
'latest_updated_at': latest.isoformat(),
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting WMT config timestamp: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wmt_api_bp.route('/config/<mac_address>', methods=['GET'])
|
||||
def get_device_config(mac_address):
|
||||
"""
|
||||
Returns merged config (global settings + device-specific) for a given MAC.
|
||||
Used by WMT client to pull updated config at startup.
|
||||
|
||||
Response: merged dict consumable by the WMT config.txt writer.
|
||||
"""
|
||||
mac = mac_address.strip().lower()
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
global_cfg = _get_or_create_global_config(session)
|
||||
device = session.query(Device).filter_by(mac_address=mac).first()
|
||||
|
||||
# Update last_seen if device is known
|
||||
if device:
|
||||
device.last_seen = datetime.utcnow()
|
||||
|
||||
_, device_ts, latest_ts = _latest_config_ts(session, mac)
|
||||
|
||||
payload = {
|
||||
# Global settings
|
||||
'chrome_url': global_cfg.chrome_url,
|
||||
'chrome_local_url': global_cfg.chrome_local_url or '',
|
||||
'chrome_insecure_origin': global_cfg.chrome_insecure_origin,
|
||||
'card_api_base_url': global_cfg.card_api_base_url,
|
||||
'server_log_url': global_cfg.server_log_url,
|
||||
'internet_check_host': global_cfg.internet_check_host,
|
||||
'update_host': global_cfg.update_host,
|
||||
'update_user': global_cfg.update_user,
|
||||
# Device-specific settings (empty string if unknown)
|
||||
'device_name': device.device_name if device else '',
|
||||
'hostname': device.hostname if device else '',
|
||||
'device_ip': device.device_ip if device else '',
|
||||
'location': device.location if device else '',
|
||||
'card_presence': device.card_presence if device else 'enable',
|
||||
# Admin-review timestamp for device info (client stores in [device] section)
|
||||
'info_reviewed_at': device.info_reviewed_at.isoformat() if (device and device.info_reviewed_at) else '1970-01-01T00:00:00',
|
||||
# Sync metadata
|
||||
'config_updated_at': latest_ts.isoformat(),
|
||||
}
|
||||
return jsonify(payload), 200
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching WMT config for {mac}: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wmt_api_bp.route('/config/update_request', methods=['POST'])
|
||||
def submit_update_request():
|
||||
"""
|
||||
WMT client sends current device info as an update request for admin approval.
|
||||
|
||||
Expected JSON:
|
||||
{
|
||||
"mac_address": "b8:27:eb:aa:bb:cc",
|
||||
"device_name": "Masa-01",
|
||||
"hostname": "rpi-masa01",
|
||||
"device_ip": "192.168.1.100",
|
||||
"client_config_mtime": "2026-04-22T09:30:00" // optional
|
||||
}
|
||||
"""
|
||||
if not request.is_json:
|
||||
return jsonify({'error': 'Content-Type must be application/json'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
mac = (data.get('mac_address') or '').strip().lower()
|
||||
if not mac:
|
||||
return jsonify({'error': 'mac_address is required'}), 400
|
||||
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(mac_address=mac).first()
|
||||
|
||||
req = WMTUpdateRequest(
|
||||
mac_address=mac,
|
||||
device_id=device.id if device else None,
|
||||
proposed_device_name=data.get('device_name'),
|
||||
proposed_hostname=data.get('hostname'),
|
||||
proposed_device_ip=data.get('device_ip'),
|
||||
client_config_mtime=data.get('client_config_mtime'),
|
||||
submitted_at=datetime.utcnow(),
|
||||
status='pending',
|
||||
)
|
||||
session.add(req)
|
||||
|
||||
# Update device last_seen
|
||||
if device:
|
||||
device.last_seen = datetime.utcnow()
|
||||
# card_presence is a device capability flag – update directly (no approval needed)
|
||||
if data.get('card_presence') in ('enable', 'disable'):
|
||||
device.card_presence = data['card_presence']
|
||||
|
||||
logger.info(f'WMT update request received from {mac}')
|
||||
return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving WMT update request from {mac}: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user