#!/usr/bin/env python3 """ GitOps Status Server API Generic multi-server status API for GitOps deployments Supports multiple server types: rsyslog, Splunk, IBM ITNM, nginx, etc. Listens on port 5000 and handles status updates from pipelines and cron jobs """ import os import json import logging from flask import Flask, request, jsonify from datetime import datetime, UTC from flasgger import Swagger from typing import Dict, List, Optional app = Flask(__name__) swagger = Swagger(app) # Configuration from environment STATUS_FILE = os.environ.get('STATUS_FILE', '/data/status.json') API_HOST = os.environ.get('API_HOST', '0.0.0.0') API_PORT = int(os.environ.get('API_PORT', 5000)) # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) def load_data() -> Dict: """Load the current data structure from file""" try: if os.path.exists(STATUS_FILE): with open(STATUS_FILE, 'r') as f: data = json.load(f) # Handle legacy format (v1.x) - migrate to v2 format if 'version' not in data and 'repo' in data: logger.info("Migrating from legacy v1 format to v2 format") legacy_data = data.copy() server_key = f"{legacy_data.get('server', 'unknown')}/{legacy_data.get('server', 'unknown')}" # Convert old format to new format migrated_server = { "project_name": "gitops-for-servers", "repo_name": legacy_data.get('repo', 'unknown'), "server_group": legacy_data.get('server', 'unknown'), "server_type": legacy_data.get('repo', 'unknown'), "environment": "unknown", "host": legacy_data.get('server', 'unknown'), "status": legacy_data.get('sync_status', 'unknown').lower(), "changed_files": [f.get('name', '') for f in legacy_data.get('drifted_files', [])] if isinstance(legacy_data.get('drifted_files'), list) else [], "validation_status": "unknown", "validation_message": "", "last_pipeline_run": legacy_data.get('last_check', ''), "last_cron_check": legacy_data.get('last_check', ''), "commit_sha": "", "branch": "", "updated_by": "legacy_migration", "timestamp": datetime.now(UTC).isoformat() } data = { "version": "2.0", "servers": { server_key: migrated_server } } save_data(data) logger.info(f"Migration complete: {server_key}") return data else: logger.warning(f"Status file not found: {STATUS_FILE}, creating new v2 structure") return { "version": "2.0", "servers": {} } except Exception as e: logger.error(f"Error loading status: {e}") return { "version": "2.0", "servers": {} } def save_data(data: Dict) -> bool: """Save the data structure to file""" try: # Ensure directory exists os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True) # Write with proper formatting with open(STATUS_FILE, 'w') as f: json.dump(data, f, indent=2) logger.info(f"Data saved successfully ({len(data.get('servers', {}))} servers)") return True except Exception as e: logger.error(f"Error saving data: {e}") return False def get_server_key(server_group: str, host: str) -> str: """Generate consistent key for server storage""" return f"{server_group}/{host}" def validate_required_fields(payload: Dict, required_fields: List[str]) -> Optional[str]: """Validate that required fields are present in payload""" missing = [field for field in required_fields if field not in payload or payload[field] == ""] if missing: return f"Missing required fields: {', '.join(missing)}" return None @app.route('/status/pipeline', methods=['POST', 'OPTIONS']) def status_pipeline(): """ Pipeline status update endpoint Updates full deployment status from CI/CD pipeline --- post: summary: Update status from pipeline deployment description: Full status update including deployment details, validation, commit info parameters: - in: body name: body required: true schema: type: object required: - server_group - host - status properties: project_name: type: string example: "gitops-for-servers" repo_name: type: string example: "rsyslog" server_group: type: string example: "rsyslog-prod" server_type: type: string example: "rsyslog" environment: type: string example: "prod" host: type: string example: "rsyslog-01" status: type: string enum: ["synced", "out_of_sync", "failed", "unknown"] example: "synced" changed_files: type: array items: type: string example: [] validation_status: type: string example: "passed" validation_message: type: string example: "rsyslog syntax validation passed" commit_sha: type: string example: "abc1234" branch: type: string example: "master" updated_by: type: string example: "woodpecker" last_pipeline_run: type: string example: "2026-04-26T00:00:00Z" responses: 200: description: Status updated successfully 400: description: Validation error 500: description: Failed to save status """ if request.method == 'OPTIONS': return '', 204 try: payload = request.get_json() if not payload: return jsonify({"error": "No JSON data provided"}), 400 # Validate required fields error = validate_required_fields(payload, ['server_group', 'host', 'status']) if error: return jsonify({"error": error}), 400 # Load current data data = load_data() # Generate server key server_key = get_server_key(payload['server_group'], payload['host']) # Get existing server data if present, or create new entry server_data = data['servers'].get(server_key, {}) # Update all fields from pipeline (full update) server_data.update({ "project_name": payload.get('project_name', server_data.get('project_name', '')), "repo_name": payload.get('repo_name', server_data.get('repo_name', '')), "server_group": payload['server_group'], "server_type": payload.get('server_type', server_data.get('server_type', '')), "environment": payload.get('environment', server_data.get('environment', '')), "host": payload['host'], "status": payload['status'], "changed_files": payload.get('changed_files', []), "validation_status": payload.get('validation_status', ''), "validation_message": payload.get('validation_message', ''), "commit_sha": payload.get('commit_sha', ''), "branch": payload.get('branch', ''), "updated_by": payload.get('updated_by', 'pipeline'), "last_pipeline_run": payload.get('last_pipeline_run', datetime.now(UTC).isoformat()), "last_cron_check": server_data.get('last_cron_check', ''), "timestamp": datetime.now(UTC).isoformat() }) # Save to data structure data['servers'][server_key] = server_data if save_data(data): logger.info(f"Pipeline update: {server_key} -> {payload['status']}") return jsonify({ "success": True, "message": "Pipeline status updated successfully", "server": server_data }), 200 else: return jsonify({"error": "Failed to save status"}), 500 except Exception as e: logger.error(f"Error in POST /status/pipeline: {e}") return jsonify({"error": str(e)}), 500 @app.route('/status/cron', methods=['POST', 'OPTIONS']) def status_cron(): """ Cron status update endpoint Updates only drift detection / health check data Does NOT overwrite pipeline deployment data --- post: summary: Update status from cron drift check description: Partial update for drift detection - does not overwrite commit_sha, changed_files, or validation data parameters: - in: body name: body required: true schema: type: object required: - server_group - host properties: server_group: type: string example: "rsyslog-prod" host: type: string example: "rsyslog-01" status: type: string enum: ["synced", "out_of_sync", "failed", "unknown"] example: "synced" changed_files: type: array items: type: string example: [] last_cron_check: type: string example: "2026-04-26T01:00:00Z" responses: 200: description: Cron status updated successfully 400: description: Validation error 404: description: Server not found 500: description: Failed to save status """ if request.method == 'OPTIONS': return '', 204 try: payload = request.get_json() if not payload: return jsonify({"error": "No JSON data provided"}), 400 # Validate required fields error = validate_required_fields(payload, ['server_group', 'host']) if error: return jsonify({"error": error}), 400 # Load current data data = load_data() # Generate server key server_key = get_server_key(payload['server_group'], payload['host']) # Check if server exists if server_key not in data['servers']: logger.warning(f"Cron update for unknown server: {server_key}") return jsonify({"error": f"Server {server_key} not found. Use /status/pipeline first."}), 404 server_data = data['servers'][server_key] # Cron updates only specific fields, preserves pipeline data if 'status' in payload: server_data['status'] = payload['status'] if 'changed_files' in payload: server_data['changed_files'] = payload['changed_files'] server_data['last_cron_check'] = payload.get('last_cron_check', datetime.now(UTC).isoformat()) server_data['timestamp'] = datetime.now(UTC).isoformat() # Save to data structure data['servers'][server_key] = server_data if save_data(data): logger.info(f"Cron update: {server_key} -> {server_data.get('status', 'unknown')}") return jsonify({ "success": True, "message": "Cron status updated successfully", "server": server_data }), 200 else: return jsonify({"error": "Failed to save status"}), 500 except Exception as e: logger.error(f"Error in POST /status/cron: {e}") return jsonify({"error": str(e)}), 500 @app.route('/status', methods=['GET']) def get_all_status(): """ Get all server statuses (Grafana Infinity friendly) --- get: summary: Retrieve all server statuses description: Returns flat array of all servers, optimized for Grafana Infinity datasource responses: 200: description: List of all server statuses schema: type: array items: type: object properties: server_group: type: string server_type: type: string host: type: string status: type: string changed_files_count: type: integer changed_files: type: array items: type: string validation_status: type: string last_pipeline_run: type: string last_cron_check: type: string commit_sha: type: string """ try: data = load_data() # Convert to flat array for Grafana result = [] for server_key, server_data in data.get('servers', {}).items(): result.append({ "project_name": server_data.get('project_name', ''), "repo_name": server_data.get('repo_name', ''), "server_group": server_data.get('server_group', ''), "server_type": server_data.get('server_type', ''), "environment": server_data.get('environment', ''), "host": server_data.get('host', ''), "status": server_data.get('status', 'unknown'), "changed_files_count": len(server_data.get('changed_files', [])), "changed_files": server_data.get('changed_files', []), "validation_status": server_data.get('validation_status', ''), "validation_message": server_data.get('validation_message', ''), "last_pipeline_run": server_data.get('last_pipeline_run', ''), "last_cron_check": server_data.get('last_cron_check', ''), "commit_sha": server_data.get('commit_sha', ''), "branch": server_data.get('branch', ''), "updated_by": server_data.get('updated_by', ''), "timestamp": server_data.get('timestamp', '') }) return jsonify(result), 200 except Exception as e: logger.error(f"Error in GET /status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/status/', methods=['GET']) def get_server_group_status(server_group: str): """ Get status for all servers in a specific server group --- get: summary: Retrieve status for a server group parameters: - in: path name: server_group type: string required: true description: The server group name responses: 200: description: List of servers in the group 404: description: Server group not found """ try: data = load_data() # Filter by server_group result = [] for server_key, server_data in data.get('servers', {}).items(): if server_data.get('server_group') == server_group: result.append({ "project_name": server_data.get('project_name', ''), "repo_name": server_data.get('repo_name', ''), "server_group": server_data.get('server_group', ''), "server_type": server_data.get('server_type', ''), "environment": server_data.get('environment', ''), "host": server_data.get('host', ''), "status": server_data.get('status', 'unknown'), "changed_files_count": len(server_data.get('changed_files', [])), "changed_files": server_data.get('changed_files', []), "validation_status": server_data.get('validation_status', ''), "validation_message": server_data.get('validation_message', ''), "last_pipeline_run": server_data.get('last_pipeline_run', ''), "last_cron_check": server_data.get('last_cron_check', ''), "commit_sha": server_data.get('commit_sha', ''), "branch": server_data.get('branch', ''), "updated_by": server_data.get('updated_by', ''), "timestamp": server_data.get('timestamp', '') }) if not result: return jsonify({"error": f"No servers found in group: {server_group}"}), 404 return jsonify(result), 200 except Exception as e: logger.error(f"Error in GET /status/{server_group}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/status//', methods=['GET']) def get_host_status(server_group: str, host: str): """ Get status for a specific server --- get: summary: Retrieve status for a specific server parameters: - in: path name: server_group type: string required: true description: The server group name - in: path name: host type: string required: true description: The hostname responses: 200: description: Server status 404: description: Server not found """ try: data = load_data() server_key = get_server_key(server_group, host) if server_key not in data.get('servers', {}): return jsonify({"error": f"Server not found: {server_key}"}), 404 server_data = data['servers'][server_key] return jsonify({ "project_name": server_data.get('project_name', ''), "repo_name": server_data.get('repo_name', ''), "server_group": server_data.get('server_group', ''), "server_type": server_data.get('server_type', ''), "environment": server_data.get('environment', ''), "host": server_data.get('host', ''), "status": server_data.get('status', 'unknown'), "changed_files_count": len(server_data.get('changed_files', [])), "changed_files": server_data.get('changed_files', []), "validation_status": server_data.get('validation_status', ''), "validation_message": server_data.get('validation_message', ''), "last_pipeline_run": server_data.get('last_pipeline_run', ''), "last_cron_check": server_data.get('last_cron_check', ''), "commit_sha": server_data.get('commit_sha', ''), "branch": server_data.get('branch', ''), "updated_by": server_data.get('updated_by', ''), "timestamp": server_data.get('timestamp', '') }), 200 except Exception as e: logger.error(f"Error in GET /status/{server_group}/{host}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/status', methods=['GET', 'POST', 'OPTIONS']) def api_status_legacy(): """ Legacy API endpoint for backward compatibility --- get: summary: (Legacy) Retrieve current status deprecated: true description: Use GET /status instead post: summary: (Legacy) Update status with old format deprecated: true description: Use POST /status/pipeline instead """ if request.method == 'OPTIONS': return '', 204 if request.method == 'GET': # Return first server in old format, or empty old format data = load_data() if not data.get('servers'): return jsonify({ "repo": "unknown", "server": "unknown", "sync_status": "UNKNOWN", "drift_count": 0, "deployed_files": [], "drifted_files": [], "last_check": "" }), 200 # Get first server and convert to old format first_key = list(data['servers'].keys())[0] server = data['servers'][first_key] return jsonify({ "repo": server.get('repo_name', 'unknown'), "server": server.get('server_group', 'unknown'), "sync_status": server.get('status', 'unknown').upper(), "drift_count": len(server.get('changed_files', [])), "deployed_files": [], "drifted_files": [{"name": f} for f in server.get('changed_files', [])], "last_check": server.get('last_pipeline_run', '') }), 200 if request.method == 'POST': # Accept old format and convert to new format try: incoming_data = request.get_json() if not incoming_data: return jsonify({"error": "No JSON data provided"}), 400 # Convert old format to new format server_group = incoming_data.get('server', 'unknown') host = incoming_data.get('server', 'unknown') new_payload = { "project_name": "gitops-for-servers", "repo_name": incoming_data.get('repo', 'unknown'), "server_group": server_group, "server_type": incoming_data.get('repo', 'unknown'), "environment": "unknown", "host": host, "status": incoming_data.get('sync_status', 'unknown').lower(), "changed_files": [f.get('name', '') for f in incoming_data.get('drifted_files', [])] if isinstance(incoming_data.get('drifted_files'), list) else [], "validation_status": "unknown", "validation_message": "", "commit_sha": "", "branch": "", "updated_by": "legacy_api", "last_pipeline_run": incoming_data.get('last_check', datetime.now(UTC).isoformat()) } # Load current data data = load_data() server_key = get_server_key(server_group, host) server_data = data['servers'].get(server_key, {}) # Merge with existing data server_data.update(new_payload) server_data['timestamp'] = datetime.now(UTC).isoformat() data['servers'][server_key] = server_data if save_data(data): logger.info(f"Legacy API update: {server_key}") return jsonify({ "success": True, "message": "Status updated successfully (legacy format)", "status": incoming_data }), 200 else: return jsonify({"error": "Failed to save status"}), 500 except Exception as e: logger.error(f"Error in POST /api/status (legacy): {e}") return jsonify({"error": str(e)}), 500 @app.route('/health', methods=['GET']) def health(): """ GET /health - Kubernetes liveness probe --- responses: 200: description: API is healthy """ return jsonify({"status": "healthy"}), 200 @app.route('/ready', methods=['GET']) def ready(): """ GET /ready - Kubernetes readiness probe --- responses: 200: description: API is ready to serve requests 503: description: API is not ready """ try: # Check if data directory is writable data_dir = os.path.dirname(STATUS_FILE) if not os.path.exists(data_dir): os.makedirs(data_dir, exist_ok=True) # Try to read or create status file data = load_data() # Verify we can read it if isinstance(data, dict) and 'version' in data: return jsonify({"status": "ready", "version": data['version']}), 200 return jsonify({"status": "not_ready", "reason": "invalid data structure"}), 503 except Exception as e: logger.error(f"Readiness check failed: {e}") return jsonify({"status": "not_ready", "error": str(e)}), 503 @app.route('/', methods=['GET']) def root(): """ GET / - API information and available endpoints --- responses: 200: description: API metadata and endpoint list """ return jsonify({ "name": "GitOps Status API", "version": "2.0.0", "description": "Generic multi-server status API for GitOps deployments", "endpoints": { "POST /status/pipeline": "Update full deployment status from pipeline", "POST /status/cron": "Update drift check status from cron", "GET /status": "Retrieve all server statuses (Grafana friendly)", "GET /status/{server_group}": "Retrieve status for server group", "GET /status/{server_group}/{host}": "Retrieve status for specific server", "GET /api/status": "(Legacy) Retrieve status in old format", "POST /api/status": "(Legacy) Update status with old format", "GET /health": "Liveness probe", "GET /ready": "Readiness probe" }, "supported_server_types": [ "rsyslog", "splunk", "ibm-itnm", "nginx", "any-generic-server-type" ] }), 200 if __name__ == '__main__': logger.info(f"Starting GitOps Status API v2.0 on {API_HOST}:{API_PORT}") logger.info(f"Status file location: {STATUS_FILE}") # Create directory if it doesn't exist os.makedirs(os.path.dirname(STATUS_FILE) or '.', exist_ok=True) app.run(host=API_HOST, port=API_PORT, debug=False, threaded=True)