Jenkins + Minio + Prometheus + Grafana v1

This commit is contained in:
2026-09-01 13:42:14 +00:00
commit 05a197b0d0
21 changed files with 2256 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
CLOUDFLARE_TUNNEL_TOKEN=<token>
# MySQL connection
MYSQL_ROOT_PASSWORD=<password>
MYSQL_USER=<user>
MYSQL_PASSWORD=<password>
+32
View File
@@ -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/
+15
View File
@@ -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"}
+14
View File
@@ -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"]
+660
View File
@@ -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 = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Generando Reporte...</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #111217; color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.spinner { border: 4px solid rgba(255,255,255,0.1); width: 60px; height: 60px; border-radius: 50%; border-left-color: #3274d9; animation: spin 1s linear infinite; margin-bottom: 25px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.text { font-size: 1.4rem; font-weight: 400; text-align: center; }
.subtext { font-size: 1rem; color: #8e8e8e; margin-top: 15px; text-align: center; max-width: 400px;}
</style>
</head>
<body>
<div class="spinner"></div>
<div class="text">Construyendo su reporte PDF...</div>
<div class="subtext">Estamos recolectando y procesando los datos de Grafana. Esto tomará entre 1 y 4 minutos dependiendo del tamaño. Por favor, <b>no cierre esta ventana</b>.</div>
<script>
const jobId = "{{job_id}}";
const checkStatus = async () => {
try {
const res = await fetch(`/api/v1/export/status/${jobId}`);
const data = await res.json();
if (data.status === 'done') {
// Redirigir a la descarga real
window.location.href = `/api/v1/export/download/${jobId}`;
// Actualizamos el mensaje
document.body.innerHTML = '<div class="spinner" style="border-left-color: #4caf50; animation: none; transform: rotate(45deg);"></div><div class="text" style="color:#4caf50;">¡Reporte generado con éxito!</div><div class="subtext">Tu descarga ha comenzado. Esta pestaña se cerrará automáticamente...</div>';
// Esperamos 2.5 segundos para asegurar la descarga y cerramos la ventana
setTimeout(() => {
window.close(); // Intenta cerrar la ventana actual
// Fallback de seguridad: si Chrome bloquea el cierre (por no haber sido abierta vía target="_blank" JS), regresamos a Grafana.
setTimeout(() => {
if (document.referrer) {
window.location.href = document.referrer;
} else {
window.history.back();
}
}, 1500);
}, 2500);
} else if (data.status === 'error') {
document.body.innerHTML = '<div class="text" style="color:#e02f44;">Ocurrió un error al generar el reporte:</div><div class="subtext">' + (data.error || 'Fallo desconocido') + '</div>';
} else {
setTimeout(checkStatus, 5000); // Polling cada 5 seg
}
} catch (e) {
setTimeout(checkStatus, 5000);
}
};
setTimeout(checkStatus, 2000);
</script>
</body>
</html>
"""
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/<job_id>', methods=['GET'])
def export_status(job_id):
status_info = get_job_status(job_id)
return jsonify(status_info)
@app.route('/api/v1/export/download/<job_id>', 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)
+7
View File
@@ -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
+18
View File
@@ -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
+82
View File
@@ -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 <customer> <sucursal> <sco_number>}"
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 <<EOF > /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
+105
View File
@@ -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 <customer> <sucursal> <sco_number>}"
SUCURSAL="${2:?Uso: $0 <customer> <sucursal> <sco_number>}"
SCO_NUMBER="${3:?Uso: $0 <customer> <sucursal> <sco_number>}"
### 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 <<EOF > /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 <<EOF > /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 &
+9
View File
@@ -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
+617
View File
@@ -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)
}
}
}
+70
View File
@@ -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()}'"
}
}
}
}
}
}
@@ -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()}'"
}
}
}
}
}
}
@@ -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'
}
}
}
}
}
}
}
+28
View File
@@ -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)
);
@@ -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
+11
View File
@@ -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']
+197
View File
@@ -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
+150
View File
@@ -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:
```
<título_sanitizado>__<uid>.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_<fecha>.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 ./<archivo>.json
jq "{dashboard: (.dashboard + {id: null}), overwrite: true}" \
/tmp/restore/<archivo>.json > /tmp/restore/payload.json
curl -sf -X POST -H "Content-Type: application/json" \
-u admin:<password_de_grafana> \
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 ./<archivo>.json
jq ".dashboard" /tmp/restore/<archivo>.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 <backup>.tar.gz | grep '\.json$'); do
echo -n "$f -> "
tar xzOf <backup>.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.
+27
View File
@@ -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 --
+13
View File
@@ -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 --