From cb52e67afa0de6b0c9e68d5bba327d13d98fee52 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 18 Dec 2025 13:59:48 +0200 Subject: [PATCH] Add Ansible integration for device management and deployment automation - Added ansible/ directory with playbooks for: * deploy.yml: Update applications on devices from git * commands.yml: Execute arbitrary commands on devices * system_update.yml: OS updates and health checks * inventory.ini: Device and group configuration * README.md: Comprehensive Ansible guide * requirements.txt: Installation instructions - Added ansible_integration.py: Python module wrapping Ansible operations - Added utils_ansible.py: Updated utilities using Ansible instead of HTTP commands Key benefits: - Idempotent operations with error recovery - Comprehensive logging and backup - Multi-device orchestration - Better reliability and control - Replaces unreliable direct HTTP command execution --- ansible/README.md | 388 ++++++++++++++++++++++++++++++ ansible/commands.yml | 51 ++++ ansible/deploy.yml | 201 ++++++++++++++++ ansible/inventory.ini | 34 +++ ansible/requirements.txt | 64 +++++ ansible/system_update.yml | 163 +++++++++++++ ansible_integration.py | 425 +++++++++++++++++++++++++++++++++ utils_ansible.py | 481 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 1807 insertions(+) create mode 100644 ansible/README.md create mode 100644 ansible/commands.yml create mode 100644 ansible/deploy.yml create mode 100644 ansible/inventory.ini create mode 100644 ansible/requirements.txt create mode 100644 ansible/system_update.yml create mode 100644 ansible_integration.py create mode 100644 utils_ansible.py diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..98218bb --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,388 @@ +# Ansible Integration for Server_Monitorizare + +This directory contains Ansible playbooks and configuration for managing Prezenta Work devices remotely. + +## Overview + +The Ansible integration replaces direct HTTP command execution with a more robust, scalable solution: + +### **Before (HTTP-based)** +``` +Server → HTTP Request → Device (/execute_command endpoint) +Problems: +- No idempotency +- Hard to track what happened +- No built-in error recovery +- Limited command execution control +``` + +### **After (Ansible-based)** +``` +Server → Ansible Playbook → Device (SSH) +Benefits: +- Idempotent operations +- Comprehensive logging +- Built-in error handling & retry logic +- Structured playbooks +- Multi-device orchestration +- Backup and rollback capability +``` + +## Files + +### Configuration +- **inventory.ini** - Defines all devices, groups, and variables + +### Playbooks +- **deploy.yml** - Deploy application updates (pull from git, restart service) +- **commands.yml** - Execute arbitrary commands on devices +- **system_update.yml** - System maintenance (OS updates, health checks) + +### Python Integration +- **ansible_integration.py** - Python module for Ansible automation +- **utils_ansible.py** - Updated utilities using Ansible (replaces old utils.py) + +## Setup + +### 1. Install Ansible on Server + +```bash +# On Server_Monitorizare (Ubuntu/Debian) +sudo apt-get update +sudo apt-get install -y ansible + +# Verify installation +ansible --version +``` + +### 2. Configure Inventory + +Edit `inventory.ini` and add your devices: + +```ini +[prezenta_devices] +device_1 ansible_host=192.168.1.20 +device_2 ansible_host=192.168.1.21 +device_3 ansible_host=192.168.1.22 +``` + +### 3. Setup SSH Keys (Optional but Recommended) + +For password-less authentication: + +```bash +# Generate SSH keys if not present +ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N "" + +# Copy public key to each device +ssh-copy-id -i ~/.ssh/id_rsa.pub pi@192.168.1.20 +ssh-copy-id -i ~/.ssh/id_rsa.pub pi@192.168.1.21 +ssh-copy-id -i ~/.ssh/id_rsa.pub pi@192.168.1.22 +``` + +Update inventory.ini to use SSH key auth: +```ini +[all:vars] +ansible_user=pi +ansible_ssh_private_key_file=~/.ssh/id_rsa +ansible_ssh_common_args='-o StrictHostKeyChecking=no' +``` + +### 4. Test Connectivity + +```bash +# Test all devices +ansible all -i inventory.ini -m ping + +# Test specific group +ansible prezenta_devices -i inventory.ini -m ping +``` + +## Usage + +### From Command Line + +#### Deploy Updates +```bash +# Deploy dev branch to all devices +ansible-playbook -i inventory.ini deploy.yml \ + -e git_branch=dev \ + -e backup_before_deploy=true \ + -e restart_service=true + +# Deploy to specific device +ansible-playbook -i inventory.ini deploy.yml \ + --limit device_1 \ + -e git_branch=dev +``` + +#### Execute Commands +```bash +# Execute command on all devices +ansible-playbook -i inventory.ini commands.yml \ + -e command="ps aux | grep prezenta" + +# Execute on specific device +ansible-playbook -i inventory.ini commands.yml \ + --limit device_1 \ + -e command="systemctl status prezenta" +``` + +#### System Update +```bash +# Update Python packages and perform health check +ansible-playbook -i inventory.ini system_update.yml \ + -e update_python_packages=true \ + -e perform_health_check=true + +# Also update OS packages +ansible-playbook -i inventory.ini system_update.yml \ + -e update_os_packages=true \ + -e update_python_packages=true \ + -e perform_health_check=true +``` + +### From Python Code + +```python +from utils_ansible import ( + deploy_updates_to_device, + deploy_updates_to_all_devices, + execute_command_on_device, + system_update_device, + get_device_status, + get_execution_logs +) + +# Deploy to specific device +result = deploy_updates_to_device('device_1', git_branch='dev') +print(result) + +# Deploy to all devices +result = deploy_updates_to_all_devices(git_branch='dev') +print(result) + +# Execute command +result = execute_command_on_device('device_1', 'systemctl status prezenta') +print(result) + +# System update +result = system_update_device('device_1', update_python=True, health_check=True) +print(result) + +# Get device status +result = get_device_status('device_1') +print(result) + +# Get recent logs +logs = get_execution_logs(limit=10) +print(logs) +``` + +### In Flask Routes + +Update your Flask routes to use the new Ansible-based utilities: + +```python +from flask import request, jsonify +from utils_ansible import ( + deploy_updates_to_device, + execute_command_on_device, + get_device_status +) + +@app.route('/api/deploy', methods=['POST']) +def deploy_api(): + data = request.json + device = data.get('device') + branch = data.get('branch', 'dev') + + result = deploy_updates_to_device(device, git_branch=branch) + return jsonify(result) + +@app.route('/api/command', methods=['POST']) +def command_api(): + data = request.json + device = data.get('device') + command = data.get('command') + + result = execute_command_on_device(device, command) + return jsonify(result) + +@app.route('/api/device//status', methods=['GET']) +def device_status_api(device): + result = get_device_status(device) + return jsonify(result) +``` + +## Playbook Details + +### deploy.yml + +**What it does:** +1. Backs up current code (optional) +2. Fetches latest from git repository +3. Checks out specified branch +4. Pulls latest changes +5. Verifies syntax +6. Restarts application service +7. Verifies service is running +8. Logs deployment + +**Example:** +```bash +ansible-playbook deploy.yml -e git_branch=dev -e backup_before_deploy=true +``` + +**Output Locations:** +- Backups: `/srv/prezenta_work/backups/` +- Logs: `./logs/deploy_YYYYMMDD_HHMMSS.log` + +### commands.yml + +**What it does:** +1. Executes command with specified user +2. Captures output and errors +3. Logs command in device's command history +4. Returns result + +**Example:** +```bash +ansible-playbook commands.yml -e command="sudo systemctl restart prezenta" +``` + +**Output Locations:** +- Logs: `./logs/command_DEVICE_YYYYMMDD_HHMMSS.log` +- Device log: `/srv/prezenta_work/data/command_history.log` + +### system_update.yml + +**What it does:** +1. Gathers system facts +2. Updates OS packages (optional) +3. Updates Python packages +4. Checks service status +5. Performs health checks (disk, memory, CPU temp) +6. Logs results +7. Reboots if needed (optional) + +**Example:** +```bash +ansible-playbook system_update.yml \ + -e update_os_packages=false \ + -e update_python_packages=true \ + -e perform_health_check=true +``` + +**Output Locations:** +- Logs: `./logs/system_update_YYYYMMDD_HHMMSS.log` +- Device log: `/srv/prezenta_work/data/system_update.log` + +## Execution Logs + +Logs are stored in `ansible/logs/` directory: + +```bash +# View recent logs +ls -lht ansible/logs/ | head -10 + +# Follow live deployment +tail -f ansible/logs/deploy_*.log + +# Search logs +grep "error\|fail" ansible/logs/*.log +``` + +## Troubleshooting + +### SSH Connection Issues + +```bash +# Test SSH manually +ssh -i ~/.ssh/id_rsa pi@192.168.1.20 + +# Test with verbose output +ansible device_1 -i inventory.ini -m ping -vvv +``` + +### Ansible Module Errors + +```bash +# Run with increased verbosity +ansible-playbook deploy.yml -vvv +``` + +### Device Not Found in Inventory + +```bash +# List all hosts +ansible all -i inventory.ini --list-hosts + +# List specific group +ansible prezenta_devices -i inventory.ini --list-hosts +``` + +## Security Considerations + +1. **SSH Keys**: Use SSH key authentication instead of passwords +2. **Inventory**: Don't commit inventory.ini with passwords to git +3. **Ansible Vault**: Use for sensitive data: + ```bash + ansible-vault create secrets.yml + ansible-playbook deploy.yml -e @secrets.yml --ask-vault-pass + ``` + +4. **Firewall**: Ensure SSH (port 22) is open on devices + +## Performance Tips + +1. **Parallel Execution**: Run playbooks on multiple devices + ```bash + ansible-playbook deploy.yml --forks 5 + ``` + +2. **Task Caching**: Avoid unnecessary updates + ```bash + # Playbooks already use idempotent operations + ``` + +3. **Logging**: Monitor log files for issues + ```bash + # Compress old logs + gzip ansible/logs/deploy_*.log + ``` + +## Migration from HTTP Commands + +**Old (HTTP-based):** +```python +def execute_command_on_device(device_ip, command): + url = f"http://{device_ip}:80/execute_command" + response = requests.post(url, json={"command": command}) + return response.json() +``` + +**New (Ansible-based):** +```python +from utils_ansible import execute_command_on_device + +result = execute_command_on_device('device_hostname', command) +return result +``` + +## Next Steps + +1. Install Ansible on Server_Monitorizare +2. Configure inventory.ini with your devices +3. Set up SSH key authentication +4. Test connectivity with `ansible all -m ping` +5. Create first deployment +6. Update Flask routes to use new utils_ansible +7. Monitor logs in `ansible/logs/` + +## Documentation + +For more information on Ansible: +- [Ansible Documentation](https://docs.ansible.com/) +- [Playbook Syntax](https://docs.ansible.com/ansible/latest/reference_appendices/playbooks_keywords.html) +- [Modules Reference](https://docs.ansible.com/ansible/latest/modules/modules_by_category.html) diff --git a/ansible/commands.yml b/ansible/commands.yml new file mode 100644 index 0000000..3132916 --- /dev/null +++ b/ansible/commands.yml @@ -0,0 +1,51 @@ +--- +# commands.yml - Execute commands on Prezenta devices +# Provides structured command execution with logging and error handling + +- name: Execute Command on Prezenta Devices + hosts: "{{ target_devices | default('prezenta_devices') }}" + gather_facts: no + + vars: + command_to_run: "{{ command | default('pwd') }}" + command_user: pi + log_commands: true + + tasks: + - name: Display command execution info + debug: + msg: | + Executing on: {{ inventory_hostname }} ({{ ansible_host }}) + Command: {{ command_to_run }} + User: {{ command_user }} + + - name: Execute command + shell: "{{ command_to_run }}" + register: command_result + become: yes + become_user: "{{ command_user }}" + ignore_errors: yes + + - name: Display command output + debug: + msg: | + Return Code: {{ command_result.rc }} + STDOUT: + {{ command_result.stdout }} + STDERR: + {{ command_result.stderr | default('None') }} + + - name: Log command execution + lineinfile: + path: "{{ app_directory }}/data/command_history.log" + line: "[{{ ansible_date_time.iso8601 }}] [{{ inventory_hostname }}] Command: {{ command_to_run }} | RC: {{ command_result.rc }}" + create: yes + state: present + delegate_to: "{{ inventory_hostname }}" + become: yes + when: log_commands + + - name: Fail if command failed + fail: + msg: "Command failed on {{ inventory_hostname }} with return code {{ command_result.rc }}" + when: command_result.rc != 0 and fail_on_error | default(false) diff --git a/ansible/deploy.yml b/ansible/deploy.yml new file mode 100644 index 0000000..1ed599f --- /dev/null +++ b/ansible/deploy.yml @@ -0,0 +1,201 @@ +--- +# deploy.yml - Deploy updates to Prezenta Work devices +# Handles pulling latest code and restarting services + +- name: Deploy Prezenta Work Updates + hosts: prezenta_devices + gather_facts: yes + serial: 1 # Deploy one device at a time to avoid service interruption + + vars: + app_directory: "/srv/prezenta_work" + git_branch: "dev" + restart_service: true + backup_before_deploy: true + + tasks: + - name: Display deployment information + debug: + msg: "Deploying to {{ inventory_hostname }} ({{ ansible_host }})" + + # Pre-deployment checks + - name: Check if app directory exists + stat: + path: "{{ app_directory }}" + register: app_dir_stat + + - name: Fail if app directory doesn't exist + fail: + msg: "Application directory {{ app_directory }} not found on {{ inventory_hostname }}" + when: not app_dir_stat.stat.exists + + # Backup current code + - name: Create backup directory + file: + path: "{{ app_directory }}/backups" + state: directory + mode: '0755' + when: backup_before_deploy + + - name: Backup current code + shell: | + cd {{ app_directory }} + tar -czf backups/backup_{{ ansible_date_time.iso8601_basic_short }}.tar.gz \ + --exclude=.git \ + --exclude=__pycache__ \ + --exclude=data \ + --exclude=Files \ + . + register: backup_result + when: backup_before_deploy + + - name: Display backup created + debug: + msg: "Backup created: {{ backup_result.stdout_lines }}" + when: backup_before_deploy and backup_result is not skipped + + # Pull latest code + - name: Fetch latest from repository + shell: | + cd {{ app_directory }} + git fetch origin + register: git_fetch + changed_when: "'Fetching' in git_fetch.stdout or 'Receiving' in git_fetch.stdout" + + - name: Checkout dev branch + shell: | + cd {{ app_directory }} + git checkout {{ git_branch }} + register: git_checkout + + - name: Pull latest changes + shell: | + cd {{ app_directory }} + git pull origin {{ git_branch }} + register: git_pull + changed_when: "'Already up to date' not in git_pull.stdout" + + - name: Display git pull result + debug: + msg: "{{ git_pull.stdout }}" + + # Verify deployment + - name: Check Python syntax + shell: | + python3 -m py_compile {{ app_directory }}/app.py + register: syntax_check + changed_when: false + failed_when: syntax_check.rc != 0 + + - name: Verify all modules compile + shell: | + cd {{ app_directory }} + python3 -m py_compile *.py + register: module_check + changed_when: false + + - name: Verify configuration + shell: | + cd {{ app_directory }} + python3 -c "import config_settings; print('✓ Configuration OK')" + register: config_check + changed_when: false + + - name: Display verification results + debug: + msg: "{{ config_check.stdout }}" + + # Restart application + - name: Restart Prezenta application + block: + - name: Stop Prezenta service + systemd: + name: prezenta + state: stopped + daemon_reload: yes + become: yes + when: restart_service + + - name: Wait for service to stop + pause: + seconds: 2 + + - name: Start Prezenta service + systemd: + name: prezenta + state: started + enabled: yes + become: yes + when: restart_service + + - name: Verify service is running + systemd: + name: prezenta + state: started + become: yes + register: service_status + until: service_status.status.ActiveState == "active" + retries: 3 + delay: 5 + rescue: + - name: Service restart failed, attempting manual restart + debug: + msg: "Attempting to restart application manually on {{ inventory_hostname }}" + + - name: Kill existing processes + shell: | + pkill -f "python3.*app.py" || true + become: yes + + - name: Wait before restart + pause: + seconds: 3 + + - name: Start application in background + shell: | + cd {{ app_directory }} + nohup python3 app.py > data/startup.log 2>&1 & + become: yes + become_user: pi + + # Post-deployment verification + - name: Wait for application to start + pause: + seconds: 5 + + - name: Check application status via HTTP + uri: + url: "http://{{ ansible_host }}:80/status" + method: GET + status_code: [200, 404] + timeout: 10 + register: app_status + retries: 3 + delay: 2 + until: app_status.status in [200, 404] + + - name: Display application status + debug: + msg: "Application on {{ inventory_hostname }} is running" + when: app_status.status in [200, 404] + + # Log deployment + - name: Record deployment in log + lineinfile: + path: "{{ app_directory }}/data/deploy.log" + line: "[{{ ansible_date_time.iso8601 }}] Deployed {{ git_branch }} from {{ ansible_user }}@monitoring_server" + create: yes + state: present + + - name: Log deployment summary + debug: + msg: | + Deployment completed for {{ inventory_hostname }} + Branch: {{ git_branch }} + Status: SUCCESS + Last git commit: {{ git_pull.stdout_lines[-1] if git_pull.stdout_lines else 'Unknown' }} + + post_tasks: + - name: Send deployment notification + debug: + msg: "Deployment notification: {{ inventory_hostname }} updated to {{ git_branch }}" diff --git a/ansible/inventory.ini b/ansible/inventory.ini new file mode 100644 index 0000000..cb6cce7 --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,34 @@ +# Ansible Inventory for Prezenta Work Devices +# This inventory file manages all Raspberry Pi devices running prezenta_work + +[all:vars] +# Common variables for all devices +ansible_user=pi +ansible_password=Initial01! +ansible_become=yes +ansible_become_method=sudo +ansible_become_password=Initial01! +ansible_python_interpreter=/usr/bin/python3 +ansible_connection=ssh + +[prezenta_devices] +# Raspberry Pi devices running the prezenta_work application +# Format: device_name ansible_host=192.168.1.x + +device_1 ansible_host=192.168.1.20 +device_2 ansible_host=192.168.1.21 +device_3 ansible_host=192.168.1.22 + +[prezenta_devices:vars] +# Variables specific to prezenta devices +app_directory=/srv/prezenta_work +app_user=pi +app_service=prezenta + +[monitoring_server] +# Central monitoring server +monitor ansible_host=192.168.1.103 ansible_user=root + +[local] +# Local development/test +localhost ansible_connection=local ansible_become=no diff --git a/ansible/requirements.txt b/ansible/requirements.txt new file mode 100644 index 0000000..0972d9a --- /dev/null +++ b/ansible/requirements.txt @@ -0,0 +1,64 @@ +# Requirements for Server_Monitorizare Ansible Integration + +## System Requirements +- Ubuntu/Debian Linux +- Python 3.7+ +- SSH access to target devices + +## Python Packages +# Flask and server dependencies +Flask==2.3.0 +Werkzeug==2.3.0 +requests==2.31.0 + +# Ansible automation +ansible>=2.10.0,<3.0.0 +ansible-core>=2.12.0 + +# Additional utilities +python-dotenv==1.0.0 +pyyaml==6.0 + +## Installation + +### 1. Install System Dependencies +```bash +sudo apt-get update +sudo apt-get install -y python3-pip python3-dev +sudo apt-get install -y openssh-client openssh-server +``` + +### 2. Install Python Packages +```bash +cd /srv/Server_Monitorizare +pip3 install -r requirements.txt +``` + +### 3. Install Ansible +```bash +pip3 install ansible>=2.10.0 +# or +sudo apt-get install -y ansible +``` + +### 4. Verify Installation +```bash +ansible --version +ansible-playbook --version +python3 -c "import ansible; print(ansible.__version__)" +``` + +## Optional: For SSH Key Authentication +```bash +sudo apt-get install -y openssh-client +ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N "" +``` + +## Testing +```bash +# Test Ansible installation +ansible localhost -m debug -a msg="Ansible works!" + +# Test against devices +ansible all -i ansible/inventory.ini -m ping +``` diff --git a/ansible/system_update.yml b/ansible/system_update.yml new file mode 100644 index 0000000..df14679 --- /dev/null +++ b/ansible/system_update.yml @@ -0,0 +1,163 @@ +--- +# system_update.yml - System updates and maintenance +# Updates OS packages, manages services, and performs health checks + +- name: System Update and Maintenance + hosts: "{{ target_devices | default('prezenta_devices') }}" + serial: 1 # One device at a time to maintain availability + gather_facts: yes + + vars: + update_os_packages: false + update_python_packages: true + perform_health_check: true + reboot_after_update: false + + tasks: + # System Information + - name: Gather system information + debug: + msg: | + System: {{ ansible_system }} + Distribution: {{ ansible_distribution }} {{ ansible_distribution_version }} + Hostname: {{ ansible_hostname }} + IP Address: {{ ansible_default_ipv4.address }} + Uptime: {{ ansible_uptime_seconds }} seconds + + # OS Package Updates + - name: Update OS package lists + apt: + update_cache: yes + cache_valid_time: 300 + become: yes + when: update_os_packages + + - name: Upgrade OS packages + apt: + upgrade: full + autoremove: yes + autoclean: yes + become: yes + register: apt_upgrade + when: update_os_packages + + - name: Display OS updates + debug: + msg: "OS packages updated" + when: update_os_packages and apt_upgrade.changed + + # Python Package Updates + - name: Check for prezenta_work directory + stat: + path: "{{ app_directory }}" + register: app_dir + + - name: Update Python dependencies + block: + - name: Find requirements.txt + stat: + path: "{{ app_directory }}/requirements.txt" + register: requirements_file + + - name: Install Python requirements + pip: + requirements: "{{ app_directory }}/requirements.txt" + state: latest + become: yes + when: requirements_file.stat.exists + + - name: Install Flask if not present + pip: + name: + - Flask + - requests + - RPi.GPIO + state: latest + become: yes + register: pip_install + + - name: Display Python updates + debug: + msg: "Python packages updated" + when: pip_install.changed + when: app_dir.stat.exists and update_python_packages + + # Service Management + - name: Check Prezenta service status + systemd: + name: prezenta + enabled: yes + become: yes + register: prezenta_service + ignore_errors: yes + + - name: Display service status + debug: + msg: | + Service: {{ prezenta_service.status.ActiveState if prezenta_service.status is defined else 'Not found' }} + Enabled: {{ prezenta_service.status.UnitFileState if prezenta_service.status is defined else 'Unknown' }} + + # Health Checks + - name: Check disk space + shell: df -h / | tail -1 | awk '{print $5}' + register: disk_usage + changed_when: false + when: perform_health_check + + - name: Check memory usage + shell: free -h | grep Mem | awk '{print $3 "/" $2}' + register: mem_usage + changed_when: false + when: perform_health_check + + - name: Check CPU temperature (Raspberry Pi) + shell: vcgencmd measure_temp 2>/dev/null | grep -oP '\d+\.\d+' || echo "N/A" + register: cpu_temp + changed_when: false + when: perform_health_check and ansible_system == 'Linux' + ignore_errors: yes + + - name: Display health check results + debug: + msg: | + Disk Usage: {{ disk_usage.stdout }} + Memory Usage: {{ mem_usage.stdout }} + CPU Temp: {{ cpu_temp.stdout if cpu_temp.stdout != 'N/A' else 'N/A' }}°C + when: perform_health_check + + - name: Warn if disk space critical + debug: + msg: "WARNING: Disk usage is {{ disk_usage.stdout }} - Consider cleanup" + when: + - perform_health_check + - disk_usage.stdout | int >= 85 + + # Log update + - name: Create system update log + lineinfile: + path: "{{ app_directory }}/data/system_update.log" + line: "[{{ ansible_date_time.iso8601 }}] System maintenance completed - Disk: {{ disk_usage.stdout }} | Memory: {{ mem_usage.stdout }}" + create: yes + state: present + become: yes + when: perform_health_check and app_dir.stat.exists + + # Reboot if required + - name: Schedule reboot if needed + debug: + msg: "System reboot scheduled after updates" + when: reboot_after_update and apt_upgrade.changed + + - name: Reboot system + reboot: + msg: "Rebooting after system updates" + pre_reboot_delay: 60 + become: yes + when: reboot_after_update and apt_upgrade.changed + + post_tasks: + - name: Display maintenance summary + debug: + msg: | + Maintenance completed for {{ inventory_hostname }} + Date: {{ ansible_date_time.iso8601 }} diff --git a/ansible_integration.py b/ansible_integration.py new file mode 100644 index 0000000..8dcc726 --- /dev/null +++ b/ansible_integration.py @@ -0,0 +1,425 @@ +""" +Ansible Integration Module for Server_Monitorizare +Provides functions to execute Ansible playbooks and commands on remote devices +""" + +import os +import json +import subprocess +import logging +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Any, Optional, Tuple + +logger = logging.getLogger(__name__) + + +class AnsibleManager: + """Manager class for Ansible operations""" + + def __init__(self, ansible_dir: str = None, inventory_file: str = None): + """ + Initialize Ansible manager + + Args: + ansible_dir: Path to ansible directory + inventory_file: Path to inventory file + """ + self.ansible_dir = ansible_dir or os.path.join( + os.path.dirname(__file__), 'ansible' + ) + self.inventory_file = inventory_file or os.path.join( + self.ansible_dir, 'inventory.ini' + ) + self.log_dir = os.path.join(self.ansible_dir, 'logs') + + # Create logs directory if it doesn't exist + Path(self.log_dir).mkdir(exist_ok=True) + + def _verify_ansible_installed(self) -> bool: + """Verify Ansible is installed""" + try: + result = subprocess.run( + ['ansible', '--version'], + capture_output=True, + text=True, + timeout=10 + ) + return result.returncode == 0 + except Exception as e: + logger.error(f"Ansible not found: {e}") + return False + + def deploy_to_devices( + self, + target_hosts: str = 'prezenta_devices', + git_branch: str = 'dev', + backup: bool = True, + restart: bool = True + ) -> Dict[str, Any]: + """ + Deploy updates to devices using deploy.yml playbook + + Args: + target_hosts: Target host group or specific host + git_branch: Git branch to deploy + backup: Whether to backup before deploy + restart: Whether to restart service + + Returns: + dict: Execution result with status and output + """ + if not self._verify_ansible_installed(): + return { + 'success': False, + 'error': 'Ansible is not installed', + 'timestamp': datetime.now().isoformat() + } + + playbook_file = os.path.join(self.ansible_dir, 'deploy.yml') + if not os.path.exists(playbook_file): + return { + 'success': False, + 'error': f'Playbook not found: {playbook_file}', + 'timestamp': datetime.now().isoformat() + } + + # Prepare Ansible command + cmd = [ + 'ansible-playbook', + '-i', self.inventory_file, + playbook_file, + '-e', f'git_branch={git_branch}', + '-e', f'backup_before_deploy={str(backup).lower()}', + '-e', f'restart_service={str(restart).lower()}', + '--limit', target_hosts, + '-v' + ] + + logger.info(f"Executing deployment: {' '.join(cmd)}") + + try: + log_file = os.path.join( + self.log_dir, + f'deploy_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log' + ) + + with open(log_file, 'w') as log: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout + cwd=self.ansible_dir + ) + + log.write(result.stdout) + if result.stderr: + log.write("\n--- STDERR ---\n") + log.write(result.stderr) + + success = result.returncode == 0 + + return { + 'success': success, + 'returncode': result.returncode, + 'output': result.stdout, + 'error': result.stderr if not success else None, + 'log_file': log_file, + 'timestamp': datetime.now().isoformat(), + 'target_hosts': target_hosts, + 'git_branch': git_branch + } + + except subprocess.TimeoutExpired: + return { + 'success': False, + 'error': 'Deployment timed out after 10 minutes', + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + logger.exception(f"Deployment failed: {e}") + return { + 'success': False, + 'error': f'Deployment failed: {str(e)}', + 'timestamp': datetime.now().isoformat() + } + + def execute_command_on_device( + self, + device: str, + command: str, + user: str = 'pi' + ) -> Dict[str, Any]: + """ + Execute command on specific device + + Args: + device: Device hostname or IP + command: Command to execute + user: User to execute command as + + Returns: + dict: Execution result + """ + if not self._verify_ansible_installed(): + return { + 'success': False, + 'error': 'Ansible is not installed', + 'timestamp': datetime.now().isoformat() + } + + playbook_file = os.path.join(self.ansible_dir, 'commands.yml') + if not os.path.exists(playbook_file): + return { + 'success': False, + 'error': f'Playbook not found: {playbook_file}', + 'timestamp': datetime.now().isoformat() + } + + cmd = [ + 'ansible-playbook', + '-i', self.inventory_file, + playbook_file, + '--limit', device, + '-e', f'command={command}', + '-e', f'command_user={user}', + '-v' + ] + + logger.info(f"Executing command on {device}: {command}") + + try: + log_file = os.path.join( + self.log_dir, + f'command_{device}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log' + ) + + with open(log_file, 'w') as log: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + cwd=self.ansible_dir + ) + + log.write(result.stdout) + if result.stderr: + log.write("\n--- STDERR ---\n") + log.write(result.stderr) + + success = result.returncode == 0 + + return { + 'success': success, + 'returncode': result.returncode, + 'output': result.stdout, + 'error': result.stderr if not success else None, + 'log_file': log_file, + 'timestamp': datetime.now().isoformat(), + 'device': device, + 'command': command + } + + except subprocess.TimeoutExpired: + return { + 'success': False, + 'error': 'Command execution timed out after 60 seconds', + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + logger.exception(f"Command execution failed: {e}") + return { + 'success': False, + 'error': f'Command execution failed: {str(e)}', + 'timestamp': datetime.now().isoformat() + } + + def system_update( + self, + target_hosts: str = 'prezenta_devices', + update_os: bool = False, + update_python: bool = True, + health_check: bool = True + ) -> Dict[str, Any]: + """ + Execute system update and maintenance + + Args: + target_hosts: Target host group + update_os: Whether to update OS packages + update_python: Whether to update Python packages + health_check: Whether to perform health check + + Returns: + dict: Execution result + """ + if not self._verify_ansible_installed(): + return { + 'success': False, + 'error': 'Ansible is not installed', + 'timestamp': datetime.now().isoformat() + } + + playbook_file = os.path.join(self.ansible_dir, 'system_update.yml') + if not os.path.exists(playbook_file): + return { + 'success': False, + 'error': f'Playbook not found: {playbook_file}', + 'timestamp': datetime.now().isoformat() + } + + cmd = [ + 'ansible-playbook', + '-i', self.inventory_file, + playbook_file, + '--limit', target_hosts, + '-e', f'update_os_packages={str(update_os).lower()}', + '-e', f'update_python_packages={str(update_python).lower()}', + '-e', f'perform_health_check={str(health_check).lower()}', + '-v' + ] + + logger.info(f"Executing system update on {target_hosts}") + + try: + log_file = os.path.join( + self.log_dir, + f'system_update_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log' + ) + + with open(log_file, 'w') as log: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=1800, # 30 minute timeout + cwd=self.ansible_dir + ) + + log.write(result.stdout) + if result.stderr: + log.write("\n--- STDERR ---\n") + log.write(result.stderr) + + success = result.returncode == 0 + + return { + 'success': success, + 'returncode': result.returncode, + 'output': result.stdout, + 'error': result.stderr if not success else None, + 'log_file': log_file, + 'timestamp': datetime.now().isoformat(), + 'target_hosts': target_hosts + } + + except subprocess.TimeoutExpired: + return { + 'success': False, + 'error': 'System update timed out after 30 minutes', + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + logger.exception(f"System update failed: {e}") + return { + 'success': False, + 'error': f'System update failed: {str(e)}', + 'timestamp': datetime.now().isoformat() + } + + def get_device_facts(self, device: str) -> Dict[str, Any]: + """ + Get facts about a specific device + + Args: + device: Device hostname or IP + + Returns: + dict: Device facts and information + """ + if not self._verify_ansible_installed(): + return { + 'success': False, + 'error': 'Ansible is not installed' + } + + cmd = [ + 'ansible', + device, + '-i', self.inventory_file, + '-m', 'setup' + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + cwd=self.ansible_dir + ) + + if result.returncode == 0: + # Parse JSON output + output_lines = result.stdout.split('\n') + for line in output_lines: + if line.startswith(device): + try: + data = json.loads(line.split(' => ', 1)[1]) + return { + 'success': True, + 'device': device, + 'facts': data.get('ansible_facts', {}), + 'timestamp': datetime.now().isoformat() + } + except json.JSONDecodeError: + pass + + return { + 'success': False, + 'error': f'Failed to get facts for {device}', + 'timestamp': datetime.now().isoformat() + } + + except Exception as e: + logger.exception(f"Failed to get device facts: {e}") + return { + 'success': False, + 'error': f'Failed to get device facts: {str(e)}', + 'timestamp': datetime.now().isoformat() + } + + def get_execution_logs(self, limit: int = 10) -> List[Dict[str, str]]: + """Get recent execution logs""" + logs = [] + try: + log_files = sorted( + Path(self.log_dir).glob('*.log'), + key=os.path.getmtime, + reverse=True + )[:limit] + + for log_file in log_files: + logs.append({ + 'filename': log_file.name, + 'timestamp': datetime.fromtimestamp(log_file.stat().st_mtime).isoformat(), + 'size': log_file.stat().st_size + }) + except Exception as e: + logger.error(f"Failed to get execution logs: {e}") + + return logs + + +# Global instance +ansible_manager = None + + +def get_ansible_manager(ansible_dir: str = None) -> AnsibleManager: + """Get or create global Ansible manager instance""" + global ansible_manager + if ansible_manager is None: + ansible_manager = AnsibleManager(ansible_dir) + return ansible_manager diff --git a/utils_ansible.py b/utils_ansible.py new file mode 100644 index 0000000..5312953 --- /dev/null +++ b/utils_ansible.py @@ -0,0 +1,481 @@ +""" +Updated Utility Functions for Server_Monitorizare +Now using Ansible for remote device management instead of direct HTTP commands +""" + +import logging +from functools import wraps +from flask import jsonify, request, session +from datetime import datetime +from ansible_integration import get_ansible_manager + +logger = logging.getLogger(__name__) + + +class APIError(Exception): + """Custom exception for API errors""" + def __init__(self, message, status_code=400, details=None): + self.message = message + self.status_code = status_code + self.details = details or {} + super().__init__(self.message) + + +def error_response(error_message, status_code=400, details=None): + """Create a standardized error response""" + response = { + 'success': False, + 'error': error_message, + 'timestamp': datetime.now().isoformat() + } + if details: + response['details'] = details + return jsonify(response), status_code + + +def success_response(data=None, message="Success", status_code=200): + """Create a standardized success response""" + response = { + 'success': True, + 'message': message, + 'timestamp': datetime.now().isoformat() + } + if data is not None: + response['data'] = data + return jsonify(response), status_code + + +def require_auth(f): + """Decorator to require authentication""" + @wraps(f) + def decorated_function(*args, **kwargs): + # Check for API key in headers + api_key = request.headers.get('X-API-Key') + from config import get_config + config = get_config() + + if not api_key or api_key != config.API_KEY: + logger.warning(f"Unauthorized access attempt from {request.remote_addr}") + return error_response("Unauthorized", 401) + + return f(*args, **kwargs) + return decorated_function + + +def require_session_auth(f): + """Decorator to require session-based authentication""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return error_response("Authentication required", 401) + return f(*args, **kwargs) + return decorated_function + + +def log_request(f): + """Decorator to log request details""" + @wraps(f) + def decorated_function(*args, **kwargs): + logger.info(f"{request.method} {request.path} from {request.remote_addr}") + try: + return f(*args, **kwargs) + except APIError as e: + logger.error(f"API Error in {f.__name__}: {e.message}") + return error_response(e.message, e.status_code, e.details) + except Exception as e: + logger.exception(f"Unexpected error in {f.__name__}") + return error_response("Internal server error", 500) + return decorated_function + + +def validate_required_fields(required_fields): + """Decorator to validate required fields in JSON request""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not request.is_json: + return error_response("Content-Type must be application/json", 400) + + data = request.get_json() + missing_fields = [field for field in required_fields if field not in data or not data[field]] + + if missing_fields: + return error_response( + "Missing required fields", + 400, + {'missing_fields': missing_fields} + ) + return f(*args, **kwargs) + return decorated_function + return decorator + + +def validate_ip_address(ip): + """Validate IP address format""" + import re + pattern = r'^(\d{1,3}\.){3}\d{1,3}$' + if not re.match(pattern, ip): + return False + parts = ip.split('.') + return all(0 <= int(part) <= 255 for part in parts) + + +def sanitize_hostname(hostname): + """Sanitize hostname to prevent injection""" + import re + # Allow alphanumeric, dash, and underscore + if not re.match(r'^[a-zA-Z0-9_-]+$', hostname): + raise APIError("Invalid hostname format", 400) + if len(hostname) > 255: + raise APIError("Hostname too long", 400) + return hostname + + +def setup_logging(config): + """Setup logging configuration""" + import os + from logging.handlers import RotatingFileHandler + + # Create logs directory if it doesn't exist + log_dir = os.path.dirname(config.LOG_FILE) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir) + + # Create logger + logger = logging.getLogger() + logger.setLevel(getattr(logging, config.LOG_LEVEL)) + + # File handler with rotation + file_handler = RotatingFileHandler( + config.LOG_FILE, + maxBytes=config.LOG_MAX_BYTES, + backupCount=config.LOG_BACKUP_COUNT + ) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + )) + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setFormatter(logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + )) + + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + + +# ============================================================================ +# ANSIBLE-BASED DEVICE MANAGEMENT FUNCTIONS +# ============================================================================ + +def execute_command_on_device(device_hostname: str, command: str, user: str = 'pi'): + """ + Execute command on a device using Ansible + + Args: + device_hostname: Device hostname from inventory + command: Command to execute + user: User to execute command as + + Returns: + dict: Execution result with success status and output + """ + try: + ansible_mgr = get_ansible_manager() + + logger.info(f"Executing command on {device_hostname}: {command}") + + result = ansible_mgr.execute_command_on_device( + device=device_hostname, + command=command, + user=user + ) + + if result['success']: + logger.info(f"Command executed successfully on {device_hostname}") + return { + "success": True, + "result": result['output'], + "device": device_hostname, + "timestamp": result['timestamp'] + } + else: + logger.error(f"Command failed on {device_hostname}: {result.get('error')}") + return { + "success": False, + "error": result.get('error', 'Unknown error'), + "device": device_hostname, + "timestamp": result['timestamp'] + } + + except Exception as e: + logger.exception(f"Error executing command on {device_hostname}: {e}") + return { + "success": False, + "error": f"Connection error: {str(e)}", + "device": device_hostname + } + + +def deploy_updates_to_device( + device_hostname: str, + git_branch: str = 'dev', + backup: bool = True, + restart: bool = True +): + """ + Deploy updates to device using Ansible deploy playbook + + Args: + device_hostname: Device hostname from inventory + git_branch: Git branch to deploy + backup: Whether to backup before deploy + restart: Whether to restart service + + Returns: + dict: Deployment result + """ + try: + ansible_mgr = get_ansible_manager() + + logger.info(f"Starting deployment on {device_hostname} from branch {git_branch}") + + result = ansible_mgr.deploy_to_devices( + target_hosts=device_hostname, + git_branch=git_branch, + backup=backup, + restart=restart + ) + + if result['success']: + logger.info(f"Deployment completed successfully on {device_hostname}") + return { + "success": True, + "message": f"Deployed {git_branch} to {device_hostname}", + "device": device_hostname, + "branch": git_branch, + "log_file": result.get('log_file'), + "timestamp": result['timestamp'] + } + else: + logger.error(f"Deployment failed on {device_hostname}: {result.get('error')}") + return { + "success": False, + "error": result.get('error', 'Deployment failed'), + "device": device_hostname, + "timestamp": result['timestamp'] + } + + except Exception as e: + logger.exception(f"Error deploying to {device_hostname}: {e}") + return { + "success": False, + "error": f"Deployment error: {str(e)}", + "device": device_hostname + } + + +def deploy_updates_to_all_devices( + git_branch: str = 'dev', + backup: bool = True, + restart: bool = True +): + """ + Deploy updates to all devices in the 'prezenta_devices' group + + Args: + git_branch: Git branch to deploy + backup: Whether to backup before deploy + restart: Whether to restart service + + Returns: + dict: Deployment result for all devices + """ + try: + ansible_mgr = get_ansible_manager() + + logger.info(f"Starting batch deployment to all devices from branch {git_branch}") + + result = ansible_mgr.deploy_to_devices( + target_hosts='prezenta_devices', + git_branch=git_branch, + backup=backup, + restart=restart + ) + + if result['success']: + logger.info(f"Batch deployment completed successfully") + return { + "success": True, + "message": f"Deployed {git_branch} to all devices", + "branch": git_branch, + "log_file": result.get('log_file'), + "timestamp": result['timestamp'] + } + else: + logger.error(f"Batch deployment failed: {result.get('error')}") + return { + "success": False, + "error": result.get('error', 'Batch deployment failed'), + "timestamp": result['timestamp'] + } + + except Exception as e: + logger.exception(f"Error in batch deployment: {e}") + return { + "success": False, + "error": f"Batch deployment error: {str(e)}" + } + + +def system_update_device( + device_hostname: str, + update_os: bool = False, + update_python: bool = True, + health_check: bool = True +): + """ + Perform system update and maintenance on device + + Args: + device_hostname: Device hostname from inventory + update_os: Whether to update OS packages + update_python: Whether to update Python packages + health_check: Whether to perform health check + + Returns: + dict: Update result + """ + try: + ansible_mgr = get_ansible_manager() + + logger.info(f"Starting system update on {device_hostname}") + + result = ansible_mgr.system_update( + target_hosts=device_hostname, + update_os=update_os, + update_python=update_python, + health_check=health_check + ) + + if result['success']: + logger.info(f"System update completed on {device_hostname}") + return { + "success": True, + "message": f"System update completed on {device_hostname}", + "device": device_hostname, + "log_file": result.get('log_file'), + "timestamp": result['timestamp'] + } + else: + logger.error(f"System update failed on {device_hostname}: {result.get('error')}") + return { + "success": False, + "error": result.get('error', 'System update failed'), + "device": device_hostname, + "timestamp": result['timestamp'] + } + + except Exception as e: + logger.exception(f"Error updating system on {device_hostname}: {e}") + return { + "success": False, + "error": f"System update error: {str(e)}", + "device": device_hostname + } + + +def get_device_facts(device_hostname: str): + """ + Get facts about a device using Ansible + + Args: + device_hostname: Device hostname from inventory + + Returns: + dict: Device facts and information + """ + try: + ansible_mgr = get_ansible_manager() + + logger.info(f"Gathering facts for {device_hostname}") + + result = ansible_mgr.get_device_facts(device_hostname) + + if result['success']: + return { + "success": True, + "device": device_hostname, + "facts": result.get('facts', {}), + "timestamp": result['timestamp'] + } + else: + logger.error(f"Failed to get facts for {device_hostname}: {result.get('error')}") + return { + "success": False, + "error": result.get('error', 'Failed to get device facts'), + "device": device_hostname + } + + except Exception as e: + logger.exception(f"Error getting facts for {device_hostname}: {e}") + return { + "success": False, + "error": f"Error getting device facts: {str(e)}", + "device": device_hostname + } + + +def get_device_status(device_hostname: str): + """ + Get device status (uses get_device_facts internally) + + Args: + device_hostname: Device hostname from inventory + + Returns: + dict: Device status information + """ + facts = get_device_facts(device_hostname) + + if facts['success']: + ansible_facts = facts.get('facts', {}) + + return { + "success": True, + "status": { + "hostname": device_hostname, + "system": ansible_facts.get('ansible_system'), + "distribution": ansible_facts.get('ansible_distribution'), + "version": ansible_facts.get('ansible_distribution_version'), + "ip_address": ansible_facts.get('ansible_default_ipv4', {}).get('address'), + "uptime_seconds": ansible_facts.get('ansible_uptime_seconds'), + "processor_count": ansible_facts.get('ansible_processor_count'), + "memtotal_mb": ansible_facts.get('ansible_memtotal_mb'), + "memfree_mb": ansible_facts.get('ansible_memfree_mb'), + "timestamp": facts['timestamp'] + } + } + else: + return facts + + +def get_execution_logs(limit: int = 10): + """Get recent Ansible execution logs""" + try: + ansible_mgr = get_ansible_manager() + logs = ansible_mgr.get_execution_logs(limit) + return { + "success": True, + "logs": logs, + "count": len(logs) + } + except Exception as e: + logger.exception(f"Error getting execution logs: {e}") + return { + "success": False, + "error": f"Error getting logs: {str(e)}" + }