Implementar MARGARET - Log de entrada CORP
- app.py: API Flask con endpoints /health, /ingest, /query, /list - Dockerfile y docker-compose.yml para despliegue - init.sql para crear tabla margaret_log - Autenticacion via X-Auth-Key (h_instancia) - Almacenamiento en R2 y PostgreSQL Desplegado en CORP (92.112.181.188:5051)
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
H_INSTANCIA=your_h_instancia_here
|
||||||
|
DB_HOST=172.17.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=corp
|
||||||
|
DB_USER=corp
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
|
||||||
|
R2_ACCESS_KEY=your_access_key
|
||||||
|
R2_SECRET_KEY=your_secret_key
|
||||||
|
R2_BUCKET=corp
|
||||||
|
PORT=5051
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.h_instancia
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 5051
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5051", "--workers", "2", "--timeout", "120", "app:app"]
|
||||||
81
README.md
81
README.md
@@ -1,7 +1,6 @@
|
|||||||
# MARGARET
|
# MARGARET
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
**Log de entrada CORP - Sistema TZZR**
|
**Log de entrada CORP - Sistema TZZR**
|
||||||
|
|
||||||
@@ -9,46 +8,72 @@
|
|||||||
|
|
||||||
Secretaria de entrada para CORP (servidor empresarial). Variante de CLARA con funcionalidades adicionales.
|
Secretaria de entrada para CORP (servidor empresarial). Variante de CLARA con funcionalidades adicionales.
|
||||||
|
|
||||||
## Posición en el Flujo
|
## Posicion en el Flujo
|
||||||
|
|
||||||
```
|
```
|
||||||
PACKET (App) ──► MARGARET ──► MASON ──► FELDMAN
|
PACKET (App) --> MARGARET --> MASON --> FELDMAN
|
||||||
│
|
|
|
||||||
└──► R2 (archivos)
|
└--> R2 (archivos)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comparación
|
## Endpoints
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/health` | Estado del servicio |
|
||||||
|
| POST | `/ingest` | Recibir contenedor |
|
||||||
|
| GET | `/query/<h_entrada>` | Consultar por hash |
|
||||||
|
| GET | `/list` | Listar entradas |
|
||||||
|
|
||||||
|
## Autenticacion
|
||||||
|
|
||||||
|
Todas las rutas (excepto `/health`) requieren:
|
||||||
|
```
|
||||||
|
X-Auth-Key: {h_instancia}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# En CORP
|
||||||
|
cd /opt/margaret
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env con credenciales
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuracion
|
||||||
|
|
||||||
|
Ver `.env.example` para variables requeridas:
|
||||||
|
- `H_INSTANCIA`: Hash unico de CORP
|
||||||
|
- `DB_*`: Credenciales PostgreSQL
|
||||||
|
- `R2_*`: Credenciales Cloudflare R2
|
||||||
|
|
||||||
|
## Base de Datos
|
||||||
|
|
||||||
|
Ejecutar `init.sql` en PostgreSQL:
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql -d corp -f init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparacion
|
||||||
|
|
||||||
| Aspecto | CLARA (DECK) | MARGARET (CORP) |
|
| Aspecto | CLARA (DECK) | MARGARET (CORP) |
|
||||||
|---------|--------------|-----------------|
|
|---------|--------------|-----------------|
|
||||||
| Servidor | Personal | Empresarial |
|
| Servidor | Personal | Empresarial |
|
||||||
| Log | Inmutable | Inmutable |
|
| Log | Inmutable | Inmutable |
|
||||||
| Extras | - | + NOTARIO (certificación) |
|
| Puerto | 5051 | 5051 |
|
||||||
|
| Bucket R2 | deck | corp |
|
||||||
|
|
||||||
## Función
|
## Funcion
|
||||||
|
|
||||||
1. Recibe contenedor de PACKET
|
1. Recibe contenedor de PACKET
|
||||||
2. Envía archivos a R2
|
2. Envia archivos a R2
|
||||||
3. Registra metadata + ubicación R2
|
3. Registra metadata + ubicacion R2
|
||||||
4. **NO añade información**
|
4. **NO agrega informacion**
|
||||||
5. **NO procesa**
|
5. **NO procesa**
|
||||||
6. **NO modifica**
|
6. **NO modifica**
|
||||||
|
|
||||||
## Identificador
|
|
||||||
|
|
||||||
```
|
|
||||||
h_instancia = SHA-256(seed único de CORP)
|
|
||||||
```
|
|
||||||
|
|
||||||
Mismo hash para:
|
|
||||||
- Autenticación (`X-Auth-Key`)
|
|
||||||
- Biblioteca privada
|
|
||||||
- Prefijo R2
|
|
||||||
|
|
||||||
## Arquitectura
|
|
||||||
|
|
||||||
Ver documentación completa en [contratos-comunes/architecture](https://git.tzzr.me/tzzr/contratos-comunes/src/branch/main/architecture/01-clara-margaret.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Componente del sistema TZZR*
|
*Componente del sistema TZZR - Implementado 2025-12-24*
|
||||||
|
|||||||
254
app.py
Normal file
254
app.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
MARGARET - Log de entrada CORP
|
||||||
|
Servicio inmutable para recibir datos de PACKET
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Configuración desde variables de entorno
|
||||||
|
H_INSTANCIA = os.getenv('H_INSTANCIA')
|
||||||
|
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||||
|
DB_PORT = os.getenv('DB_PORT', '5432')
|
||||||
|
DB_NAME = os.getenv('DB_NAME', 'corp')
|
||||||
|
DB_USER = os.getenv('DB_USER', 'postgres')
|
||||||
|
DB_PASSWORD = os.getenv('DB_PASSWORD')
|
||||||
|
|
||||||
|
# Cloudflare R2
|
||||||
|
R2_ENDPOINT = os.getenv('R2_ENDPOINT')
|
||||||
|
R2_ACCESS_KEY = os.getenv('R2_ACCESS_KEY')
|
||||||
|
R2_SECRET_KEY = os.getenv('R2_SECRET_KEY')
|
||||||
|
R2_BUCKET = os.getenv('R2_BUCKET', 'corp')
|
||||||
|
|
||||||
|
# Cliente S3 (compatible con R2)
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=R2_ENDPOINT,
|
||||||
|
aws_access_key_id=R2_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=R2_SECRET_KEY,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Obtener conexión a PostgreSQL"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth(request):
|
||||||
|
"""Verificar autenticación mediante X-Auth-Key"""
|
||||||
|
auth_key = request.headers.get('X-Auth-Key')
|
||||||
|
if not auth_key or auth_key != H_INSTANCIA:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_r2(file_data, file_path):
|
||||||
|
"""Subir archivo a R2"""
|
||||||
|
try:
|
||||||
|
s3_client.put_object(
|
||||||
|
Bucket=R2_BUCKET,
|
||||||
|
Key=file_path,
|
||||||
|
Body=file_data
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error subiendo a R2: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health():
|
||||||
|
"""Endpoint de salud"""
|
||||||
|
return jsonify({
|
||||||
|
"service": "margaret",
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/ingest', methods=['POST'])
|
||||||
|
def ingest():
|
||||||
|
"""
|
||||||
|
Endpoint principal para recibir contenedores de PACKET
|
||||||
|
"""
|
||||||
|
if not verify_auth(request):
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
contenedor = request.get_json()
|
||||||
|
|
||||||
|
if not contenedor.get('id'):
|
||||||
|
return jsonify({"error": "missing_id"}), 400
|
||||||
|
|
||||||
|
if not contenedor.get('archivo_hash'):
|
||||||
|
return jsonify({"error": "missing_archivo_hash"}), 400
|
||||||
|
|
||||||
|
h_entrada = contenedor['archivo_hash']
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM margaret_log WHERE h_entrada = %s",
|
||||||
|
(h_entrada,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cur.fetchone():
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "hash_exists"}), 409
|
||||||
|
|
||||||
|
r2_paths = {}
|
||||||
|
if 'archivos' in contenedor:
|
||||||
|
for idx, archivo in enumerate(contenedor['archivos']):
|
||||||
|
if 'data' in archivo:
|
||||||
|
file_data = archivo['data']
|
||||||
|
file_name = archivo.get('nombre', f'archivo_{idx}')
|
||||||
|
r2_path = f"{H_INSTANCIA}/{h_entrada}/{file_name}"
|
||||||
|
|
||||||
|
if upload_to_r2(file_data, r2_path):
|
||||||
|
r2_paths[file_name] = r2_path
|
||||||
|
else:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "r2_upload_failed"}), 500
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO margaret_log (h_instancia, h_entrada, contenedor, r2_paths)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(H_INSTANCIA, h_entrada, Json(contenedor), Json(r2_paths))
|
||||||
|
)
|
||||||
|
|
||||||
|
record_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app.logger.info(f"Contenedor registrado: {record_id} - {h_entrada}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"id": record_id,
|
||||||
|
"h_entrada": h_entrada
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error en /ingest: {e}")
|
||||||
|
return jsonify({"error": "internal_error", "detail": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/query/<h_entrada>', methods=['GET'])
|
||||||
|
def query(h_entrada):
|
||||||
|
if not verify_auth(request):
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, h_entrada, contenedor, r2_paths, created_at
|
||||||
|
FROM margaret_log
|
||||||
|
WHERE h_entrada = %s AND h_instancia = %s
|
||||||
|
""",
|
||||||
|
(h_entrada, H_INSTANCIA)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"id": row[0],
|
||||||
|
"h_entrada": row[1],
|
||||||
|
"contenedor": row[2],
|
||||||
|
"r2_paths": row[3],
|
||||||
|
"created_at": row[4].isoformat()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error en /query: {e}")
|
||||||
|
return jsonify({"error": "internal_error"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/list', methods=['GET'])
|
||||||
|
def list_entries():
|
||||||
|
if not verify_auth(request):
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
limit = int(request.args.get('limit', 50))
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, h_entrada, created_at
|
||||||
|
FROM margaret_log
|
||||||
|
WHERE h_instancia = %s
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
(H_INSTANCIA, limit, offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM margaret_log WHERE h_instancia = %s",
|
||||||
|
(H_INSTANCIA,)
|
||||||
|
)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"h_entrada": row[1],
|
||||||
|
"created_at": row[2].isoformat()
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error en /list: {e}")
|
||||||
|
return jsonify({"error": "internal_error"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = int(os.getenv('PORT', 5051))
|
||||||
|
app.run(host='0.0.0.0', port=port, debug=False)
|
||||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
margaret:
|
||||||
|
build: .
|
||||||
|
container_name: margaret-service
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5051:5051"
|
||||||
|
environment:
|
||||||
|
- H_INSTANCIA=${H_INSTANCIA}
|
||||||
|
- DB_HOST=${DB_HOST}
|
||||||
|
- DB_PORT=${DB_PORT}
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
- R2_ENDPOINT=${R2_ENDPOINT}
|
||||||
|
- R2_ACCESS_KEY=${R2_ACCESS_KEY}
|
||||||
|
- R2_SECRET_KEY=${R2_SECRET_KEY}
|
||||||
|
- R2_BUCKET=${R2_BUCKET}
|
||||||
|
- PORT=5051
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5051/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
23
init.sql
Normal file
23
init.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- MARGARET Log Table
|
||||||
|
-- Deploy on CORP PostgreSQL
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS margaret_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
h_instancia VARCHAR(64) NOT NULL,
|
||||||
|
h_entrada VARCHAR(64) NOT NULL,
|
||||||
|
contenedor JSONB NOT NULL,
|
||||||
|
r2_paths JSONB DEFAULT '{}',
|
||||||
|
estado VARCHAR(20) DEFAULT 'recibido',
|
||||||
|
procesado_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
CONSTRAINT margaret_log_h_entrada_unique UNIQUE (h_entrada)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grant permissions
|
||||||
|
GRANT ALL PRIVILEGES ON TABLE margaret_log TO corp;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO corp;
|
||||||
|
|
||||||
|
-- Index for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_margaret_log_h_instancia ON margaret_log(h_instancia);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_margaret_log_estado ON margaret_log(estado);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_margaret_log_created_at ON margaret_log(created_at);
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
flask==3.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
boto3==1.34.0
|
||||||
Reference in New Issue
Block a user