feat: execution failure reports, auto-printer for WMT, UTC timezone fix for all timestamps
This commit is contained in:
@@ -6,7 +6,7 @@ import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app.services.ansible_service import AnsibleService
|
||||
from app.models import Device, AnsibleExecution
|
||||
from app.models import Device, AnsibleExecution, ExecutionFailureReport
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
|
||||
@@ -148,7 +148,12 @@ def list_playbooks():
|
||||
'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({
|
||||
@@ -353,6 +358,88 @@ def test_ssh_connectivity():
|
||||
'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"""
|
||||
@@ -451,4 +538,119 @@ def service_restart_callback():
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
logging.error(f"Error in service restart callback: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
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
|
||||
Reference in New Issue
Block a user