commit 05a197b0d0a1f9b1ab29c9dc74fbc64e0a47dd95 Author: Agustin Date: Tue Sep 1 13:42:14 2026 +0000 Jenkins + Minio + Prometheus + Grafana v1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..00dfa03 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +CLOUDFLARE_TUNNEL_TOKEN= + +# MySQL connection +MYSQL_ROOT_PASSWORD= +MYSQL_USER= +MYSQL_PASSWORD= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b411f6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Backups +/backups/ + +# Cache de Python +__pycache__/ + +# Variables de entorno +.env + +# Certificados y claves +/data/certs/ +*.key +*.pem +*.crt + +# Grafana (estado runtime) +/data/grafana/data/ + +# Jenkins home (volumen) +/data/jenkins_home/ + +# MinIO (motor de storage y objetos) +/data/minio/ + +# MySQL (datadir del motor, no el init) +/data/mysql/data/ + +# Nginx Proxy Manager (estado, keys, certs) +/data/npm/ + +# Prometheus (TimeSeriesDataBase, no el config) +/data/prometheus/data/ diff --git a/data/alerts/alerts.txt b/data/alerts/alerts.txt new file mode 100644 index 0000000..2738931 --- /dev/null +++ b/data/alerts/alerts.txt @@ -0,0 +1,15 @@ + +=========================================================================== +[2026-09-01 13:27:18] ALERTA GRAFANA - ESTADO: FIRING +Título: [FIRING:1] TestAlert Grafana + + [Alerta #1] Status: firing + - Nombre : TestAlert + - Cliente : N/A + - Sucursal : N/A + - SCO : Grafana + - Severidad : N/A + - Resumen : Notification test + - Inicio : 2026-09-01T13:27:18.650618266Z + +RAW JSON: {"receiver": "webhook", "status": "firing", "alerts": [{"status": "firing", "labels": {"alertname": "TestAlert", "instance": "Grafana"}, "annotations": {"summary": "Notification test"}, "startsAt": "2026-09-01T13:27:18.650618266Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "57c6d9296de2ad39", "silenceURL": "http://laoficina1782.com:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTestAlert&matcher=instance%3DGrafana", "dashboardURL": "", "panelURL": "", "values": null, "valueString": "[ metric='foo' labels={instance=bar} value=10 ]"}], "groupLabels": {"alertname": "TestAlert", "instance": "Grafana"}, "commonLabels": {"alertname": "TestAlert", "instance": "Grafana"}, "commonAnnotations": {"summary": "Notification test"}, "externalURL": "http://laoficina1782.com:3000/", "appVersion": "13.1.1", "version": "1", "groupKey": "webhook-57c6d9296de2ad39-1788269238", "truncatedAlerts": 0, "orgId": 1, "title": "[FIRING:1] TestAlert Grafana ", "state": "alerting", "message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = TestAlert\n - instance = Grafana\nAnnotations:\n - summary = Notification test\nSilence: http://laoficina1782.com:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTestAlert&matcher=instance%3DGrafana\n"} diff --git a/data/central-api/Dockerfile b/data/central-api/Dockerfile new file mode 100644 index 0000000..21b0e34 --- /dev/null +++ b/data/central-api/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy + +WORKDIR /app + +# copiamos e instalamos dependencias +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# copiamos el resto del código +COPY . . + +# gunicorn con 2 workers y 4 threads por worker +# gunicorn -k gthread -w 2 --threads 4 -b 0.0.0.0:5000 central_api:app +CMD ["gunicorn", "-k", "gthread", "-w", "2", "--threads", "4", "-b", "0.0.0.0:5000", "central_api:app"] diff --git a/data/central-api/central_api.py b/data/central-api/central_api.py new file mode 100644 index 0000000..2c04769 --- /dev/null +++ b/data/central-api/central_api.py @@ -0,0 +1,660 @@ +import os +import io +import requests +import logging +from pathlib import Path +from flask import Flask, request, jsonify, send_file, after_this_request +from pydantic import BaseModel, ValidationError, Field +from typing import Optional, List +from datetime import datetime +import mysql.connector +from mysql.connector import Error +from mysql.connector import pooling +import copy +import uuid +import concurrent.futures +import threading +import time +from flask import render_template_string +import json +from playwright.sync_api import sync_playwright + +app = Flask(__name__) + +# auxpara compartir multiples workers de Gunicorn +def set_job_status(job_id, status_dict): + try: + with open(f"/tmp/{job_id}.json", "w") as f: + json.dump(status_dict, f) + except Exception as e: + logger.error(f"Error guardando status JSON: {e}") + +def get_job_status(job_id): + try: + path = f"/tmp/{job_id}.json" + if os.path.exists(path): + with open(path, "r") as f: + return json.load(f) + except: + pass + return {'status': 'not_found'} + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Pydantic para validar los campos +class TransactionSchema(BaseModel): + cliente: str + sco_number: str + sucursal: str + pos_transaction_id: int + + started_at: datetime + end_transaction_at: Optional[datetime] = None + closed_at: datetime + + status: str + + item_count: int = 0 + total_amount: float = 0.0 + paid_amount: float = 0.0 + change_amount: float = 0.0 + amounts_reconstructed: bool = False + + payment_type: Optional[str] = None + split_payment: bool = False + + age_verification_requested: bool = False + age_verification_value: Optional[int] = Field(default=None, gt=0) + bag_item_count: int = 0 + +# config mysql con .env general +DB_HOST = os.environ.get("DB_HOST") +DB_PORT = int(os.environ.get("DB_PORT", "3306")) +DB_USER = os.environ.get("DB_USER") +DB_PASSWORD = os.environ.get("DB_PASSWORD") +DB_NAME = os.environ.get("DB_NAME") + +if not all([DB_HOST, DB_USER, DB_PASSWORD, DB_NAME]): + raise ValueError("Faltan credenciales de MySQL.") + +# manejo de db pool dentro de la instancia +db_pool = None + +dbconfig = { + "host": DB_HOST, + "port": DB_PORT, + "user": DB_USER, + "password": DB_PASSWORD, + "database": DB_NAME +} + +max_retries = 5 +for attempt in range(max_retries): + try: + db_pool = pooling.MySQLConnectionPool( + pool_name="sco_pool", + pool_size=10, + pool_reset_session=True, + **dbconfig + ) + logger.info("Connection pool de MySQL creado con éxito.") + break + except Error as e: + logger.error(f"Error al crear el pool de conexiones (Intento {attempt + 1}/{max_retries}): {e}") + if attempt < max_retries - 1: + time.sleep(3) + else: + logger.error("No se pudo conectar a MySQL después de varios intentos.") + +def get_db_pool(): + return db_pool + +# Pantalla de carga HTML (PRUEBA grafica) +LOADING_HTML = """ + + + + + Generando Reporte... + + + +
+
Construyendo su reporte PDF...
+
Estamos recolectando y procesando los datos de Grafana. Esto tomará entre 1 y 4 minutos dependiendo del tamaño. Por favor, no cierre esta ventana.
+ + + +""" + +def background_export_task(job_id, dashboard_uid, dashboard_name, cliente, grafana_vars, time_from, time_to, tz, grafana_session): + logger.info(f"[{job_id}] Iniciando hilo de trabajo en segundo plano (Modo Playwright Headless)...") + set_job_status(job_id, {'status': 'processing'}) + + try: + # URL hacia la UI real de Grafana + target_url = f"http://grafana:3000/d/{dashboard_uid}?orgId=1" + + # Inyectamos dinámicamente TODAS las variables que empiezan con var- + for var_key, var_values in grafana_vars.items(): + for val in var_values: + target_url += f"&{var_key}={val}" + + # Forzamos timezone + target_url += "&kiosk=1&hideNav=1&timezone=browser" + + if time_from: target_url += f"&from={time_from}" + if time_to: target_url += f"&to={time_to}" + + with sync_playwright() as p: + # Apagamos GPU y WebGL para evitar limites de hardware + browser = p.chromium.launch(headless=True, args=['--disable-gpu', '--disable-dev-shm-usage', '--disable-webgl']) + context = browser.new_context( + viewport={'width': 1920, 'height': 1080}, + ignore_https_errors=True, + timezone_id=tz, + ) + + if grafana_session: + context.add_cookies([{ + 'name': 'grafana_session', + 'value': grafana_session, + 'domain': 'grafana', # host interno del contenedor + 'path': '/' + }]) + logger.info(f"[{job_id}] Clonación de Cookie exitosa.") + else: + logger.warning(f"[{job_id}] ERROR: No se recibió cookie de sesión desde. Petición rechazada por Grafana.") + + page = context.new_page() + + # Bloquear Websockets + page.route("**/*api/live/ws*", lambda route: route.abort()) + page.on("pageerror", lambda err: logger.error(f"[{job_id}] Playwright Page Error: {err}")) + + # escuchamos dinamicamente las querys + active_queries = [0] + + def handle_request(request): + if "api/ds/query" in request.url: + active_queries[0] += 1 + + def handle_request_finished(request): + if "api/ds/query" in request.url: + active_queries[0] = max(0, active_queries[0] - 1) + + def handle_request_failed(request): + if "api/ds/query" in request.url: + active_queries[0] = max(0, active_queries[0] - 1) + + page.on("request", handle_request) + page.on("requestfinished", handle_request_finished) + page.on("requestfailed", handle_request_failed) + + page.goto(target_url, timeout=0, wait_until='load') + + logger.info(f"[{job_id}] Validando permisos de acceso al dashboard...") + # Dar tiempo a que React termine sus peticiones + try: + page.wait_for_load_state("networkidle", timeout=10000) + except: + pass + + page.wait_for_timeout(2000) + + if "/login" in page.url or page.get_by_placeholder("email or username").is_visible(): + error_msg = "Acceso Denegado - No tienes permisos para exportar este Dashboard o tu sesion caducó." + logger.error(f"[{job_id}] {error_msg}") + raise Exception(error_msg) + + # check "Forbidden" o "Access denied" en el contenido de la página + page_text = page.locator("body").inner_text().lower() + if "failed to load dashboard" in page_text or "forbidden" in page_text or "access denied" in page_text or "not found" in page_text: + error_msg = "Error 404 - El dashboard solicitado no existe o fue eliminado." + logger.error(f"[{job_id}] Intento de acceso a dashboard sin permisos.") + raise Exception(error_msg) + + logger.info(f"[{job_id}] Acceso concedido. Esperando 10s para la primera fila de paneles...") + page.wait_for_timeout(10000) + + try: + # # Localizamos ROWS colapsadas y las abrimos + page.evaluate(""" + const buttons = document.querySelectorAll('[aria-expanded="false"]'); + buttons.forEach(btn => { + const testId = btn.getAttribute('data-testid') || ''; + const className = btn.className || ''; + const parentClass = (btn.parentElement && btn.parentElement.className) || ''; + const ariaLabel = btn.getAttribute('aria-label') || ''; + + // Es una fila si cumple alguna de estas heurísticas de Grafana: + const isRow = testId.includes('dashboard-row') || + className.includes('row') || + parentClass.includes('row') || + ariaLabel.toLowerCase().includes('row') || + ariaLabel.toLowerCase().includes('fila') || + btn.offsetWidth > 500; // Las barras de fila ocupan mucho ancho + + if (isRow) { + btn.setAttribute('data-pdf-row', 'true'); + } + }); + + // Soporte legacy para Grafana 8 + document.querySelectorAll('.dashboard-row--collapsed').forEach(div => { + div.setAttribute('data-pdf-row', 'true'); + }); + """) + + # Recolectamos todas las filas + row_elements = page.locator('[data-pdf-row="true"]').all() + rows_to_click = [] + + for el in row_elements: + if el.is_visible(): + box = el.bounding_box() + if box: + rows_to_click.append({ + 'el': el, + 'y': box['y'], + 'text': el.inner_text().strip()[:40] + }) + + # ORDENAMOS de mayor a menor (important) + rows_to_click.sort(key=lambda item: item['y'], reverse=True) + + logger.info(f"[{job_id}] Se encontraron {len(rows_to_click)} filas dinamicas para expandir.") + + for item in rows_to_click: + logger.info(f"[{job_id}] Expandiendo fila interactiva: {item['text']}") + item['el'].scroll_into_view_if_needed() + page.wait_for_timeout(500) + item['el'].click(force=True) + page.wait_for_timeout(1000) + + except Exception as e: + logger.warning(f"[{job_id}] Error secundario al hacer click en filas: {e}") + + # 5 segundos para que Grafana renderize + page.wait_for_timeout(5000) + + total_height = page.evaluate("document.body.scrollHeight") + + # salto de 1080px para que las querys carguen tranquilas + viewport_height = 1080 + current_y = total_height + + while current_y > 0: + page.evaluate(f"window.scrollTo(0, {current_y})") + + logger.info(f"[{job_id}] Scrolleamos a {current_y}px...") + # 2s iniciales para que grafana/react reaccione + page.wait_for_timeout(2000) + + # Espera reactiva ---> vigila hasta que las queries activas terminen. + wait_time = 0 + while active_queries[0] > 0 and wait_time < 15000: + page.wait_for_timeout(1000) + wait_time += 1000 + if wait_time % 5000 == 0: + logger.info(f"[{job_id}] Waiting DB... Queries actives: {active_queries[0]}") + + current_y -= viewport_height + + page.evaluate("window.scrollTo(0, 0)") + logger.info(f"[{job_id}] Tope alcanzado. Esperando ultima resolución.") + page.wait_for_timeout(2000) + wait_time = 0 + + # tiempo de seguridad por precaucion + while active_queries[0] > 0 and wait_time < 45000: + page.wait_for_timeout(1000) + wait_time += 1000 + if wait_time % 5000 == 0: + logger.info(f"[{job_id}] Tope - Still waiting DB... Queries actives: {active_queries[0]}") + + logger.info(f"[{job_id}] Cambiando a Viewport original de la pagina de: {total_height}px.") + final_height = min(total_height, 25000) + page.set_viewport_size({'width': 1920, 'height': final_height}) + + page.evaluate("window.scrollTo(0, 0)") + + logger.info(f"[{job_id}] 15000ms para que grafana procese...") + page.wait_for_timeout(15000) + + # checkeamos si NO ESTAMOS EN LOGIN == tenemos cookies validas? + if "/login" in page.url or page.get_by_placeholder("email or username").is_visible(): + error_msg = "Acceso Denegado - Sesión caducada o rechazada en el último momento." + logger.error(f"[{job_id}] {error_msg}") + raise Exception(error_msg) + + # sacamos titulo para que no diga: " - Dashboards - Grafana" + page.evaluate("document.title = document.title.replace(' - Dashboards - Grafana', '');") + + # Eliminar todos los (href) para que no sean clickeables en el PDF generado + logger.info(f"[{job_id}] Eliminando enlaces interactivos del HTML...") + page.evaluate(""" + document.querySelectorAll('a[href]').forEach(el => { + el.removeAttribute('href'); + }); + """) + + logger.info(f"[{job_id}] Generando PDF...") + pdf_filepath = f"/tmp/{job_id}.pdf" + + # Forzamos a Chrome a creer que sigue en una pantalla usando CSS @media print + # Esto evita que Grafana oculte el reloj + botones + page.emulate_media(media="screen") + + page.pdf( + path=pdf_filepath, + print_background=True, + width="1920px", + height=f"{final_height}px", + margin={"top": "0", "right": "0", "bottom": "0", "left": "0"}, + page_ranges="1" + ) + + browser.close() + + logger.info(f"[{job_id}] Work Done! Guardado en: {pdf_filepath}") + + import re + # convertimos a lowercase el 'dashboard_name' para que el archivo salga mejor + safe_dashboard_name = re.sub(r'[^a-z0-9]+', '-', dashboard_name.lower()).strip('-') + filename_final = f"{safe_dashboard_name}.pdf" + + set_job_status(job_id, {'status': 'done', 'filepath': pdf_filepath, 'filename': filename_final}) + + except Exception as e: + logger.error(f"[{job_id}] FATAL ERROR en Playwright: {str(e)}") + set_job_status(job_id, {'status': 'error', 'error': str(e)}) + +@app.route('/api/v1/export/image', methods=['GET']) +def export_image_trigger(): + + # Capturar TODAS las variables (empiezan con var-) + grafana_vars = {} + for key in request.args.keys(): + if key.startswith('var-'): + grafana_vars[key] = request.args.getlist(key) + + cliente = request.args.get('cliente') + if not cliente and 'var-cliente' in grafana_vars: + cliente = grafana_vars['var-cliente'][0] + + dashboard_uid = request.args.get('dashboard_uid') + dashboard_name = request.args.get('dashboard_name', 'Masivo') + time_from = request.args.get('from') + time_to = request.args.get('to') + tz = request.args.get('tz', 'America/Argentina/Buenos_Aires') + if "${" in tz or not tz: + tz = 'America/Argentina/Buenos_Aires' + + grafana_session = request.cookies.get('grafana_session') + + if not cliente or not dashboard_uid: + return jsonify({'error': 'Faltan parametros obligatorios'}), 400 + + # Crear el ticket asincrono + job_id = uuid.uuid4().hex + set_job_status(job_id, {'status': 'starting'}) + + # Lanzar el thread + thread = threading.Thread( + target=background_export_task, + args=(job_id, dashboard_uid, dashboard_name, cliente, grafana_vars, time_from, time_to, tz, grafana_session) + ) + thread.start() + + # Responder INMEDIATAMENTE a el usuario asi no falla cloudflare + html_content = LOADING_HTML.replace("{{job_id}}", job_id) + return render_template_string(html_content) + +@app.route('/api/v1/export/status/', methods=['GET']) +def export_status(job_id): + status_info = get_job_status(job_id) + return jsonify(status_info) + +@app.route('/api/v1/export/download/', methods=['GET']) +def export_download(job_id): + status_info = get_job_status(job_id) + if status_info.get('status') != 'done': + return "Archivo no encontrado o no está listo", 404 + + filepath = status_info.get('filepath') + download_name = status_info.get('filename', 'reporte.pdf') + + try: + # delete del archivo after download + @after_this_request + def remove_file(response): + try: + os.remove(filepath) + os.remove(f"/tmp/{job_id}.json") + logger.info(f"[{job_id}] Limpieza temporal exitosa tras descarga.") + except Exception as e: + logger.error(f"[{job_id}] Error borrando temp files: {e}") + return response + + return send_file( + filepath, + mimetype='application/pdf', + as_attachment=True, + download_name=download_name + ) + except Exception as e: + return str(e), 500 + +@app.route('/api/v1/transactions', methods=['POST']) +def receive_transaction(): + data = request.get_json() + + if not data or not isinstance(data, list): + return jsonify({"error": "Se esperaba un array JSON de transacciones"}), 400 + + # Pydantic validation + valid_transactions = [] + failed_messages = [] + + for i, item in enumerate(data): + try: + valid_transactions.append(TransactionSchema(**item)) + except ValidationError as e: + logger.warning(f"JSON rechazado en índice {i}: {e.errors()}") + tx_id = item.get("pos_transaction_id", f"índice {i}") + failed_messages.append(f"La transaccion con id {tx_id} no cumple el formato esperado: {str(e.errors())}") + + if not valid_transactions: + return jsonify({ + "status": "error", + "data": "Todas las transacciones fallaron la validación de formato.\n" + "\n".join(failed_messages) + }), 400 + + pool = get_db_pool() + if not pool: + return jsonify({"error": "Error interno del servidor"}), 500 + + # Extraemos una conexion disponible del pool + try: + connection = pool.get_connection() + except Error as e: + logger.error(f"Error obteniendo conexión del pool: {e}") + return jsonify({"error": "Error de base de datos"}), 500 + + cursor = connection.cursor() + + insert_query = """ + INSERT INTO transactions ( + cliente, sco_number, sucursal, pos_transaction_id, started_at, end_transaction_at, + closed_at, status, item_count, total_amount, paid_amount, change_amount, + amounts_reconstructed, payment_type, split_payment, age_verification_requested, + age_verification_value, bag_item_count + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) ON DUPLICATE KEY UPDATE + started_at = VALUES(started_at), + end_transaction_at = VALUES(end_transaction_at), + closed_at = VALUES(closed_at), + status = VALUES(status), + item_count = VALUES(item_count), + total_amount = VALUES(total_amount), + paid_amount = VALUES(paid_amount), + change_amount = VALUES(change_amount), + amounts_reconstructed = VALUES(amounts_reconstructed), + payment_type = VALUES(payment_type), + split_payment = VALUES(split_payment), + age_verification_requested = VALUES(age_verification_requested), + age_verification_value = VALUES(age_verification_value), + bag_item_count = VALUES(bag_item_count) + """ + + values_list = [] + for transaction in valid_transactions: + # Forzamos lower case en Python también como doble capa de seguridad + values_list.append(( + transaction.cliente.lower(), + transaction.sco_number, + transaction.sucursal, + transaction.pos_transaction_id, + transaction.started_at, + transaction.end_transaction_at, + transaction.closed_at, + transaction.status, + transaction.item_count, + transaction.total_amount, + transaction.paid_amount, + transaction.change_amount, + transaction.amounts_reconstructed, + transaction.payment_type, + transaction.split_payment, + transaction.age_verification_requested, + transaction.age_verification_value, + transaction.bag_item_count + )) + + try: + cursor.executemany(insert_query, values_list) + connection.commit() + logger.info(f"Bulk Upsert exitoso: {len(valid_transactions)} guardadas. {len(failed_messages)} fallidas.") + + response_data = "Batch procesado correctamente." + if failed_messages: + response_data = "\n".join(failed_messages) + + return jsonify({ + "status": "ok", + "data": response_data + }), 200 + except Error as e: + logger.error(f"Error ejecutando query MySQL: {e}") + connection.rollback() + return jsonify({"error": "Error al guardar en la base de datos"}), 500 + finally: + if cursor: + cursor.close() + if connection: + # no cerramos la conexión - la devolvemos al pool + connection.close() + +ALERTS_LOG_FILE = os.environ.get("ALERTS_LOG_FILE", "/data/alerts/alerts.txt") + +@app.route('/api/v1/alerts/webhook', methods=['POST']) +def receive_grafana_alert(): + try: + data = request.get_json(force=True) + if not data: + return jsonify({"status": "error", "message": "No JSON payload received"}), 400 + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + status = data.get("status", "unknown").upper() + title = data.get("title", data.get("groupLabels", {}).get("alertname", "Sin Titulo")) + alerts = data.get("alerts", []) + + # Asegurar que el directorio exista + log_path = Path(ALERTS_LOG_FILE) + log_path.parent.mkdir(parents=True, exist_ok=True) + + with open(str(log_path), "a", encoding="utf-8") as f: + f.write(f"\n{'='*75}\n") + f.write(f"[{now_str}] ALERTA GRAFANA - ESTADO: {status}\n") + f.write(f"Título: {title}\n") + + if alerts: + for idx, alert in enumerate(alerts, 1): + labels = alert.get("labels", {}) + annotations = alert.get("annotations", {}) + starts_at = alert.get("startsAt", "") + ends_at = alert.get("endsAt", "") + alert_status = alert.get("status", status) + + f.write(f"\n [Alerta #{idx}] Status: {alert_status}\n") + f.write(f" - Nombre : {labels.get('alertname', 'N/A')}\n") + f.write(f" - Cliente : {labels.get('cliente', 'N/A')}\n") + f.write(f" - Sucursal : {labels.get('sucursal', 'N/A')}\n") + f.write(f" - SCO : {labels.get('sco_number', labels.get('instance', 'N/A'))}\n") + f.write(f" - Severidad : {labels.get('severity', 'N/A')}\n") + f.write(f" - Resumen : {annotations.get('summary', annotations.get('description', 'N/A'))}\n") + if starts_at: + f.write(f" - Inicio : {starts_at}\n") + if ends_at and ends_at != "0001-01-01T00:00:00Z": + f.write(f" - Fin : {ends_at}\n") + else: + f.write(f" Detalle: {data.get('message', 'Sin descripción adicional')}\n") + + f.write(f"\nRAW JSON: {json.dumps(data, ensure_ascii=False)}\n") + f.flush() + + logger.info(f"Alerta de Grafana guardada en {ALERTS_LOG_FILE} [{status}] {title}") + return jsonify({"status": "ok", "message": "Alerta registrada correctamente"}), 200 + + except Exception as e: + logger.error(f"Error procesando webhook de alerta: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/data/central-api/requirements.txt b/data/central-api/requirements.txt new file mode 100644 index 0000000..47b0096 --- /dev/null +++ b/data/central-api/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.1.3 +mysql-connector-python==26.7.0 +gunicorn==21.2.0 +pydantic>=2.0.0 +requests==2.31.0 +Pillow==10.2.0 +playwright==1.45.0 \ No newline at end of file diff --git a/data/jenkins/Dockerfile b/data/jenkins/Dockerfile new file mode 100644 index 0000000..595d343 --- /dev/null +++ b/data/jenkins/Dockerfile @@ -0,0 +1,18 @@ +FROM jenkins/jenkins:2.541.3-jdk21 + +USER root +RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/* + +USER jenkins + +RUN jenkins-plugin-cli --plugins \ + configuration-as-code \ + credentials-binding \ + git \ + workflow-aggregator \ + job-dsl \ + pipeline-utility-steps + +COPY jenkins.yaml /usr/share/jenkins/ref/jenkins.yaml +ENV CASC_JENKINS_CONFIG=/usr/share/jenkins/ref/jenkins.yaml + diff --git a/data/jenkins/deploy/install.sh b/data/jenkins/deploy/install.sh new file mode 100644 index 0000000..223e2fa --- /dev/null +++ b/data/jenkins/deploy/install.sh @@ -0,0 +1,82 @@ +#!/bin/sh + +### DOWNLOAD MC +if [ ! -f /usr/local/bin/mc ]; then + curl -L https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc + chmod +x /usr/local/bin/mc +fi + +systemctl stop scoserver.service +systemctl stop scodocker.service +docker rmi cpi-server:latest gotenberg/gotenberg:8-chromium jumpmind/symmetricds:3.15.16 html-converter:latest mysql:8.0.32 + +### UPDATE SOFTWARE +SERVER=minio-api.laoficina1782.com +CUSTOMER="${1:?Uso: $0 }" +mc --config-dir /tmp/mc-config alias set minio "https://$SERVER" minioadmin LIGTNbUgQr +mc --config-dir /tmp/mc-config mirror --json --remove --overwrite --exclude "backend/docker/data/**" "minio/$CUSTOMER" /opt/sco/ +find /opt/sco/ -type d -exec chmod 755 {} \; +find /opt/sco/ -type f -exec chmod 644 {} \; +chmod -R 755 /opt/sco/frontend/ + +### TMP FOLDER +cp /usr/lib/tmpfiles.d/fs-tmp.conf /etc/tmpfiles.d/ +echo "D /tmp 1777 root root -" > /etc/tmpfiles.d/fs-tmp.conf + +### SERVICE +find /opt/sco/resources/services/ -type f -exec sh -c 'cp "$1" "/etc/systemd/system/$(basename "$1")"' _ {} \; +find /opt/sco/resources/services/ -type f -exec sh -c 'systemctl enable "$(basename "$1")"' _ {} \; +systemctl daemon-reload + +### AUTOSTART +rsync -a --delete /opt/sco/resources/autostart/ /home/sco/.config/autostart/ +chown -R sco:users /home/sco/.config/autostart/* +chmod -R 644 /home/sco/.config/autostart/* + +### CRONTAB +crontab -u root /opt/sco/resources/crontab/root.txt +crontab -u sco /opt/sco/resources/crontab/sco.txt + +### PYTHON +find /opt/sco/resources/python/ -type d -iname "packages" | while read packages_dir; do + parent_dir=$(dirname "$packages_dir") + req_file="$parent_dir/requirements.txt" + if [ -f "$req_file" ]; then + pip install -q --no-index --find-links="$packages_dir" -r "$req_file" + fi +done + +### DOCKER IMAGES +mkdir -p /tmp/images/ +find /opt/sco/resources/images/ -type f -name '*.gz' -exec sh -c 'gunzip -c "$1" > /tmp/images/"$(basename "${1%.gz}")"' _ {} \; +find /tmp/images/ -type f -iname "*.tar" -exec docker load -i {} \; + +### INIT CPI-SERVER DATABASES AND LOGS FOLDERS +mkdir -p /opt/sco/backend/docker/data/cpi-server/database +mkdir -p /opt/sco/backend/docker/data/cpi-server/logs/devices +mkdir -p /opt/sco/backend/docker/data/cpi-server/logs/service +touch /opt/sco/backend/docker/data/cpi-server/database/configuration.db +touch /opt/sco/backend/docker/data/cpi-server/database/connectivity.db +touch /opt/sco/backend/docker/data/cpi-server/database/Identity.db +touch /opt/sco/backend/docker/data/cpi-server/database/transaction.db +touch /opt/sco/backend/docker/data/cpi-server/database/trialdata.db + +### INIT SYMETRIC CONFIGURATION FILE +mkdir -p /opt/sco/backend/docker/data/symmetric +if [ ! -f /opt/sco/backend/docker/data/symmetric/engine.properties ]; then + LOCAL_NODE=$(hostname) + LOCAL_IP=$(ip route get 1.1.1.1 | grep -oP '(?<=src\s)\d+(\.\d+){3}') + cat < /opt/sco/backend/docker/data/symmetric/engine.properties +engine.name=$LOCAL_NODE +group.id=fullmesh +external.id=$LOCAL_NODE +db.driver=com.mysql.cj.jdbc.Driver +db.url=jdbc:mysql://$LOCAL_IP:3306/sco +db.user=root +db.password=U2qs&U6LA9U2 +registration.url= +sync.url=http://$LOCAL_IP:31415/sync/$LOCAL_NODE +start.pulled.job=true +job.purge.period.time.ms=7200000 +EOF +fi diff --git a/data/jenkins/deploy/install.sh.reports b/data/jenkins/deploy/install.sh.reports new file mode 100644 index 0000000..a474f8a --- /dev/null +++ b/data/jenkins/deploy/install.sh.reports @@ -0,0 +1,105 @@ +#!/bin/sh + +### DOWNLOAD MC +if [ ! -f /usr/local/bin/mc ]; then + curl -L https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc + chmod +x /usr/local/bin/mc +fi + +### VALIDATE PARAMETERS +SERVER=minio-api.laoficina1782.com +CUSTOMER="${1:?Uso: $0 }" +SUCURSAL="${2:?Uso: $0 }" +SCO_NUMBER="${3:?Uso: $0 }" + +### STOP SERVICES +systemctl stop scovirtual.service +systemctl stop scoserver.service +systemctl stop scodocker.service + +### UPDATE SOFTWARE +mc --config-dir /tmp/mc-config alias set minio "https://$SERVER" minioadmin LIGTNbUgQr +mc --config-dir /tmp/mc-config mirror --json --remove --overwrite --exclude "backend/docker/data/**" "minio/$CUSTOMER" /opt/sco/ +find /opt/sco/ -type d -exec chmod 755 {} \; +find /opt/sco/ -type f -exec chmod 644 {} \; +chmod -R 755 /opt/sco/frontend/ + +### ENV FILE FOR MONITORING+REPORTING +cat < /opt/sco/backend/docker/.env +SUCURSAL=$SUCURSAL +SCO_NUMBER=$SCO_NUMBER +SCO_CLIENT=$CUSTOMER +EOF +chmod 644 /opt/sco/backend/docker/.env + +### SCO-AGENT DATA DIRS +mkdir -p /opt/sco/backend/docker/data/sco-agent/outbox +mkdir -p /opt/sco/backend/docker/data/sco-agent/state +mkdir -p /opt/sco/backend/docker/data/sco-agent/prom +chmod -R 777 /opt/sco/backend/docker/data/sco-agent/ + +### TMP FOLDER +cp /usr/lib/tmpfiles.d/fs-tmp.conf /etc/tmpfiles.d/ +echo "D /tmp 1777 root root -" > /etc/tmpfiles.d/fs-tmp.conf + +### SERVICE +find /opt/sco/resources/services/ -type f -exec sh -c 'cp "$1" "/etc/systemd/system/$(basename "$1")"' _ {} \; +find /opt/sco/resources/services/ -type f -exec sh -c 'systemctl enable "$(basename "$1")"' _ {} \; +systemctl daemon-reload + +### AUTOSTART +rsync -a --delete /opt/sco/resources/autostart/ /home/sco/.config/autostart/ +chown -R sco:users /home/sco/.config/autostart/* +chmod -R 644 /home/sco/.config/autostart/* + +### CRONTAB +crontab -u root /opt/sco/resources/crontab/root.txt +crontab -u sco /opt/sco/resources/crontab/sco.txt + +### PYTHON +find /opt/sco/resources/python/ -type d -iname "packages" | while read packages_dir; do + parent_dir=$(dirname "$packages_dir") + req_file="$parent_dir/requirements.txt" + if [ -f "$req_file" ]; then + pip install -q --no-index --find-links="$packages_dir" -r "$req_file" + fi +done + +### DOCKER IMAGES +docker rmi -f $(docker images -aq) +mkdir -p /tmp/images/ +find /opt/sco/resources/images/ -type f -name '*.gz' -exec sh -c 'gunzip -c "$1" > /tmp/images/"$(basename "${1%.gz}")"' _ {} \; +find /tmp/images/ -type f -iname "*.tar" -exec docker load -i {} \; + +### INIT CPI-SERVER DATABASES AND LOGS FOLDERS +mkdir -p /opt/sco/backend/docker/data/cpi-server/database +mkdir -p /opt/sco/backend/docker/data/cpi-server/logs/devices +mkdir -p /opt/sco/backend/docker/data/cpi-server/logs/service +touch /opt/sco/backend/docker/data/cpi-server/database/configuration.db +touch /opt/sco/backend/docker/data/cpi-server/database/connectivity.db +touch /opt/sco/backend/docker/data/cpi-server/database/Identity.db +touch /opt/sco/backend/docker/data/cpi-server/database/transaction.db +touch /opt/sco/backend/docker/data/cpi-server/database/trialdata.db + +### INIT SYMETRIC CONFIGURATION FILE +mkdir -p /opt/sco/backend/docker/data/symmetric +if [ ! -f /opt/sco/backend/docker/data/symmetric/engine.properties ]; then + LOCAL_NODE=$(hostname) + LOCAL_IP=$(ip route get 1.1.1.1 | grep -oP '(?<=src\s)\d+(\.\d+){3}') + cat < /opt/sco/backend/docker/data/symmetric/engine.properties +engine.name=$LOCAL_NODE +group.id=fullmesh +external.id=$LOCAL_NODE +db.driver=com.mysql.cj.jdbc.Driver +db.url=jdbc:mysql://$LOCAL_IP:3306/sco +db.user=root +db.password=U2qs&U6LA9U2 +registration.url= +sync.url=http://$LOCAL_IP:31415/sync/$LOCAL_NODE +start.pulled.job=true +job.purge.period.time.ms=7200000 +EOF +fi + +### REBOOT +nohup sh -c 'sleep 5; shutdown -r now' >/dev/null 2>&1 & diff --git a/data/jenkins/history/history.sh b/data/jenkins/history/history.sh new file mode 100644 index 0000000..dfcd37d --- /dev/null +++ b/data/jenkins/history/history.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +if ! docker ps --format '{{.Names}}' | grep -qx sco-agent; then + echo "El contenedor sco-agent no esta corriendo en este equipo" >&2 + exit 1 +fi + +docker exec sco-agent python -m sco_agent.history diff --git a/data/jenkins/jenkins.yaml b/data/jenkins/jenkins.yaml new file mode 100644 index 0000000..3fc2390 --- /dev/null +++ b/data/jenkins/jenkins.yaml @@ -0,0 +1,617 @@ +jenkins: + numExecutors: 2 + nodeMonitors: + - "architecture" + - "clock" + - diskSpace: + freeSpaceThreshold: "0" + freeSpaceWarningThreshold: "0" + - "swapSpace" + - tmpSpace: + freeSpaceThreshold: "0" + freeSpaceWarningThreshold: "0" + - "responseTime" + authorizationStrategy: + loggedInUsersCanDoAnything: + allowAnonymousRead: false + securityRealm: + local: + allowsSignup: false + users: + - id: admin + password: H0l4mund0! + # Agentes permanentes (WebSocket): cada SCO conecta outbound a Jenkins via wss://. + # El deploy (Jenkinsfile.sco) targetea por nombre exacto de nodo (parámetro NODO), + # no por label, por eso los nodos no llevan labelString. + nodes: + - permanent: + name: "ralph-caguas-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-caguas-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-caguas-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-caguas-sco07" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-caguas-sco08" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-caguas-sco09" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-cayey-sco01" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-cayey-sco02" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-cayey-sco03" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-cayey-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-deleste-humacao-sco01" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-deleste-humacao-sco02" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-fajardo-sco07" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-fajardo-sco08" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-fajardo-sco09" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-fajardo-sco10" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-fajardo-sco11" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-fajardo-sco12" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-gurabo-sco03" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-gurabo-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-gurabo-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-gurabo-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-humacao-sco07" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-humacao-sco08" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-humacao-sco09" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-humacao-sco10" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-humacao-sco11" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-humacao-sco12" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-juntos-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-juntos-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-juntos-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-juntos-sco07" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-las-piedras-sco08" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-las-piedras-sco09" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-las-piedras-sco10" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-las-piedras-sco11" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-las-piedras-sco12" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco13" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco14" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco15" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco16" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco17" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco18" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco19" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-montehiedra-sco20" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-naguabo-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-naguabo-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-naguabo-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-naguabo-sco07" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-yabucoa-sco02" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-yabucoa-sco03" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-yabucoa-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-yabucoa-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-yabucoa-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-yabucoa-sco07" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-san-lorenzo-sco03" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-san-lorenzo-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-san-lorenzo-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-san-lorenzo-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-rio-grande-sco01" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-rio-grande-sco02" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-rio-grande-sco03" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-rio-grande-sco04" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-rio-grande-sco05" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "ralph-rio-grande-sco06" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "laboratorio-girboy-sco81" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "laboratorio-girboy-sco82" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "laboratorio-girboy-sco83" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + - permanent: + name: "hisense-santiago-sco01" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + + +unclassified: + location: + url: https://jenkins.laoficina1782.com/ +security: + globalJobDslSecurityConfiguration: + useScriptSecurity: false +jobs: + - script: | + pipelineJob('deploy-sco') { + description('Deploy de install.sh a un nodo SCO puntual, para el cliente seleccionado.') + definition { + cps { + script(new File('/opt/jenkins/Jenkinsfile.sco').text) + sandbox(true) + } + } + } + - script: | + pipelineJob('deploy-sco-reports') { + description('Deploy de install.sh.reports a un nodo SCO puntual, para el cliente+sucursal+sco seleccionado.') + definition { + cps { + script(new File('/opt/jenkins/Jenkinsfile.sco.reports').text) + sandbox(true) + } + } + } + - script: | + pipelineJob('history-sco') { + description('Reprocesa el historial de logs de un nodo SCO puntual via sco-agent.') + definition { + cps { + script(new File('/opt/jenkins/Jenkinsfile.sco.reports.history').text) + sandbox(true) + } + } + } diff --git a/data/jenkins/pipelines/Jenkinsfile.sco b/data/jenkins/pipelines/Jenkinsfile.sco new file mode 100644 index 0000000..5d65f01 --- /dev/null +++ b/data/jenkins/pipelines/Jenkinsfile.sco @@ -0,0 +1,70 @@ + pipeline { + agent none + parameters { + string( + name: 'NODO', + defaultValue: '', + description: 'Nombre exacto del nodo a deployar' + ) + choice( + name: 'CLIENTE', + choices: [ + 'SELECCIONAR_CLIENTE', + 'cencosud-jumbo', + 'censosud-staisabel', + 'changomas-hiper', + 'changomas-super', + 'dibal', + 'hisense', + 'lsco', + 'mbs-econo', + 'mbs-famcoop', + 'mbs-ralphs', + 'mbs-supermax', + 'modatelas', + 'walmart-chile-acuenta', + 'walmart-chile-lider', + 'wm-mexico-bodega', + 'wm-mexico-walmart' + ], + description: 'Cliente para el cual se ejecuta el deploy' + ) + } + stages { + stage('Registro de parámetros') { + agent none + when { expression { params.CLIENTE == null } } + steps { + echo 'Primer build: Jenkins está registrando los parámetros de este Jenkinsfile. No se ejecuta ningún deploy. Volvé a correr el job con "Build with Parameters".' + script { currentBuild.result = 'NOT_BUILT' } + } + } + stage('Preparar scripts') { + agent { label 'built-in' } + when { expression { params.CLIENTE != null } } + steps { + sh 'cp /opt/deploy/install.sh .' + stash name: 'scripts', includes: 'install.sh' + } + } + stage('Deploy') { + agent { label 'built-in' } + when { expression { params.CLIENTE != null } } + steps { + script { + if (!params.NODO?.trim()) { + error "El parámetro NODO es obligatorio" + } + if (params.CLIENTE == 'SELECCIONAR_CLIENTE') { + error "El parámetro CLIENTE es obligatorio" + } + def target = params.NODO.trim() + node(target) { + unstash 'scripts' + sh "bash install.sh '${params.CLIENTE.trim()}'" + } + } + } + } + } +} diff --git a/data/jenkins/pipelines/Jenkinsfile.sco.reports b/data/jenkins/pipelines/Jenkinsfile.sco.reports new file mode 100644 index 0000000..13c6f22 --- /dev/null +++ b/data/jenkins/pipelines/Jenkinsfile.sco.reports @@ -0,0 +1,80 @@ + pipeline { + agent none + parameters { + string( + name: 'NODO', + defaultValue: '', + description: 'Nombre exacto del nodo a deployar' + ) + choice( + name: 'CLIENTE', + choices: [ + 'SELECCIONAR_CLIENTE', + 'cencosud-jumbo', + 'censosud-staisabel', + 'changomas-hiper', + 'changomas-super', + 'dibal', + 'hisense', + 'lsco', + 'mbs-econo', + 'mbs-famcoop', + 'mbs-ralphs', + 'mbs-supermax', + 'modatelas', + 'walmart-chile-acuenta', + 'walmart-chile-lider', + 'wm-mexico-bodega', + 'wm-mexico-walmart' + ], + description: 'Cliente para el cual se ejecuta el deploy' + ) + string( + name: 'SUCURSAL', + defaultValue: '', + description: 'Sucursal del equipo' + ) + string( + name: 'SCO_NUMBER', + defaultValue: '', + description: 'Número de SCO (ej. sco05)' + ) + } + stages { + stage('Registro de parámetros') { + agent none + when { expression { params.CLIENTE == null } } + steps { + echo 'Primer build: Jenkins está registrando los parámetros de este Jenkinsfile. No se ejecuta ningún deploy. Volvé a correr el job con "Build with Parameters".' + script { currentBuild.result = 'NOT_BUILT' } + } + } + stage('Preparar scripts') { + agent { label 'built-in' } + when { expression { params.CLIENTE != null } } + steps { + sh 'cp /opt/deploy/install.sh.reports .' + stash name: 'scripts', includes: 'install.sh.reports' + } + } + stage('Deploy') { + agent { label 'built-in' } + when { expression { params.CLIENTE != null } } + steps { + script { + if (!params.NODO?.trim()) { + error "El parámetro NODO es obligatorio" + } + if (params.CLIENTE == 'SELECCIONAR_CLIENTE') { + error "El parámetro CLIENTE es obligatorio" + } + def target = params.NODO.trim() + node(target) { + unstash 'scripts' + sh "bash install.sh.reports '${params.CLIENTE.trim()}' '${params.SUCURSAL.trim()}' '${params.SCO_NUMBER.trim()}'" + } + } + } + } + } +} diff --git a/data/jenkins/pipelines/Jenkinsfile.sco.reports.history b/data/jenkins/pipelines/Jenkinsfile.sco.reports.history new file mode 100644 index 0000000..d0d204e --- /dev/null +++ b/data/jenkins/pipelines/Jenkinsfile.sco.reports.history @@ -0,0 +1,44 @@ +pipeline { + agent none + + parameters { + string(name: 'NODO', defaultValue: '', description: 'Nombre exacto del agente SCO (ej: ralph-yabucoa-sco05)') + } + + stages { + stage('Registro de parámetros') { + agent none + when { expression { params.NODO == null } } + steps { + echo 'Primer build: Jenkins está registrando los parámetros de este Jenkinsfile. No se ejecuta nada. Volvé a correr el job con "Build with Parameters".' + script { currentBuild.result = 'NOT_BUILT' } + } + } + stage('Preparar scripts') { + agent { label 'built-in' } + when { expression { params.NODO != null } } + steps { + sh 'cp /opt/history/history.sh .' + stash name: 'scripts', includes: 'history.sh' + } + } + stage('Reprocesar historial') { + agent { label 'built-in' } + when { expression { params.NODO != null } } + steps { + script { + if (!params.NODO?.trim()) { + error "El parámetro NODO es obligatorio" + } + def target = params.NODO.trim() + node(target) { + unstash 'scripts' + timeout(time: 30, unit: 'MINUTES') { + sh 'bash history.sh' + } + } + } + } + } + } +} diff --git a/data/mysql/init/01_init.sql b/data/mysql/init/01_init.sql new file mode 100644 index 0000000..35e238f --- /dev/null +++ b/data/mysql/init/01_init.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + cliente VARCHAR(50) NOT NULL, + sco_number VARCHAR(50) NOT NULL, + sucursal VARCHAR(100) NOT NULL, + pos_transaction_id INT NOT NULL, + started_at DATETIME NOT NULL, + end_transaction_at DATETIME NULL, + closed_at DATETIME NOT NULL, + status ENUM('completed', 'canceled') NOT NULL, + item_count INT NOT NULL DEFAULT 0, + total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + payment_type VARCHAR(50) NULL, + payment_amount DECIMAL(10,2) NULL, + split_payment BOOLEAN NOT NULL DEFAULT FALSE, + age_verification_requested BOOLEAN NOT NULL DEFAULT FALSE, + age_verification_value INT NULL, + bag_item_count INT NOT NULL DEFAULT 0, + + -- restrict lower case + CONSTRAINT chk_cliente_lower CHECK (cliente = LOWER(cliente)), + + -- anti-duplicados para el UPSERT + UNIQUE KEY uq_cliente_sucursal_sco_pos (cliente, sucursal, sco_number, pos_transaction_id, (DATE(started_at))), + + -- index para acelerar Grafana + KEY idx_cliente_sucursal_sco_started (cliente, sucursal, sco_number, started_at) +); diff --git a/data/node-exporter/textfile_inventory/sco_registered.prom b/data/node-exporter/textfile_inventory/sco_registered.prom new file mode 100644 index 0000000..435e50e --- /dev/null +++ b/data/node-exporter/textfile_inventory/sco_registered.prom @@ -0,0 +1,70 @@ +sco_registered{sucursal="santiago", sco_number="sco1", cliente="econo"} 1 +sco_registered{sucursal="santiago", sco_number="sco2", cliente="econo"} 1 +sco_registered{sucursal="concepcion", sco_number="sco1", cliente="wallmart"} 1 +sco_registered{sucursal="ciudadela", sco_number="sco1", cliente="econo"} 1 +sco_registered{sucursal="santiago", sco_number="sco01", cliente="hisense"} 1 +sco_registered{sucursal="caguas", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="caguas", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="caguas", sco_number="sco06", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="caguas", sco_number="sco07", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="caguas", sco_number="sco08", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="caguas", sco_number="sco09", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="cayey", sco_number="sco01", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="cayey", sco_number="sco02", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="cayey", sco_number="sco03", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="cayey", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="deleste-humacao", sco_number="sco01", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="deleste-humacao", sco_number="sco02", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="fajardo", sco_number="sco07", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="fajardo", sco_number="sco08", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="fajardo", sco_number="sco09", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="fajardo", sco_number="sco10", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="fajardo", sco_number="sco11", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="fajardo", sco_number="sco12", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="gurabo", sco_number="sco03", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="gurabo", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="gurabo", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="gurabo", sco_number="sco06", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="humacao", sco_number="sco07", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="humacao", sco_number="sco08", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="humacao", sco_number="sco09", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="humacao", sco_number="sco10", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="humacao", sco_number="sco11", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="humacao", sco_number="sco12", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="juntos", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="juntos", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="juntos", sco_number="sco06", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="juntos", sco_number="sco07", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="las-piedras", sco_number="sco08", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="las-piedras", sco_number="sco09", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="las-piedras", sco_number="sco10", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="las-piedras", sco_number="sco11", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco13", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco14", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco15", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco16", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco17", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco18", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco19", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="montehiedra", sco_number="sco20", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="naguabo", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="naguabo", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="naguabo", sco_number="sco06", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="naguabo", sco_number="sco07", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="yabucoa", sco_number="sco02", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="yabucoa", sco_number="sco03", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="yabucoa", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="yabucoa", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="yabucoa", sco_number="sco06", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="yabucoa", sco_number="sco07", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="san-lorenzo", sco_number="sco03", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="san-lorenzo", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="san-lorenzo", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="san-lorenzo", sco_number="sco06", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="rio-grande", sco_number="sco01", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="rio-grande", sco_number="sco02", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="rio-grande", sco_number="sco03", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="rio-grande", sco_number="sco04", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="rio-grande", sco_number="sco05", cliente="mbs-ralphs"} 1 +sco_registered{sucursal="rio-grande", sco_number="sco06", cliente="mbs-ralphs"} 1 + diff --git a/data/prometheus/config/prometheus.yml b/data/prometheus/config/prometheus.yml new file mode 100644 index 0000000..a930f3c --- /dev/null +++ b/data/prometheus/config/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'prometheus-central' + static_configs: + - targets: ['localhost:9090'] + - job_name: 'server-central-node-exporter' + static_configs: + - targets: ['node-exporter:9100'] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..237a5ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,197 @@ +services: + minio: + image: minio/minio:latest + container_name: minio + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: LIGTNbUgQr + ports: + - "9000:9000" # API + - "9001:9001" # Consola web + volumes: + - ./data/minio/data:/data + #- ./data/certs:/root/.minio/certs:ro + networks: + - proxy + + jenkins: + build: + context: ./data/jenkins + dockerfile: Dockerfile + container_name: jenkins-server + restart: unless-stopped + environment: + - JAVA_OPTS=-Djenkins.install.runSetupWizard=false + expose: + - "8080" + volumes: + - ./data/jenkins_home:/var/jenkins_home + - ./data/jenkins/jenkins.yaml:/usr/share/jenkins/ref/jenkins.yaml:ro + - ./data/jenkins/deploy:/opt/deploy:ro + - ./data/jenkins/history:/opt/history:ro + - ./data/jenkins/pipelines/Jenkinsfile.sco:/opt/jenkins/Jenkinsfile.sco:ro + - ./data/jenkins/pipelines/Jenkinsfile.sco.reports:/opt/jenkins/Jenkinsfile.sco.reports:ro + - ./data/jenkins/pipelines/Jenkinsfile.sco.reports.history:/opt/jenkins/Jenkinsfile.sco.reports.history:ro + networks: + - proxy + + npm: + image: jc21/nginx-proxy-manager:latest + container_name: nginx-proxy-manager + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "81:81" + volumes: + - ./data/npm/data:/data + - ./data/npm/letsencrypt:/etc/letsencrypt + networks: + - proxy + + prometheus-central: + image: prom/prometheus:latest + container_name: prometheus_central + restart: unless-stopped + ports: + - "9090:9090" + volumes: + # Archivo de configuracion alojado en data + - ./data/prometheus/config/prometheus.yml:/etc/prometheus/prometheus.yml:ro + # Carpeta donde se guarda el historial de metricas + - ./data/prometheus/data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.enable-remote-write-receiver' + - '--query.lookback-delta=2m' + networks: + - proxy + + prom-label-proxy: + image: quay.io/prometheuscommunity/prom-label-proxy:latest + container_name: prom-label-proxy + restart: unless-stopped + ports: + - "8081:8080" + command: + - "-insecure-listen-address=0.0.0.0:8080" + - "-upstream=http://prometheus-central:9090" + - "-label=cliente" + - "-enable-label-apis" + depends_on: + - prometheus-central + networks: + - proxy + + grafana: + image: grafana/grafana:13.1.1 + container_name: grafana_central + restart: unless-stopped + ports: + - "3000:3000" + environment: + - GF_SERVER_DOMAIN=laoficina1782.com + - GF_SESSION_COOKIE_DOMAIN=.laoficina1782.com + - GF_SECURITY_ALLOW_EMBEDDING=true + - GF_SECURITY_COOKIE_SAMESITE=none + - GF_SECURITY_COOKIE_SECURE=true + - GF_DATAPROXY_CONCURRENT_QUERIES=4 + - GF_DATAPROXY_TIMEOUT=900 + - GF_DATAPROXY_DIAL_TIMEOUT=900 + - GF_DATAPROXY_KEEPALIVE=900 + volumes: + - ./data/grafana/data:/var/lib/grafana + depends_on: + - prometheus-central + networks: + - proxy + + node-exporter: + image: prom/node-exporter:latest + container_name: node-exporter + restart: unless-stopped + networks: + - proxy + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + - ./data/node-exporter/textfile_inventory:/etc/prometheus/textfile_inventory + command: + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + - '--path.rootfs=/rootfs' + - '--collector.textfile.directory=/etc/prometheus/textfile_inventory' + + cloudflared: + image: cloudflare/cloudflared:latest + container_name: cloudflared + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - proxy + + mysql-db: + image: mysql:8.0 + container_name: mysql-db + restart: unless-stopped + # sin exponer puertos, lo maneja ng + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: sco_telemetry + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - ./data/mysql/data:/var/lib/mysql + # script de inicializacion + - ./data/mysql/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - proxy + + central-api: + build: + # Compilamos desde la estructura data/api-central + context: ./data/central-api + dockerfile: Dockerfile + container_name: central-api + restart: unless-stopped + expose: + - "5000" + environment: + - DB_HOST=mysql-db + - DB_PORT=3306 + - DB_USER=${MYSQL_USER} + - DB_PASSWORD=${MYSQL_PASSWORD} + - DB_NAME=sco_telemetry + - ALERTS_LOG_FILE=/data/alerts/alerts.txt + volumes: + - ./data/alerts:/data/alerts + depends_on: + mysql-db: + condition: service_healthy + networks: + - proxy + + # interfaz UI para probar registros + adminer: + image: adminer + container_name: adminer + restart: unless-stopped + ports: + - "8080:8080" + networks: + - proxy + +networks: + proxy: + driver: bridge \ No newline at end of file diff --git a/scripts/GRAFANA_BACKUP_README.md b/scripts/GRAFANA_BACKUP_README.md new file mode 100644 index 0000000..22add66 --- /dev/null +++ b/scripts/GRAFANA_BACKUP_README.md @@ -0,0 +1,150 @@ +# Backup de dashboards de Grafana + +Este servidor corre Grafana en `http://localhost:3000` (puerto expuesto: 3000). +El backup se hace vía la API HTTP de Grafana (no toca los volúmenes/DB de Docker +directamente), exportando cada dashboard como JSON. + +## Archivos involucrados + +| Ruta | Qué es | +|----------------------------------------------------------------|-------------------------------------------| +| `/opt/server/scripts/backup-grafana-dashboards.sh` | Script de backup (root:root, permisos 700 porque contiene la contraseña de Grafana) | +| `/opt/server/backups/grafana_dashboards/` | Carpeta con los `.tar.gz` generados | + +**No está agregado a crontab** (a diferencia de `backup-jenkins-home.sh`). Se +ejecuta manualmente con `sudo` cuando se necesite. + +## Cómo correr el backup manualmente + +```bash +sudo /opt/server/scripts/backup-grafana-dashboards.sh +``` + +Esto genera `/opt/server/backups/grafana_dashboards/grafana_dashboards_YYYYMMDD.tar.gz` +y conserva solo los últimos 4 backups (los más viejos se borran automáticamente, +`KEEP=4` dentro del script). + +### Convención de nombres dentro del tar.gz + +Cada dashboard se guarda como: + +``` +__.json +``` + +Ejemplo: + +``` +Monitoreo__adpjcx6.json +Node_Exporter_Full__rYdddlPWk.json +Node_Exporter_Full_completo__rYdddlPWk3.json +MySQL_Overview__MQWgroiiz.json +``` + +El título se sanitiza (solo `A-Za-z0-9`, resto reemplazado por `_`), y el `uid` +real de Grafana queda al final por si dos títulos sanitizados coinciden. + +### Ver contenido de un backup sin extraerlo + +```bash +sudo tar tzf /opt/server/backups/grafana_dashboards/grafana_dashboards_.tar.gz +``` + +## Cómo restaurar un dashboard + +Cada archivo `.json` dentro del tar tiene esta forma (respuesta cruda de la API +`GET /api/dashboards/uid/{uid}`): + +```json +{ + "meta": { ... }, + "dashboard": { ... } <- esto es lo que Grafana necesita para importar +} +``` + +**IMPORTANTE:** para importar, Grafana necesita únicamente el contenido del +campo `dashboard`, sin el wrapper `meta`. Si se sube/pega el JSON completo +(`meta` + `dashboard`) da el error: + +``` +Invalid or unknown dashboard schema +``` + +### Opción A — Restaurar vía API (rápido, scripteable) + +```bash +sudo bash -c ' +LATEST=$(ls -1t /opt/server/backups/grafana_dashboards/grafana_dashboards_*.tar.gz | head -1) +mkdir -p /tmp/restore +tar xzf "$LATEST" -C /tmp/restore ./.json + +jq "{dashboard: (.dashboard + {id: null}), overwrite: true}" \ + /tmp/restore/.json > /tmp/restore/payload.json + +curl -sf -X POST -H "Content-Type: application/json" \ + -u admin: \ + http://localhost:3000/api/dashboards/db \ + -d @/tmp/restore/payload.json +' +``` + +Notas: +- `id: null` — el `id` interno viejo puede no existir más (ej. si se borró el + dashboard); ponerlo en `null` hace que Grafana cree uno nuevo. +- El `uid` se mantiene igual, así el dashboard vuelve con la misma URL que tenía. +- `overwrite: true` evita el error 412 si el uid ya existiera. +- Respuesta esperada: `{"status":"success", "uid": "...", "url": "...", ...}`. +- Limpiar `/tmp/restore` después (`sudo rm -rf /tmp/restore`). + +### Opción B — Restaurar a mano desde la UI + +1. Extraer solo el campo `dashboard` (sin el wrapper `meta`): + + ```bash + sudo bash -c ' + LATEST=$(ls -1t /opt/server/backups/grafana_dashboards/grafana_dashboards_*.tar.gz | head -1) + mkdir -p /tmp/restore + tar xzf "$LATEST" -C /tmp/restore ./.json + jq ".dashboard" /tmp/restore/.json > /tmp/restore/import.json + ' + ``` + +2. Bajar `/tmp/restore/import.json` a tu equipo si vas a subirlo como archivo + (`scp jenkins-srv:/tmp/restore/import.json .`), o simplemente abrirlo con + `cat`/editor para copiar el contenido. + +3. En la UI de Grafana (`http://172.19.13.204:3000`): + **Dashboards → New → Import → Upload JSON file** (o pegar el contenido en + "Import via panel json"). + +4. Confirmar el nombre/folder propuesto y click **Import**. + +5. Si Grafana avisa que el `uid` ya existe, ofrece la opción de sobrescribir + en el mismo diálogo. + +6. Limpiar `/tmp/restore` cuando termines. + +## Encontrar rápido el uid/título de un dashboard sin extraer todo el tar + +```bash +for f in $(tar tzf .tar.gz | grep '\.json$'); do + echo -n "$f -> " + tar xzOf .tar.gz "$f" | jq -r '.dashboard.title' +done +``` + +(Con la convención de nombres actual esto ya no es necesario para uso normal, +pero sirve como referencia si en algún momento se cambia el formato del script.) + +## Credenciales + +El script usa el usuario admin de Grafana (`admin`) y su contraseña, embebidas +en `/opt/server/scripts/backup-grafana-dashboards.sh`. Por eso el script tiene +permisos `700` (solo root puede leerlo/ejecutarlo), a diferencia de otros +scripts en `/opt/server/scripts/` que son `755`. + +## Probado el (2026-08-05) + +Ciclo completo validado: crear dashboard de prueba → backup → borrar dashboard +→ restaurar (API y manual) → verificar que el contenido vuelve idéntico. Ambos +métodos de restauración funcionan. diff --git a/scripts/backup-grafana-dashboards.sh b/scripts/backup-grafana-dashboards.sh new file mode 100755 index 0000000..e33861a --- /dev/null +++ b/scripts/backup-grafana-dashboards.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +GRAFANA_URL=http://localhost:3000 +GRAFANA_USER=admin +GRAFANA_PASS=holamundo +BACKUP_DIR=/opt/server/backups/grafana_dashboards +KEEP=4 + +mkdir -p "$BACKUP_DIR" + +STAMP=$(date +%Y%m%d) +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +curl -sf -u "$GRAFANA_USER:$GRAFANA_PASS" "$GRAFANA_URL/api/search?type=dash-db" \ + | jq -r '.[].uid' \ + | while read -r uid; do + dashboard_json=$(curl -sf -u "$GRAFANA_USER:$GRAFANA_PASS" "$GRAFANA_URL/api/dashboards/uid/$uid") + title=$(jq -r '.dashboard.title' <<< "$dashboard_json") + safe_title=$(echo "$title" | tr -c 'A-Za-z0-9' '_' | sed 's/_\+/_/g; s/^_//; s/_$//') + echo "$dashboard_json" > "$TMP_DIR/${safe_title}__${uid}.json" + done + +tar czf "$BACKUP_DIR/grafana_dashboards_$STAMP.tar.gz" -C "$TMP_DIR" . + +ls -1t "$BACKUP_DIR"/grafana_dashboards_*.tar.gz | tail -n +$((KEEP + 1)) | xargs -r rm -- diff --git a/scripts/backup-jenkins-home.sh b/scripts/backup-jenkins-home.sh new file mode 100755 index 0000000..dfe27da --- /dev/null +++ b/scripts/backup-jenkins-home.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +SRC_DIR=/opt/server/data +BACKUP_DIR=/opt/server/backups/jenkins_home +KEEP=4 + +mkdir -p "$BACKUP_DIR" + +STAMP=$(date +%Y%m%d) +tar czf "$BACKUP_DIR/jenkins_home_$STAMP.tar.gz" -C "$SRC_DIR" jenkins_home + +ls -1t "$BACKUP_DIR"/jenkins_home_*.tar.gz | tail -n +$((KEEP + 1)) | xargs -r rm --