diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23f279d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ed2493 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +*.pyo +.h_instancia diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fe0ef56 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 430fe11..58a11fb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # MARGARET -![Estado](https://img.shields.io/badge/Estado-PLANIFICADO-yellow) - +![Estado](https://img.shields.io/badge/Estado-IMPLEMENTADO-brightgreen) **Log de entrada CORP - Sistema TZZR** @@ -9,46 +8,72 @@ 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 - │ - └──► R2 (archivos) +PACKET (App) --> MARGARET --> MASON --> FELDMAN + | + └--> R2 (archivos) ``` -## Comparación +## Endpoints + +| Metodo | Ruta | Descripcion | +|--------|------|-------------| +| GET | `/health` | Estado del servicio | +| POST | `/ingest` | Recibir contenedor | +| GET | `/query/` | 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) | |---------|--------------|-----------------| | Servidor | Personal | Empresarial | | Log | Inmutable | Inmutable | -| Extras | - | + NOTARIO (certificación) | +| Puerto | 5051 | 5051 | +| Bucket R2 | deck | corp | -## Función +## Funcion 1. Recibe contenedor de PACKET -2. Envía archivos a R2 -3. Registra metadata + ubicación R2 -4. **NO añade información** +2. Envia archivos a R2 +3. Registra metadata + ubicacion R2 +4. **NO agrega informacion** 5. **NO procesa** 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* diff --git a/app.py b/app.py new file mode 100644 index 0000000..bf420bb --- /dev/null +++ b/app.py @@ -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/', 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..672d594 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..5d58316 --- /dev/null +++ b/init.sql @@ -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); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e44dc7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.0.0 +gunicorn==21.2.0 +psycopg2-binary==2.9.9 +boto3==1.34.0