commit 1ecfb022b1977887ce563ef9de457afa1400450c Author: Fernando Rivas Date: Thu Aug 13 00:43:15 2026 -0600 bootstrap: iniciar bitácora operativa diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..929199f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a7c1959 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Instalar Zola fijado + uses: taiki-e/install-action@v2 + with: + tool: zola@0.23.3 + fallback: none + + - name: Preparar tema fijado + run: ./scripts/bootstrap-theme.sh + + - name: Formato Go + run: test -z "$(gofmt -l cmd internal)" + + - name: Pruebas Go + run: go test ./... + + - name: Detector de carreras Go + run: go test -race ./... + + - name: Análisis estático Go + run: go vet ./... + + - name: Compilar CLI + run: make build + + - name: Flujo integral de la CLI + run: ./scripts/e2e-local.sh + + - name: Verificar firmas SSH de commits del pull request + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: ./scripts/verify-signatures-ci.sh + + - name: Regenerar y comprobar proyección + run: | + ./bin/clusterlog --root . sync + ./bin/clusterlog --root . sync --check + + - name: Validar contenido y plantillas + run: ./bin/clusterlog --root . validate --require-zola + + - name: Construir sitio + run: zola build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34c6268 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/public/ +/bin/ +/themes/zola.386/ +.DS_Store +*.tmp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b00c97 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# Instrucciones para agentes de programación + +## Propósito + +Use `clusterlog` para registrar trabajo operativo de forma atribuible, validada y reproducible. No edite directamente archivos derivados ni simule una identidad. + +## Reglas obligatorias + +1. Lea `clusterlog --json schema ` antes de construir una entrada nueva. +2. No incluya secretos, tokens, contraseñas, llaves privadas ni datos personales innecesarios. +3. Registre sólo hechos observados. Distinga claramente entre cambio ejecutado, validación ejecutada y recomendación pendiente. +4. Ejecute primero con `--dry-run` cuando el contenido provenga de una sesión larga o incluya comandos. +5. Use siempre `--json` y compruebe el código de salida. +6. Para una escritura final, use `--commit` únicamente cuando el usuario haya autorizado la operación y su mecanismo de firma esté disponible. +7. Nunca exporte, lea ni copie la llave privada. Pase una ruta autorizada o permita que Git/ssh-agent la maneje. +8. No edite `data/generated/site_state.json`; ejecute `clusterlog sync`. +9. No agregue manualmente eventos a un arreglo central. Cada evento debe ocupar su archivo independiente y ser creado por la CLI. +10. Después de escribir, ejecute `clusterlog validate` y devuelva al usuario ID, ruta, advertencias y SHA del commit. + +## Flujo para una memoria de sesión + +```bash +clusterlog --json schema memory > /tmp/memory-schema-envelope.json + +cat > /tmp/memory.json <<'JSON' +{ + "title": "...", + "description": "...", + "tags": [], + "systems": [], + "risk": "low", + "review_required": true, + "context": "...", + "changes": [], + "commands": [], + "validation": [], + "rollback": "...", + "agent": "" +} +JSON + +clusterlog --root . --json --dry-run --ssh-key "$CLUSTERLOG_SSH_KEY" \ + memory create --from-json /tmp/memory.json + +clusterlog --root . --json --commit --ssh-key "$CLUSTERLOG_SSH_KEY" \ + memory create --from-json /tmp/memory.json + +clusterlog --root . --json validate +clusterlog --root . --json verify commit HEAD +``` + +## Calidad de una memoria + +Una memoria útil responde: + +- ¿Cuál era el contexto o síntoma? +- ¿Qué se cambió exactamente? +- ¿Qué comandos son relevantes, con valores sensibles redactados? +- ¿Qué resultado se observó? +- ¿Cómo se validó? +- ¿Qué riesgo quedó abierto? +- ¿Cómo se revierte? +- ¿Qué manual o documento debe actualizarse? + +No convierta salidas extensas de terminal en el cuerpo. Resuma y enlace evidencia aprobada. + +## Comportamiento ante errores + +- `identity_not_found`: deténgase; no elija otra identidad por aproximación. +- `possible_secret`: redacte y repita el dry-run; no fuerce la escritura. +- `validation_failed`: presente los códigos y rutas concretas. +- `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse. +- `task_has_running_timers`: identifique a los actores; no cierre o borre eventos. + +## Cambios al código del proyecto + +Antes de entregar: + +```bash +gofmt -w cmd internal +go test ./... +go vet ./... +make build +./bin/clusterlog --root . sync +./bin/clusterlog --root . sync --check +./bin/clusterlog --root . validate --skip-zola +``` + +Cuando Zola esté disponible: + +```bash +./scripts/bootstrap-theme.sh +./bin/clusterlog --root . validate --require-zola +zola build +``` + +Mantenga compatibilidad del JSON. Cualquier cambio de contrato debe actualizar `schema.go`, `CLI.md`, ejemplos y pruebas. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0d2eca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 0.1.0 — 2026-08-12 + +- Estructura Zola con Documentación, Manuales, Memorias, Proyectos, Tareas y Revisión. +- CLI `clusterlog` orientada a humanos y agentes. +- Identidad por huella SSH y commits firmados opcionales. +- Revisión por hash y conjunto capturado de administradores. +- Eventos independientes para revisiones y tiempo de tareas. +- Proyección regenerable para el sitio. +- Validación, escaneo heurístico de secretos, pruebas y CI. +- Estrategia de compatibilidad fijada para `zola.386`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..23151ad --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# Claude Code: operación de la bitácora + +Sigue primero `AGENTS.md`. Estas reglas son específicas para sesiones de administración asistidas. + +## Al iniciar + +- Confirma la raíz con `clusterlog --root . --json identity` usando la llave proporcionada por el entorno. +- Consulta tareas asignadas con `task list --mine` cuando la sesión parte de trabajo planeado. +- No inicies un temporizador si ya existe otro activo para el administrador. + +## Durante la sesión + +- Mantén notas temporales fuera del repositorio hasta redactarlas y eliminar secretos. +- Separa observación, hipótesis, acción y validación. +- No registres como ejecutado un comando sólo porque fue sugerido. +- Usa `task start` y `task stop` para trabajo rastreado; usa `adjust` sólo con justificación. + +## Al cerrar + +- Prepara una memoria cuando hubo un cambio, corrección, diagnóstico significativo o aprendizaje reutilizable. +- Incluye un rollback accionable para riesgo `high` o `critical`. +- Actualiza o crea un manual cuando el procedimiento deba repetirse. +- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada. +- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado. + +## Formato de respuesta al usuario + +```text +Resultado: <éxito o error verificado> +Tarea: +Memoria: +Archivos: +Validación: +Commit: +Pendientes: +``` + +No ocultes un fallo de firma o validación detrás de una descripción optimista. diff --git a/CLI.md b/CLI.md new file mode 100644 index 0000000..264c1ce --- /dev/null +++ b/CLI.md @@ -0,0 +1,368 @@ +# Referencia de `clusterlog` + +## 1. Forma general + +```text +clusterlog [opciones globales] [opciones del comando] +``` + +Las opciones globales deben colocarse **antes** del comando. + +| Opción | Efecto | +|---|---| +| `--root DIR` | raíz del repositorio; si se omite, la CLI busca el proyecto desde el directorio actual | +| `--json` | salida estructurada y estable para agentes | +| `--ssh-key PATH` | llave pública o ruta base usada para resolver identidad y firma | +| `--commit` | crea un commit Git SSH firmado con sólo los archivos de la operación | +| `--dry-run` | valida y calcula el resultado sin escribir | + +También puede definirse `CLUSTERLOG_SSH_KEY`. + +## 2. Contrato JSON + +Éxito: + +```json +{ + "ok": true, + "command": "memory.create", + "data": {}, + "warnings": [] +} +``` + +Error: + +```json +{ + "ok": false, + "error": { + "kind": "identity_not_found", + "message": "ninguna llave SSH local coincide con un administrador activo", + "details": {} + } +} +``` + +Un agente debe usar el código del proceso como decisión primaria y `error.kind` para tratamiento específico. No debe inferir éxito por texto parcial. + +## 3. Códigos de salida + +| Código | Significado | +|---:|---| +| `0` | éxito | +| `2` | uso, flags o entrada inválida | +| `3` | identidad no encontrada o no autorizada | +| `4` | validación, firma o contenido inválido | +| `5` | conflicto de estado | +| `6` | operación Git fallida | +| `10` | error interno no clasificado | + +## 4. Identidad + +```bash +clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub identity +``` + +La respuesta incluye el administrador, la huella, la ruta pública y la ruta que Git intentará usar para firmar. No prueba posesión hasta que exista una firma válida. + +## 5. Administradores + +### Agregar + +```bash +clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub admin add \ + --id ana \ + --name "Ana Torres" \ + --email ana@example.org \ + --key ~/.ssh/id_ed25519.pub \ + --roles owner,admin +``` + +Reglas: + +- El primer administrador obtiene `owner` aunque no se incluya explícitamente. +- Después del primero, sólo un `owner` puede agregar administradores. +- No se aceptan IDs, correos o huellas duplicados. +- `data/admins.json` y `config/allowed_signers` se actualizan juntos. + +### Listar + +```bash +clusterlog --root . --json admin list +clusterlog --root . --json admin list --active +``` + +## 6. Memorias + +### Consultar esquema + +```bash +clusterlog --json schema memory +``` + +### Crear por JSON + +```bash +cat examples/memory.json | clusterlog \ + --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + memory create --from-json - +``` + +### Crear por flags + +```bash +clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub memory create \ + --title "Rotar certificado del API" \ + --description "Renovación programada y comprobación de clientes" \ + --tags tls,certificados \ + --systems kube-apiserver \ + --risk medium \ + --ticket CHG-1042 \ + --duration 45m \ + --agent claude-code \ + --body-file memoria.md +``` + +Opciones específicas: + +- `--title` +- `--description` +- `--tags` +- `--systems` +- `--risk low|medium|high|critical` +- `--ticket` +- `--review` (por defecto `true`) +- `--review-policy all-active|all-active-except-author` +- `--duration` en minutos, `90m` o `2h` +- `--agent` +- `--body-file ARCHIVO|-` +- `--from-json ARCHIVO|-` + +Cuando se combinan JSON y flags, los flags que se proporcionen explícitamente tienen precedencia. + +## 7. Revisión + +### Listar pendientes + +```bash +clusterlog --root . --json review list +clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine +``` + +`--mine` requiere identidad y devuelve sólo memorias cuyo conjunto pendiente contiene al administrador actual. + +### Marcar la versión actual + +```bash +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + review mark MEM-20260812-... --note "Revisado; sin objeciones" +``` + +El evento guarda el SHA-256 del contenido actual. Repetir el comando sobre el mismo hash es idempotente; editar la memoria hace que el evento anterior deje de contar. + +## 8. Tareas + +### Crear + +```bash +cat examples/task.json | clusterlog \ + --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task create --from-json - +``` + +Flags disponibles: + +- `--title`, `--description` +- `--project PRJ-...` +- `--priority low|medium|high|urgent` +- `--assignees ana,luis` +- `--estimate 120m` +- `--due YYYY-MM-DD` +- `--tags`, `--systems` +- `--objective` +- `--acceptance` separado por comas +- `--notes` +- `--body-file ARCHIVO|-` +- `--from-json ARCHIVO|-` + +### Iniciar y detener tiempo + +```bash +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task start TSK-... --note "Diagnóstico inicial" + +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task stop TSK-... --note "Prueba completada" +``` + +Un administrador sólo puede mantener un temporizador activo. Los eventos se ordenan por fecha y tipo para producir un total reproducible. + +### Ajustar tiempo + +```bash +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task adjust TSK-... --minutes 20 --note "Trabajo previo no registrado" + +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task adjust TSK-... --minutes -10 --note "Corregir doble conteo" +``` + +El total efectivo nunca puede quedar negativo. + +### Cambiar estado + +```bash +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task status TSK-... --status blocked --note "Esperando refacción" +``` + +Estados admitidos aquí: `backlog`, `todo`, `in_progress`, `blocked`, `cancelled`. Para finalizar use `task done`. + +### Completar + +```bash +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + task done TSK-... --note "Criterios de aceptación verificados" +``` + +Si el administrador actual mantiene el temporizador, la CLI lo detiene. Rechaza completar si otra persona sigue registrando tiempo en la misma tarea. + +### Listar + +```bash +clusterlog --root . --json task list +clusterlog --root . --json task list --status blocked +clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub task list --mine +clusterlog --root . --json task list --project PRJ-... +``` + +## 9. Proyectos + +### Crear + +```bash +cat examples/project.json | clusterlog \ + --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + project create --from-json - +``` + +Flags: + +- `--title`, `--description`, `--objective` +- `--owners` +- `--target-date YYYY-MM-DD` +- `--status planned|active|blocked|done|cancelled|archived` +- `--tags`, `--systems` +- `--scope`, `--out-of-scope` +- `--milestones`, `--success` +- `--body-file ARCHIVO|-` +- `--from-json ARCHIVO|-` + +### Listar + +```bash +clusterlog --root . --json project list +clusterlog --root . --json project list --status active +``` + +## 10. Documentación y manuales + +```bash +cat examples/manual.json | clusterlog \ + --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + content create --from-json - +``` + +También acepta: + +```bash +clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub content create \ + --section manuales \ + --title "Reemplazar nodo de cómputo" \ + --description "Procedimiento seguro y validaciones" \ + --tags runbook,hardware \ + --systems compute \ + --body-file reemplazo.md \ + --weight 20 +``` + +`section` sólo puede ser `documentacion` o `manuales`. + +## 11. Proyección + +```bash +clusterlog --root . sync +clusterlog --root . sync --check +``` + +`sync` lee Markdown, administradores y eventos; genera `data/generated/site_state.json`. `--check` no escribe y falla cuando el archivo derivado no coincide con la fuente. + +Los agentes no deben editar `data/generated/site_state.json` a mano. + +## 12. Validación + +```bash +clusterlog --root . --json validate +clusterlog --root . --json validate --skip-zola +clusterlog --root . --json validate --require-zola +``` + +Se comprueban, entre otros: + +- registro de administradores y `allowed_signers`; +- IDs, autores, revisores, owners y assignees; +- estados, fechas y referencias de proyectos; +- eventos huérfanos o inconsistentes; +- temporizadores concurrentes y totales negativos; +- secretos probables; +- vigencia de la proyección; +- plantillas y enlaces con `zola check`, cuando está disponible. + +## 13. Firmas + +```bash +clusterlog --root . verify commit HEAD +clusterlog --root . verify commit abc1234 +``` + +La verificación usa: + +```text +config/allowed_signers +namespace: git +``` + +En CI conviene verificar todos los commits nuevos, no sólo `HEAD`. + +## 14. Esquemas para agentes + +```bash +clusterlog --json schema +clusterlog --json schema memory +clusterlog --json schema task +clusterlog --json schema project +clusterlog --json schema content +``` + +Los esquemas siguen JSON Schema 2020-12 y rechazan propiedades adicionales. Esta restricción permite detectar errores ortográficos en nombres de campos. + +## 15. Patrón recomendado para agentes + +```bash +set -euo pipefail + +SCHEMA="$(clusterlog --json schema memory)" +# Construir input.json con base en data del sobre. + +clusterlog --root . --json --dry-run --ssh-key "$KEY" \ + memory create --from-json input.json + +RESULT="$(clusterlog --root . --json --commit --ssh-key "$KEY" \ + memory create --from-json input.json)" + +printf '%s\n' "$RESULT" +clusterlog --root . --json verify commit HEAD +``` + +El agente debe detenerse ante cualquier código distinto de cero y presentar el error al usuario sin ocultarlo. diff --git a/HOJA_DE_PROYECTO.md b/HOJA_DE_PROYECTO.md new file mode 100644 index 0000000..87dd117 --- /dev/null +++ b/HOJA_DE_PROYECTO.md @@ -0,0 +1,484 @@ +# Hoja de proyecto: Bitácora Operativa del Clúster + +**Versión:** 0.1.0 +**Fecha base:** 12 de agosto de 2026 +**Nombre de trabajo:** Bitácora Operativa del Clúster +**CLI:** `clusterlog` +**Tipo de producto:** sitio interno estático + CLI de escritura + repositorio Git auditable + +## 1. Resumen ejecutivo + +El equipo de soporte necesita un lugar único para comprender **cómo está construido el clúster, cómo se opera, qué cambió, qué falta por hacer y qué debe revisar el resto del equipo**. La propuesta convierte un repositorio Git en la fuente de verdad y publica una proyección navegable mediante Zola, con la apariencia del tema `zola.386`. + +La escritura no ocurre en el navegador. Administradores y agentes de programación usan una CLI no interactiva que: + +- identifica al actor por la huella de su llave pública SSH; +- valida el contenido contra contratos predecibles; +- crea Markdown y eventos JSON pequeños; +- actualiza la proyección que consume Zola; +- y, cuando se solicita, deja todo en un commit Git firmado por SSH. + +La página **Revisión** será la agenda inicial de las reuniones semanales. Muestra las memorias aún no revisadas por todos los administradores requeridos y deja de mostrarlas en cuanto la versión actual obtiene todas las confirmaciones. + +## 2. Problema que resuelve + +Hoy, el conocimiento operativo suele quedar fragmentado entre terminales, chats, tickets, wikis, memoria personal y commits técnicos. Esto produce cinco fallas: + +1. No hay una narrativa cronológica de las intervenciones. +2. Los manuales y la arquitectura se desactualizan sin que exista una señal clara. +3. El equipo no sabe qué cambios ya fueron comprendidos por todos. +4. Las tareas y proyectos pierden contexto técnico y tiempo real invertido. +5. Los agentes de IA pueden ayudar a documentar, pero no disponen de un contrato seguro, determinista y atribuible. + +## 3. Visión del producto + +> Después de cada sesión de administración, el equipo debe poder reconstruir qué se intentó, qué se cambió, cómo se validó, cómo se revierte, quién lo ejecutó y quién ya lo revisó, sin depender de la memoria individual. + +## 4. Objetivos + +### Objetivos del MVP + +- Centralizar documentación técnica, runbooks, memorias, proyectos y tareas. +- Hacer que registrar una memoria tome menos fricción que escribir una nota libre en otro sistema. +- Proporcionar a Claude Code y agentes similares un contrato JSON estable y no interactivo. +- Atribuir las escrituras a una identidad registrada por llave SSH. +- Hacer auditable la aprobación mediante commits SSH firmados. +- Construir una cola semanal de revisión reproducible y sin estado oculto en el navegador. +- Medir tiempo real de tareas sin introducir una base de datos. +- Mantener la solución portable: archivos legibles, Git y un generador estático. + +### Objetivos posteriores al MVP + +- Integración con CI corporativa, SSO, tickets y notificaciones. +- Importación de inventario o CMDB. +- Reportes de SLO, incidentes, cambios por sistema y deuda operativa. +- API remota controlada, sólo cuando el trabajo sin conexión y el modelo Git resulten insuficientes. + +### Fuera de alcance del MVP + +- Edición web multiusuario. +- Chat interno, comentarios en tiempo real o presencia. +- Almacenamiento de secretos, credenciales o volcados sensibles. +- Sustitución de monitoreo, ticketing, CMDB o gestión de incidentes. +- Identidad basada únicamente en el comentario textual de una llave. +- Firmar en nombre del usuario sin acceso autorizado a su llave privada o agente SSH. + +## 5. Usuarios y responsabilidades + +### Administrador del clúster + +Crea memorias, documentación, manuales, tareas y proyectos; registra tiempo; revisa cambios ajenos; conserva su llave privada bajo su control. + +### Owner de la bitácora + +Da de alta administradores, mantiene la política de revisión, protege la rama principal y supervisa la validez de firmas. + +### Agente de IA + +Prepara contenido estructurado en nombre del administrador que inició la sesión. Usa la CLI y su salida JSON; no modifica archivos derivados a mano; no inventa resultados; no expone secretos. + +### Lector técnico + +Consulta el sitio, busca por texto, sistema o etiqueta y usa manuales y memorias como contexto operativo. + +## 6. Arquitectura de información + +| Sección | Propósito | Unidad principal | Cadencia esperada | +|---|---|---|---| +| Documentación | Explicar estado y diseño vigentes | documento técnico | cuando cambia la arquitectura | +| Manuales | Describir procedimientos repetibles | runbook/manual | cuando cambia un procedimiento | +| Memorias | Narrar una intervención concreta | entrada cronológica | después de cada sesión o cambio | +| Proyectos | Coordinar un resultado de varias tareas | proyecto | semanal/mensual | +| Tareas | Registrar trabajo accionable y tiempo | tarea | diaria | +| Revisión | Alinear al equipo sobre cambios recientes | proyección de pendientes | semanal | + +### Taxonomías comunes + +- `tags`: tema transversal, por ejemplo `firmware`, `backup`, `seguridad`. +- `systems`: componente real afectado, por ejemplo `etcd`, `spine-01`, `ceph`. +- `categories`: sección funcional. + +### Convención de identificadores + +- Documentación: `DOC-...` +- Manuales: `RUN-...` +- Memorias: `MEM-...` +- Proyectos: `PRJ-...` +- Tareas: `TSK-...` +- Eventos: identificadores únicos propios, almacenados en archivos independientes. + +## 7. Modelo de datos + +### Memoria operativa + +Campos mínimos o recomendados: + +- ID, título, resumen y fecha. +- autor registrado y huella SSH empleada; +- agente que ayudó a redactar, cuando aplique; +- sistemas, etiquetas, ticket y nivel de riesgo; +- contexto, cambios, comandos redactados, validaciones y reversión; +- inicio, fin y duración; +- requisito y política de revisión; +- conjunto capturado de revisores; +- hash SHA-256 de la versión actual, calculado por la CLI. + +### Evento de revisión + +Cada confirmación vive en `data/review-events/.json` e incluye: + +- memoria; +- administrador y huella; +- fecha; +- hash exacto del contenido revisado; +- nota opcional. + +No existe un booleano mutable `reviewed=true`. La revisión se deriva de eventos válidos para el hash actual. + +### Tarea + +La definición Markdown contiene título, objetivo, prioridad, responsables, proyecto, estimación y fecha. Los cambios de ejecución viven como eventos independientes: + +- `start`; +- `stop`; +- `adjust`; +- `status`. + +El tiempo real y el estado efectivo se proyectan al sincronizar. + +### Proyecto + +Incluye objetivo medible, responsables, estado, fecha objetivo, alcance, fuera de alcance, hitos y criterios de éxito. + +## 8. Semántica de revisión + +La revisión debe responder: **“¿qué administradores confirmaron haber comprendido esta versión exacta?”** + +Reglas: + +1. Al crear una memoria se captura el conjunto de revisores aplicable en ese momento. +2. La política puede requerir a todos los activos o a todos excepto el autor. +3. Cada evento registra el hash SHA-256 actual de la memoria. +4. Sólo cuentan eventos cuyo hash coincide con la versión actual. +5. Una edición posterior cambia el hash y vuelve a abrir la memoria automáticamente. +6. Dar de alta a un administrador en el futuro no reabre memorias históricas, porque cada memoria conserva su conjunto capturado. +7. Una memoria sale de `/revision/` cuando no falta ningún revisor. +8. La firma del commit, no la mera presencia de la huella en JSON, aporta la prueba criptográfica de autoría. + +## 9. Identidad y confianza + +### Resolución de identidad + +La CLI busca una llave local en este orden general: + +1. `--ssh-key`; +2. `CLUSTERLOG_SSH_KEY`; +3. `git config user.signingkey` cuando es una ruta; +4. llaves públicas comunes dentro de `~/.ssh`. + +Calcula la huella OpenSSH SHA-256 de la llave pública y la compara con `data/admins.json`. + +### Límite importante + +Conocer una llave pública sólo identifica un registro; no demuestra posesión de la privada. Por ello, el flujo de producción debe exigir: + +- `--commit` en operaciones de escritura; +- commits SSH firmados; +- `config/allowed_signers` generado desde administradores activos; +- verificación en CI; +- protección de rama y revisión de cambios en archivos de identidad. + +## 10. Requisitos funcionales + +### RF-01. Navegación central + +El sitio debe exponer las seis secciones principales y búsqueda global. + +**Aceptación:** desde cualquier página se llega a cada sección en un paso. + +### RF-02. Creación de memorias por CLI + +La CLI debe aceptar flags humanos o JSON por archivo/`stdin`. + +**Aceptación:** `memory create --from-json -` produce una página válida, un ID y una proyección actualizada. + +### RF-03. Atribución + +Toda escritura debe guardar el ID del administrador y la huella resuelta. + +**Aceptación:** una llave no registrada falla con código de identidad y no escribe archivos. + +### RF-04. Revisión por todos + +La página de revisión debe mostrar faltantes y desaparecer la memoria al completar el conjunto requerido. + +**Aceptación:** con dos revisores, la entrada sigue visible tras una confirmación y desaparece tras la segunda. + +### RF-05. Invalidación por edición + +Una revisión anterior no debe contar si cambia la memoria. + +**Aceptación:** modificar el cuerpo y ejecutar `sync` vuelve a mostrar la entrada. + +### RF-06. Tareas y tiempo + +Debe ser posible crear, iniciar, detener, ajustar y completar una tarea. + +**Aceptación:** el tiempo real es la suma reproducible de eventos y no puede ser negativo. + +### RF-07. Proyectos + +Debe ser posible crear y listar proyectos con objetivo y estado. + +**Aceptación:** las tareas pueden enlazar un proyecto existente y la validación rechaza referencias inexistentes. + +### RF-08. Operación por agentes + +Debe existir salida JSON estable, esquemas JSON y `--dry-run`. + +**Aceptación:** un agente puede descubrir el esquema, validar y escribir sin prompts ni parsing de texto humano. + +### RF-09. Validación + +La CLI debe revisar estructura, referencias, eventos, firmas autorizables, secretos probables, proyección y Zola. + +**Aceptación:** `validate --require-zola` falla ante cualquier error bloqueante. + +### RF-10. Auditoría Git + +Una operación con `--commit` debe añadir sólo los archivos que creó o modificó y producir un commit SSH firmado. + +**Aceptación:** `verify commit ` confirma la firma contra `config/allowed_signers`. + +## 11. Requisitos no funcionales + +- **Portabilidad:** Markdown, JSON y Git deben seguir siendo legibles sin la CLI. +- **Determinismo:** la misma fuente genera la misma proyección, salvo la marca informativa `generated_at` controlada por la sincronización. +- **Concurrencia Git:** cada revisión y evento de tarea ocupa un archivo distinto para minimizar conflictos. +- **Seguridad:** no almacenar secretos; sitio no indexable y desplegado en red privada. +- **Accesibilidad:** navegación semántica, uso por teclado y contenido legible sin JavaScript salvo la búsqueda. +- **Mantenibilidad:** plantillas locales separadas del tema visual fijado. +- **Observabilidad:** errores con `kind`, detalles y código de salida estable. +- **Recuperación:** toda proyección puede eliminarse y regenerarse desde la fuente. + +## 12. Diseño de la CLI + +Forma general: + +```text +clusterlog [--root DIR] [--json] [--ssh-key PATH] [--commit] [--dry-run] COMANDO +``` + +Principios: + +- flags globales antes del comando; +- sin prompts interactivos; +- operaciones de creación atómicas; +- `stdin` con `--from-json -` o `--body-file -`; +- respuesta JSON envuelta en `{ok, command, data, warnings}`; +- errores JSON con `kind`, `message`, `details` y código de proceso; +- no sobrescribir eventos existentes; +- comandos idempotentes cuando existe un estado equivalente, por ejemplo revisar dos veces el mismo hash. + +La referencia exhaustiva está en `CLI.md`. + +## 13. Arquitectura técnica + +```text +┌───────────────────────────────┐ +│ Humano / Claude Code / agente │ +└───────────────┬───────────────┘ + │ JSON o flags + v +┌───────────────────────────────┐ +│ clusterlog (Go) │ +│ identidad · validación · Git │ +└───────┬──────────────┬────────┘ + │ │ + │ Markdown │ eventos JSON inmutables + v v +┌───────────────────────────────────────────────┐ +│ Repositorio Git │ +│ content/ · data/ · config/allowed_signers │ +└───────────────────┬───────────────────────────┘ + │ sync + v +┌───────────────────────────────────────────────┐ +│ data/generated/site_state.json │ +│ cola · métricas · tareas · proyectos · memoria│ +└───────────────────┬───────────────────────────┘ + │ load_data + v +┌───────────────────────────────────────────────┐ +│ Zola + recursos visuales de zola.386 │ +└───────────────────────────────────────────────┘ +``` + +### Decisión sobre estado compartido + +No se usa `localStorage`, una cookie ni un formulario estático para las revisiones. Esos mecanismos no representan un consenso compartido ni son auditables. El estado se conserva en Git y Zola sólo lo presenta. + +### Decisión sobre el tema + +Se fijan Sass y archivos estáticos de `zola.386` a una revisión concreta. Las plantillas son locales, porque el tema original precede a cambios mayores de Zola/Tera y el proyecto necesita estructuras específicas para revisión, tareas y proyectos. + +## 14. Alcance del MVP entregado + +### Incluido + +- estructura Zola y navegación; +- tema visual preparado mediante script; +- CLI Go funcional; +- contratos JSON y ejemplos; +- registro de administradores; +- memoria/revisión por hash; +- tareas y eventos de tiempo; +- proyectos; +- proyección para el sitio; +- validación y escaneo de secretos probables; +- commits y verificación SSH; +- pruebas Go; +- CI para Zola 0.23.3; +- documentación de seguridad y operación. + +### Pendiente para producción + +- reemplazar dominio de ejemplo; +- decidir hosting interno y autenticación; +- configurar protección de rama y verificación obligatoria de firmas; +- poblar administradores reales; +- revisar taxonomía de sistemas; +- probar la canalización en el repositorio corporativo; +- definir retención y clasificación de información; +- integrar respaldos del repositorio. + +## 15. Fases recomendadas + +### Fase 0 — Alineación y seguridad + +- Aprobar nombres, secciones y política de revisión. +- Definir qué información está prohibida. +- Seleccionar repositorio y hosting interno. +- Registrar owners iniciales. + +**Salida:** decisiones aprobadas y threat model aceptado. + +### Fase 1 — Piloto técnico + +- Desplegar el prototipo. +- Incorporar de dos a cuatro administradores. +- Importar cinco manuales y diez memorias recientes. +- Ejecutar dos reuniones semanales usando `/revision/`. + +**Salida:** retroalimentación sobre fricción, campos y cola. + +### Fase 2 — Adopción operativa + +- Añadir plantilla a las instrucciones de Claude Code. +- Exigir `validate` y firmas en pull requests. +- Migrar proyectos y tareas activas. +- Establecer responsables de calidad documental. + +**Salida:** bitácora como flujo normal, no como actividad extraordinaria. + +### Fase 3 — Integraciones + +- Enlaces automáticos a tickets. +- Notificaciones de revisión. +- Métricas y reportes de mantenimiento. +- Evaluar una API sólo con casos concretos que Git no resuelva. + +## 16. Riesgos y mitigaciones + +| Riesgo | Consecuencia | Mitigación | +|---|---|---| +| El agente incluye un secreto | exposición en Git y sitio | contrato explícito, escáner heurístico, revisión humana y herramientas corporativas de secret scanning | +| Se suplanta una huella en un JSON | falsa atribución aparente | exigir commit SSH firmado y verificarlo en CI | +| Dos personas editan el mismo estado | conflictos de merge | un archivo por evento y Markdown por entidad | +| Una memoria cambia después de revisarse | consenso obsoleto | hash de contenido en cada revisión | +| Un nuevo admin reabre todo el histórico | cola inmanejable | captura de revisores por memoria | +| El tema deja de compilar | bloqueo de publicación | revisión fijada, plantillas locales y build en CI | +| El sitio se expone a Internet | fuga de conocimiento operativo | VPN/SSO, `robots.txt`, `noindex`, mínimo privilegio | +| La documentación se vuelve burocrática | baja adopción | JSON preparado por agente, campos mínimos y plantillas breves | +| Git crece con muchos eventos | mantenimiento | eventos pequeños, política de archivo posterior y métricas reales antes de rediseñar | + +## 17. Métricas de éxito del piloto + +- Porcentaje de cambios relevantes con memoria dentro de 24 horas. +- Tiempo mediano para registrar una memoria. +- Porcentaje de memorias revisadas antes de la siguiente reunión. +- Antigüedad máxima de un pendiente de revisión. +- Número de manuales actualizados a partir de hallazgos en memorias. +- Diferencia entre tiempo estimado y real por categoría de tareas. +- Errores de validación o secretos bloqueados antes de llegar a la rama principal. + +Las métricas deben usarse para mejorar el sistema, no para evaluar individualmente el desempeño sin contexto. + +## 18. Criterios de aceptación del MVP + +El MVP se considera aceptado cuando: + +1. La CLI compila y todas las pruebas automatizadas pasan. +2. Zola construye el sitio con el tema preparado y las plantillas locales. +3. Un owner puede registrar a un segundo administrador. +4. Una memoria creada por JSON aparece en Memorias y Revisión. +5. Cada administrador puede revisarla desde su propia llave. +6. La memoria desaparece al completar el conjunto de revisores. +7. Editar la memoria vuelve a abrir su revisión. +8. Una tarea registra tiempo y puede completarse sin producir minutos negativos. +9. Una tarea puede enlazarse a un proyecto existente. +10. La validación detecta referencias inválidas, temporizadores inconsistentes y secretos probables. +11. Los commits creados con `--commit` se verifican contra `allowed_signers`. +12. La rama principal exige CI y firmas según la política del repositorio. +13. El sitio está accesible sólo en el entorno interno acordado. + +## 19. Definición de terminado para cada cambio + +Un cambio está terminado cuando: + +- tiene pruebas o una justificación explícita de por qué no aplican; +- no introduce secretos ni datos personales innecesarios; +- actualiza esquema, documentación y ejemplo cuando cambia un contrato; +- ejecuta `go test ./...`, `go vet ./...`, `clusterlog sync --check` y `clusterlog validate`; +- construye con Zola en CI; +- conserva compatibilidad de salida JSON o incrementa versión con migración; +- queda en un commit firmado y revisable. + +## 20. Backlog inmediato priorizado + +### P0 — antes del piloto + +- Configurar dominio interno, hosting y acceso. +- Ejecutar CI en el repositorio real. +- Registrar owners y política de firmas. +- Revisar patrones del escáner de secretos con ejemplos del entorno. +- Crear los primeros manuales críticos: acceso, respaldo, restauración y escalamiento. + +### P1 — durante el piloto + +- Añadir edición controlada de tareas y proyectos mediante eventos o comandos específicos. +- Añadir desactivación/rotación de llaves de administradores. +- Añadir verificación de firma para un rango de commits en CI. +- Generar una vista “mis pendientes”. +- Incorporar relaciones entre memorias y documentación que debe actualizarse. + +### P2 — después de validar adopción + +- Notificaciones de revisión pendientes. +- Importación de tickets. +- Reportes de actividad por sistema y riesgo. +- Archivado por periodo y política de retención. +- API o servicio de escritura sólo si los flujos Git resultan insuficientes. + +## 21. Preguntas de gobierno que el equipo debe cerrar + +- ¿El autor debe revisar su propia memoria o se excluye por defecto? +- ¿Qué riesgo exige revisión síncrona antes del cambio y cuál permite revisión posterior? +- ¿Cuánto tiempo puede permanecer una memoria pendiente? +- ¿Quién puede registrar, desactivar o rotar llaves? +- ¿Qué datos de infraestructura están permitidos en el sitio? +- ¿Qué sistema conserva el ticket canónico y cuál sólo enlaza? +- ¿Cuándo una memoria obliga a actualizar un manual o documento de arquitectura? +- ¿Qué estados de proyecto y tarea necesita realmente el equipo? + +Estas decisiones son configuración y proceso; no requieren convertir el MVP en una aplicación con base de datos. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b6bae72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Proyecto Bitácora Operativa del Clúster + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..132ae74 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +SHELL := /usr/bin/env bash +GO ?= go +ZOLA ?= zola +BINARY ?= bin/clusterlog + +.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean + +all: build + +build: + mkdir -p $(dir $(BINARY)) + $(GO) build -trimpath -o $(BINARY) ./cmd/clusterlog + +test: + $(GO) test ./... + +vet: + $(GO) vet ./... + +e2e: build + ./scripts/e2e-local.sh + +theme: + ./scripts/bootstrap-theme.sh + +sync: build + ./$(BINARY) --root . sync + +sync-check: build + ./$(BINARY) --root . sync --check + +validate: build + ./$(BINARY) --root . validate --require-zola + +validate-local: build + ./$(BINARY) --root . validate --skip-zola + +site: theme sync validate + $(ZOLA) build + +serve: theme sync + $(ZOLA) serve --interface 127.0.0.1 + +smoke: build + ./scripts/smoke.sh + +ci: theme test vet e2e build sync sync-check validate + $(ZOLA) build + +clean: + rm -rf bin public themes/zola.386 diff --git a/README.md b/README.md new file mode 100644 index 0000000..579b3a6 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Bitácora Operativa del Clúster + +Sitio interno y CLI auditable para conservar en un solo lugar la documentación, los manuales, las memorias de cambio, los proyectos, las tareas y la agenda de revisión semanal del equipo que administra un clúster. + +La solución separa responsabilidades: + +```text +administrador o agente IA + | + v + clusterlog CLI ---- identifica por huella SSH + | ---- valida contratos y secretos probables + | ---- escribe Markdown/eventos JSON + | ---- opcionalmente crea commit SSH firmado + v + repositorio Git ---- fuente de verdad y auditoría + | + v + clusterlog sync ---- genera data/generated/site_state.json + | + v + Zola ---- lectura, navegación, taxonomías y búsqueda +``` + +## Qué incluye el MVP + +- Secciones de **Documentación**, **Manuales**, **Memorias**, **Proyectos**, **Tareas** y **Revisión**. +- Memorias operativas con autor, huella SSH, riesgo, sistemas, etiquetas, ticket, duración, validación y reversión. +- Cola de revisión por administrador. Una memoria desaparece cuando todos los revisores capturados para esa entrada la aprobaron. +- Invalidación automática de revisiones cuando cambia el contenido de una memoria. +- Tareas con estimación, fecha objetivo, responsables, estado y tiempo real mediante eventos `start`, `stop` y `adjust`. +- Proyectos con objetivo, responsables, alcance, hitos, criterio de éxito y estado. +- CLI no interactiva, salida JSON, entrada JSON por archivo o `stdin`, modo `--dry-run` y códigos de salida estables. +- Identidad por huella de llave pública SSH y commits Git firmados por SSH con `--commit`. +- Detección heurística de secretos antes de escribir contenido. +- Estado derivado y reproducible para que Zola siga siendo un sitio estático. + +## Requisitos + +- Go 1.23 o posterior. +- Git con soporte de firmas SSH. +- OpenSSH/`ssh-keygen` para firmar y verificar commits. +- Zola 0.23.3 para construir el sitio. +- Acceso al repositorio del tema durante `make theme`, o una copia local indicada con `ZOLA386_SOURCE`. + +## Inicio rápido + +```bash +make theme +make build +./bin/clusterlog --root . sync +./bin/clusterlog --root . validate --skip-zola +zola serve +``` + +El sitio queda disponible en la dirección que muestre Zola. Antes de publicarlo, cambie `base_url` en `zola.toml` y manténgalo detrás de VPN, red privada o autenticación corporativa. + +## Registrar al primer administrador + +La llave pública identifica el registro del administrador. Para una auditoría fuerte, las operaciones posteriores deben usar `--commit` y la rama protegida debe rechazar commits sin firma válida. + +```bash +./bin/clusterlog \ + --root . \ + --ssh-key ~/.ssh/id_ed25519.pub \ + admin add \ + --id ana \ + --name "Ana Torres" \ + --email ana@example.org \ + --key ~/.ssh/id_ed25519.pub \ + --roles owner,admin +``` + +Configure Git para firmar el primer commit completo: + +```bash +git init +git config user.name "Ana Torres" +git config user.email ana@example.org +git config gpg.format ssh +git config user.signingkey ~/.ssh/id_ed25519 +git config commit.gpgsign true +git add . +git commit -S -m "bootstrap: iniciar bitácora operativa" +``` + +A partir de ahí: + +```bash +./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit identity +./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit memory create --from-json examples/memory.json +``` + +`identity` no modifica el repositorio; `--commit` sólo tiene efecto en comandos que escriben archivos. + +## Flujo para Claude Code u otro agente + +1. El agente recopila y redacta los hechos de la sesión, sin incluir credenciales ni valores sensibles. +2. Consulta el contrato: + + ```bash + ./bin/clusterlog --json schema memory + ``` + +3. Prevalida sin escribir: + + ```bash + cat session-memory.json | ./bin/clusterlog \ + --root . --json --dry-run \ + --ssh-key ~/.ssh/id_ed25519.pub \ + memory create --from-json - + ``` + +4. Escribe y firma una unidad atómica: + + ```bash + cat session-memory.json | ./bin/clusterlog \ + --root . --json --commit \ + --ssh-key ~/.ssh/id_ed25519.pub \ + memory create --from-json - + ``` + +5. El agente devuelve al humano el `entry_id`, la ruta y el SHA del commit; nunca afirma que la firma fue válida sin comprobar la salida. + +Las instrucciones completas para agentes están en [`AGENTS.md`](AGENTS.md) y [`CLAUDE.md`](CLAUDE.md). + +## Flujo de revisión semanal + +```bash +# Ver sólo las memorias que le faltan al administrador actual +./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine + +# Marcar la versión actual como revisada y crear un commit firmado +./bin/clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + review mark MEM-... --note "Revisado en reunión semanal" + +# Confirmar la firma del commit actual +./bin/clusterlog --root . verify commit HEAD +``` + +La página `/revision/` contiene la cola pendiente y, como apoyo a la reunión, las tareas abiertas y los proyectos planeados, activos o bloqueados. + +## Comandos de calidad + +```bash +make test # pruebas unitarias e integración de la CLI +make vet # análisis estático de Go +make e2e # flujo completo en una copia temporal +make smoke # contratos, sincronización y validación local +make validate-local # sin requerir Zola +make site # tema + estado + validación completa + sitio +make ci # equivalente local de la canalización +``` + +## Estructura + +```text +cmd/clusterlog/ punto de entrada de la CLI +internal/clusterlog/ modelo, comandos, validación y proyección +content/ Markdown fuente por sección +data/admins.json registro de administradores y política +data/review-events/ un archivo por revisión +data/task-events/ un archivo por evento de tiempo/estado +data/generated/ proyección regenerable para Zola +templates/ plantillas locales compatibles con Zola actual +static/ ajustes visuales y búsqueda +scripts/ preparación del tema y smoke tests +docs/ decisiones arquitectónicas y operación +``` + +## Documentación clave + +- [`HOJA_DE_PROYECTO.md`](HOJA_DE_PROYECTO.md): alcance, requisitos, fases y criterios de aceptación. +- [`CLI.md`](CLI.md): contrato de comandos, JSON y códigos de salida. +- [`SECURITY.md`](SECURITY.md): modelo de confianza y despliegue recomendado. +- [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`. +- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal. +- [`VALIDATION_REPORT.md`](VALIDATION_REPORT.md): pruebas ejecutadas, límites y controles pendientes. +- [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0. + +## Estado del prototipo + +La CLI compila con Go 1.23 y sus pruebas cubren creación por JSON, resolución de identidad, revisión por hash, proyección de tareas y eventos independientes. La construcción de Zola está configurada en la CI incluida. Cuando el registro ya contiene administradores activos, la CI también verifica cada commit del pull request contra `config/allowed_signers`; el scaffold vacío omite esa comprobación durante el bootstrap inicial. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..c7cd060 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,49 @@ +# Notas de entrega — 0.1.0 + +**Fecha:** 2026-08-12 + +## Entrega + +Esta versión convierte la idea inicial en un MVP ejecutable compuesto por: + +- un sitio estático Zola de consulta; +- la CLI `clusterlog` como única interfaz de escritura; +- Markdown y eventos JSON anexables como datos fuente; +- Git y firmas SSH como capa de auditoría; +- una proyección determinista para la cola de revisión, tareas y proyectos. + +## Capacidades principales + +- Secciones de documentación, manuales, memorias, proyectos, tareas y revisión semanal. +- Identidad de administradores mediante huella de llave pública SSH. +- Registro generado de firmantes autorizados para Git. +- Memorias con contexto, cambios, validación, riesgo y reversión. +- Revisión por todos los administradores capturados al crear la memoria. +- Invalidación automática de revisiones tras cualquier cambio de contenido. +- Proyectos con objetivo, alcance, hitos y criterio de éxito. +- Tareas con estado, prioridad, responsables, estimación, vencimiento y tiempo efectivo. +- Eventos independientes para reducir conflictos de Git. +- Contrato JSON, entrada por `stdin`, `--dry-run`, salida estable y códigos de error para agentes de IA. +- Validación de contenido, referencias, proyección y secretos probables. +- Búsqueda local sin servicios externos. +- CI con pruebas, detector de carreras, validación Zola, construcción y control de firmas. + +## Decisiones relevantes + +- El navegador no modifica estado en el MVP; toda escritura pasa por la CLI y por Git. +- La huella pública atribuye una identidad registrada, pero sólo una firma Git verificada demuestra posesión autorizada de la llave. +- Cada revisión está ligada al SHA-256 de la versión exacta de la memoria. +- La lista de revisores se captura al crear la memoria para que altas futuras no reabran historia antigua. +- El tema `zola.386` aporta estilos y recursos, mientras las plantillas se mantienen localmente para aislar cambios de compatibilidad de Zola. + +## Inicio recomendado + +```bash +make theme +make build +./bin/clusterlog --root . sync +./bin/clusterlog --root . validate --skip-zola +zola serve +``` + +Antes de uso operativo, complete los pendientes de producción descritos en `HOJA_DE_PROYECTO.md`, `SECURITY.md` y `VALIDATION_REPORT.md`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..edd35ac --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,126 @@ +# Seguridad y modelo de confianza + +## Alcance + +Este repositorio contiene conocimiento operativo potencialmente sensible. Debe tratarse como un sistema interno aun cuando el resultado sea un sitio estático. + +## Garantías que sí busca el diseño + +- Atribución de contenido a un administrador registrado por huella de llave pública SSH. +- Prueba criptográfica de autoría cuando la operación queda en un commit SSH firmado y autorizado. +- Historial de cambios, revisiones y tiempo dentro de Git. +- Revisión ligada al hash exacto de una memoria. +- Separación entre fuente y proyección derivada. +- Validación de referencias, estados y secuencias de eventos. +- Bloqueo heurístico de patrones frecuentes de secretos. + +## Lo que una huella pública no demuestra + +Un archivo JSON puede copiar una huella conocida. La huella por sí sola sirve para resolver una identidad, pero **no demuestra posesión de la llave privada**. La confianza fuerte exige verificar la firma del commit que introdujo ese archivo. + +Por ello, el despliegue de producción debe: + +1. exigir commits firmados en la rama protegida; +2. ejecutar `clusterlog verify commit` o una verificación equivalente en CI; +3. limitar quién puede modificar `data/admins.json` y `config/allowed_signers`; +4. requerir revisión de owner para altas, bajas y rotaciones de llaves; +5. conservar logs del proveedor Git. + +## Llaves + +- Sólo se guardan llaves **públicas**. +- Nunca copie una llave privada al repositorio ni a la entrada JSON de un agente. +- `--ssh-key` puede apuntar a la `.pub` para identidad; para `--commit`, la CLI intenta localizar la contraparte privada cuando está disponible. +- En estaciones administradas, prefiera `ssh-agent` o un dispositivo de hardware según la política local. +- Rote una llave comprometida de inmediato y revoque su acceso en el proveedor Git. + +El MVP no incluye todavía un comando de rotación/desactivación. Hágalo mediante un cambio revisado por owners en `data/admins.json`, regenere `config/allowed_signers` con la CLI o una migración controlada y documente el incidente. + +## Secretos + +El escáner integrado es una defensa adicional, no una garantía. Antes de guardar una memoria: + +- elimine tokens, contraseñas, cookies, llaves privadas y cadenas de conexión; +- redacte valores en comandos y salidas; +- no copie volcados completos de configuración; +- use identificadores de un gestor de secretos, nunca el valor; +- ejecute el secret scanner corporativo en pre-commit y CI. + +Una vez que un secreto entra a Git, borrarlo del archivo actual no basta: debe revocarse y, si la política lo exige, reescribirse el historial. + +## Sitio interno + +El proyecto incluye `robots.txt` y `noindex`, pero estos controles **no son autenticación**. Despliegue detrás de al menos uno de los siguientes: + +- VPN o red de administración; +- proxy con SSO y MFA; +- hosting privado con control de acceso; +- segmentación y listas de acceso. + +No publique el directorio `public/` en un bucket o servicio accesible anónimamente. + +## Contenido prohibido o restringido + +No almacene: + +- credenciales o material de autenticación; +- datos personales no necesarios; +- claves privadas, kubeconfigs con tokens o archivos de credenciales cloud; +- dumps de bases de datos; +- inventarios que revelen información clasificada más allá del acceso autorizado; +- instrucciones destructivas sin contexto, validación y reversión. + +Defina una clasificación interna y márquela en la documentación si el entorno lo requiere. + +## Agentes de IA + +Un agente debe operar con los permisos mínimos del usuario y cumplir estas reglas: + +- nunca afirmar que ejecutó o validó algo que no observó; +- no inventar comandos, salidas, tickets, horas ni revisores; +- presentar un `--dry-run` antes de escrituras sensibles; +- usar `--commit` sólo con autorización y acceso legítimo a la firma del usuario; +- no intentar exportar la llave privada; +- redactar secretos antes de llamar la CLI; +- detenerse ante errores de identidad, validación o firma; +- devolver el ID y SHA del commit como evidencia. + +## Protección recomendada de rama + +- Pull request obligatorio. +- CI obligatoria: tests, vet, sync check, validate, Zola build y secret scanning. +- Firmas verificadas. +- Revisión de CODEOWNERS para `data/admins.json`, `config/allowed_signers`, `.github/` y `SECURITY.md`. +- Prohibir force-push y borrado de rama principal. +- Respaldo del repositorio y prueba periódica de restauración. + +Ejemplo de `CODEOWNERS` a adaptar: + +```text +/data/admins.json @equipo/owners-bitacora +/config/allowed_signers @equipo/owners-bitacora +/.github/ @equipo/owners-bitacora +/SECURITY.md @equipo/seguridad @equipo/owners-bitacora +``` + +## Revisión de commits en CI + +`scripts/verify-signatures-ci.sh` recorre el rango del pull request y ejecuta la verificación de cada commit contra `config/allowed_signers`. El scaffold sin administradores activos omite la regla para permitir el bootstrap; desde el primer alta, un commit sin firma SSH autorizada hace fallar la comprobación. + +La protección de rama debe marcar la CI como obligatoria. Para conservar las firmas originales, prefiera una estrategia de merge que no sustituya todos los commits por un commit nuevo sin una identidad autorizada, o configure un bot de merge con una llave explícitamente aprobada. Los cambios de gobierno siguen necesitando revisión de owners además de una firma válida. + +## Respuesta a incidentes + +Ante contenido sensible o firma cuestionable: + +1. retire temporalmente el sitio o restrinja acceso; +2. preserve evidencia y determine el alcance; +3. revoque credenciales expuestas; +4. suspenda la llave o usuario afectado; +5. corrija la rama mediante el procedimiento aprobado; +6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro; +7. actualice manuales y controles preventivos. + +## Reporte de vulnerabilidades + +No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales. diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..b201d30 --- /dev/null +++ b/SHA256SUMS @@ -0,0 +1 @@ +76b832471eead59133fbfffe63dd5e5ef1ec708a2e4c4b9b864d665350616ad5 bin/clusterlog diff --git a/THEME_COMPATIBILITY.md b/THEME_COMPATIBILITY.md new file mode 100644 index 0000000..919311c --- /dev/null +++ b/THEME_COMPATIBILITY.md @@ -0,0 +1,80 @@ +# Compatibilidad con `zola.386` + +## Objetivo + +Conservar la estética retro y responsiva de `lopes/zola.386` sin acoplar la lógica del producto a plantillas históricas del tema. + +## Revisión fijada + +El script `scripts/bootstrap-theme.sh` usa por defecto: + +```text +d9c9726435d1347341f2171f9aa751291f1db93d +``` + +Puede cambiarse de forma explícita: + +```bash +ZOLA386_REF= ./scripts/bootstrap-theme.sh +``` + +Para una copia local u offline: + +```bash +ZOLA386_SOURCE=/ruta/a/zola.386 ./scripts/bootstrap-theme.sh +``` + +## Qué se importa + +- `sass/` +- `static/` +- `theme.toml` +- licencia y README upstream, cuando existen + +## Qué no se importa + +Las plantillas upstream no se copian. El proyecto mantiene plantillas locales en `templates/` para: + +- usar el contrato actual de Zola/Tera; +- cargar `data/generated/site_state.json`; +- presentar memorias, proyectos, tareas y revisión; +- controlar navegación, búsqueda y accesibilidad; +- evitar que una actualización visual cambie la semántica de datos. + +Zola da precedencia a las plantillas locales sobre las del tema, por lo que se conserva el tema como fuente visual y no como capa de aplicación. + +## Actualización del tema + +1. Seleccione un commit, no una rama flotante. +2. Ejecute el script con `ZOLA386_REF`. +3. Revise visualmente todas las secciones. +4. Ejecute: + + ```bash + go test ./... + go vet ./... + make sync + clusterlog --root . validate --require-zola + zola build + ``` + +5. Compare HTML, búsqueda y responsive. +6. Registre la nueva revisión en un commit firmado. + +## Búsqueda en español + +El proyecto genera `search_index.es.json` con `elasticlunr_json`, pero no carga el motor Elasticlunr en el navegador. `static/js/ops-search.js` lee los documentos del índice y hace una coincidencia local normalizada, sin depender de stemmers externos ni de una CDN. Para el tamaño esperado del MVP, esto favorece portabilidad y funcionamiento offline. + +## Zola fijado + +La CI usa Zola `0.23.3`. Actualizar Zola requiere una pull request separada que incluya: + +- revisión de notas de migración; +- `zola check`; +- construcción completa; +- prueba de búsqueda; +- prueba visual de portada, secciones, páginas, taxonomías y revisión. + +## Licencias + +La licencia del tema se conserva dentro de `themes/zola.386/LICENSE` cuando el script lo prepara. La licencia del proyecto no sustituye ni elimina las obligaciones del componente upstream. diff --git a/VALIDATION_REPORT.md b/VALIDATION_REPORT.md new file mode 100644 index 0000000..4be08e7 --- /dev/null +++ b/VALIDATION_REPORT.md @@ -0,0 +1,104 @@ +# Informe de validación del MVP + +**Producto:** Bitácora Operativa del Clúster +**Versión:** 0.1.0 +**Fecha de corte:** 2026-08-12, zona `America/Mexico_City` +**Plataforma local:** Linux `x86_64`, Go 1.23.2, Git 2.47.3, Node.js 22.16.0 + +## Resultado ejecutivo + +El prototipo de la CLI quedó compilado y todas las comprobaciones ejecutables en el entorno local terminaron correctamente. El flujo integral validó el ciclo administrador → memoria → revisión por dos personas → invalidación por edición → nueva revisión → proyecto → tarea → tiempo → cierre. + +La construcción real del sitio con Zola y una firma SSH válida no pudieron ejecutarse en este entorno porque no estaban instalados `zola` ni `ssh-keygen`. Ambos controles están definidos en la canalización de GitHub Actions y deben ejecutarse antes de promover el repositorio a piloto. + +## Comprobaciones ejecutadas + +| Comprobación | Comando | Resultado | +|---|---|---| +| Formato Go | `test -z "$(gofmt -l cmd internal)"` | Correcto | +| Pruebas Go | `go test ./...` | Correcto | +| Detector de carreras | `go test -race ./...` | Correcto | +| Cobertura | `go test -cover ./...` | 32.9 % en `internal/clusterlog` | +| Análisis estático | `go vet ./...` | Correcto | +| Compilación | `make build` | Correcto | +| Flujo integral | `make e2e` | Correcto | +| Contratos rápidos | `make smoke` | Correcto | +| JavaScript | `node --check static/js/ops-search.js` | Correcto | +| Scripts shell | `bash -n scripts/*.sh` | Correcto | +| Proyección | `clusterlog sync` | Sin diferencias | +| Reproducibilidad | `clusterlog sync --check` | Correcto | +| Validación local | `clusterlog validate --skip-zola` | Válido, sin errores ni advertencias | +| Firma no autorizada | prueba negativa de `verify-signatures-ci.sh` | Rechazada correctamente | +| Bootstrap sin administradores | `verify-signatures-ci.sh` | Omisión controlada correcta | + +## Escenario E2E validado + +1. Registra el primer administrador como `owner` y un segundo administrador. +2. Crea una memoria con política `all-active`. +3. Comprueba que ambos administradores aparecen como revisores requeridos. +4. Registra cada revisión en un archivo de evento independiente. +5. Comprueba que la memoria desaparece de la cola al completar las revisiones. +6. Modifica la memoria, recalcula su hash y comprueba que las revisiones anteriores dejan de aplicar. +7. Registra nuevamente las dos revisiones. +8. Crea un proyecto activo y una tarea vinculada. +9. Registra inicio de tiempo, ajuste manual y cierre de la tarea. +10. Comprueba la proyección final, la independencia de eventos y la regeneración determinista. + +## Estado inicial de demostración + +La proyección incluida contiene: + +- `0` administradores activos; +- `0` memorias pendientes de revisión; +- `1` tarea abierta de demostración; +- `1` proyecto planeado de demostración. + +La memoria de ejemplo declara `review_required = false` para no contaminar la primera reunión real. + +## Binario entregado + +- Plataforma: Linux `amd64`. +- Tipo: ejecutable ELF de 64 bits, enlazado estáticamente. +- Ruta: `bin/clusterlog`. +- SHA-256 al cierre de esta validación: se publica en `SHA256SUMS`. + +## Comprobaciones delegadas a CI + +La canalización `.github/workflows/ci.yml` fija Zola 0.23.3 y ejecuta: + +- descarga del tema fijado a una revisión concreta; +- validación con `--require-zola`; +- construcción completa con `zola build`; +- verificación de todos los commits de un pull request contra `config/allowed_signers` cuando ya existen administradores activos. + +Estas comprobaciones están configuradas, pero este informe no afirma que GitHub Actions ya haya ejecutado una corrida. Debe conservarse como requisito de aceptación del primer pull request. + +## Comprobaciones pendientes antes del piloto + +1. Ejecutar la CI en el repositorio remoto y conservar una corrida verde. +2. Crear y verificar al menos un commit firmado con una llave SSH real autorizada. +3. Revisar el sitio generado en escritorio y móvil detrás del mecanismo de acceso interno elegido. +4. Probar el flujo con al menos dos administradores reales y una memoria no demostrativa. +5. Subir la cobertura de lógica crítica, especialmente errores de Git, validación de contenido y colisiones de archivos. +6. Ejecutar revisión de amenazas y acordar protección de rama, propietarios de código y retención. + +## Repetición local + +```bash +make build +make test +make vet +make e2e +make smoke +node --check static/js/ops-search.js +for f in scripts/*.sh; do bash -n "$f"; done +./bin/clusterlog --root . --json sync --check +./bin/clusterlog --root . --json validate --skip-zola +``` + +En un equipo con Zola, OpenSSH y acceso al tema: + +```bash +make theme +make site +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/clusterlog/main.go b/cmd/clusterlog/main.go new file mode 100644 index 0000000..5c00f17 --- /dev/null +++ b/cmd/clusterlog/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "example.org/bitacora-cluster/internal/clusterlog" +) + +func main() { + fs := flag.NewFlagSet("clusterlog", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + root := fs.String("root", "", "raíz del proyecto") + jsonMode := fs.Bool("json", false, "salida JSON estable") + sshKey := fs.String("ssh-key", "", "llave pública SSH o ruta base") + commit := fs.Bool("commit", false, "crea un commit Git SSH firmado") + dryRun := fs.Bool("dry-run", false, "valida sin escribir") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "Uso: clusterlog [opciones globales] [opciones]") + fmt.Fprintln(os.Stderr, "Ejecute clusterlog help para ver los comandos.") + fs.PrintDefaults() + } + if err := fs.Parse(os.Args[1:]); err != nil { + os.Exit(clusterlog.PrintError(os.Stderr, *jsonMode, clusterlog.NewError(clusterlog.ExitUsage, "invalid_global_flags", err.Error(), nil))) + } + app := clusterlog.New(clusterlog.Options{ + Root: *root, JSON: *jsonMode, SSHKey: *sshKey, Commit: *commit, DryRun: *dryRun, + Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, Executable: "clusterlog", + }) + if err := app.Run(fs.Args()); err != nil { + os.Exit(clusterlog.PrintError(os.Stderr, *jsonMode, err)) + } +} diff --git a/config/allowed_signers b/config/allowed_signers new file mode 100644 index 0000000..8fc9b9d --- /dev/null +++ b/config/allowed_signers @@ -0,0 +1,3 @@ +# Archivo generado por clusterlog. Contiene únicamente llaves públicas. +# principal namespaces="git" tipo base64 +fenandos@hotmail.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICKqDgPynfafNBlem/TW9hJZ0Ejp5UFrnUk72hcpJl1v diff --git a/content/_index.md b/content/_index.md new file mode 100644 index 0000000..5fafc98 --- /dev/null +++ b/content/_index.md @@ -0,0 +1,14 @@ ++++ +title = "Bitácora Operativa del Clúster" +description = "Punto único para entender, operar y revisar los cambios técnicos del clúster." +sort_by = "date" +template = "index.html" ++++ + +La bitácora reúne conocimiento estable y actividad operativa en un mismo sitio, sin mezclar sus ciclos de vida: + +- **Documentación** explica cómo está construido el clúster. +- **Manuales** indican cómo ejecutar procedimientos repetibles. +- **Memorias** registran qué se cambió, quién lo hizo y cómo se validó. +- **Proyectos** agrupan objetivos, alcance e hitos. +- **Tareas** muestran trabajo pendiente, responsables y tiempo real. diff --git a/content/documentacion/_index.md b/content/documentacion/_index.md new file mode 100644 index 0000000..a5fe80d --- /dev/null +++ b/content/documentacion/_index.md @@ -0,0 +1,9 @@ ++++ +title = "Documentación" +description = "Arquitectura, decisiones, inventarios, políticas y explicaciones técnicas duraderas." +sort_by = "weight" +template = "section.html" +page_template = "page.html" ++++ + +Aquí vive el conocimiento descriptivo del clúster: el **qué**, el **por qué** y las relaciones entre componentes. diff --git a/content/documentacion/arquitectura-bitacora.md b/content/documentacion/arquitectura-bitacora.md new file mode 100644 index 0000000..889f64e --- /dev/null +++ b/content/documentacion/arquitectura-bitacora.md @@ -0,0 +1,33 @@ ++++ +title = "Arquitectura de la bitácora operativa" +description = "Separación entre contenido legible, eventos de estado, identidad SSH y publicación estática." +date = 2026-08-12T00:00:00Z +authors = ["sistema"] +slug = "arquitectura-bitacora" +template = "page.html" +weight = 10 + +[taxonomies] +categories = ["documentacion"] +tags = ["arquitectura", "git", "zola"] +systems = ["bitacora"] + +[extra] +id = "DOC-DEMO-001" +kind = "documentacion" +author_id = "sistema" +author_fingerprint = "" ++++ + +## Capas + +1. **Contenido fuente:** páginas Markdown con front matter TOML. +2. **Eventos:** revisiones y temporizadores en archivos JSON anexables. +3. **Identidad:** registro de administradores y huellas de llaves públicas SSH. +4. **Auditoría:** commits Git, preferentemente firmados con SSH. +5. **Proyección:** `clusterlog sync` calcula el estado actual para las vistas de Zola. +6. **Publicación:** Zola genera HTML estático, búsqueda, taxonomías y feeds. + +## Regla de diseño + +El sitio web es una proyección de lectura. Las mutaciones se realizan con la CLI, se validan y después se integran mediante Git. diff --git a/content/manuales/_index.md b/content/manuales/_index.md new file mode 100644 index 0000000..db8f5a7 --- /dev/null +++ b/content/manuales/_index.md @@ -0,0 +1,9 @@ ++++ +title = "Manuales" +description = "Runbooks y procedimientos operativos verificables, con prerrequisitos, pasos y reversión." +sort_by = "weight" +template = "section.html" +page_template = "page.html" ++++ + +Los manuales deben ser ejecutables por una persona distinta de quien los escribió y declarar claramente sus condiciones de seguridad. diff --git a/content/manuales/primer-administrador.md b/content/manuales/primer-administrador.md new file mode 100644 index 0000000..2f53f6d --- /dev/null +++ b/content/manuales/primer-administrador.md @@ -0,0 +1,45 @@ ++++ +title = "Registrar al primer administrador" +description = "Inicialización segura del registro de identidad de clusterlog." +date = 2026-08-12T00:00:00Z +authors = ["sistema"] +slug = "registrar-primer-administrador" +template = "page.html" +weight = 10 + +[taxonomies] +categories = ["manuales"] +tags = ["identidad", "ssh", "inicio"] +systems = ["bitacora"] + +[extra] +id = "RUN-DEMO-001" +kind = "manuales" +author_id = "sistema" +author_fingerprint = "" ++++ + +## Prerrequisitos + +- Una llave SSH personal con su archivo público disponible. +- Git inicializado en la raíz del proyecto. +- El binario `clusterlog` compilado. + +## Procedimiento + +```bash +git init +make build +./bin/clusterlog --ssh-key ~/.ssh/id_ed25519 --commit admin add \ + --id ana --name "Ana Operadora" --email ana@example.org +``` + +El primer administrador recibe automáticamente el rol `owner`. Después, sólo un `owner` activo puede registrar a otra persona. + +## Verificación + +```bash +./bin/clusterlog --ssh-key ~/.ssh/id_ed25519.pub identity +./bin/clusterlog validate --skip-zola +git log --show-signature -1 +``` diff --git a/content/memorias/_index.md b/content/memorias/_index.md new file mode 100644 index 0000000..0a74653 --- /dev/null +++ b/content/memorias/_index.md @@ -0,0 +1,10 @@ ++++ +title = "Memorias" +description = "Historia cronológica y auditable de intervenciones, cambios, incidentes y correcciones." +sort_by = "date" +template = "memories.html" +page_template = "memory.html" +generate_feeds = true ++++ + +Cada entrada describe una sesión de trabajo o una unidad de cambio. Las entradas sujetas a revisión aparecen también en **Revisión semanal**. diff --git a/content/memorias/ejemplo-inicial.md b/content/memorias/ejemplo-inicial.md new file mode 100644 index 0000000..ac0736f --- /dev/null +++ b/content/memorias/ejemplo-inicial.md @@ -0,0 +1,47 @@ ++++ +title = "Inicializar la estructura de la bitácora" +description = "Ejemplo de memoria no operativa que explica el formato esperado." +date = 2026-08-12T00:00:00Z +authors = ["sistema"] +slug = "ejemplo-inicial" +template = "memory.html" + +[taxonomies] +categories = ["memorias"] +tags = ["ejemplo", "inicio"] +systems = ["bitacora"] + +[extra] +id = "MEM-DEMO-001" +kind = "memory" +author_id = "sistema" +author_fingerprint = "" +risk = "low" +ticket = "" +review_required = false +review_policy = "all-active" +started_at = "2026-08-12T00:00:00Z" +finished_at = "2026-08-12T00:15:00Z" +duration_minutes = 15 +agent = "scaffold" ++++ + +## Contexto + +Se creó una entrada demostrativa para documentar el contrato de las memorias sin añadirla a la cola de revisión. + +## Cambios realizados + +- Se definió el front matter mínimo. +- Se añadieron taxonomías de etiquetas y sistemas. +- Se declaró explícitamente que el ejemplo no requiere revisión. + +## Validación + +- La página es parseable por la CLI. +- El identificador comienza con `MEM-`. +- No contiene información sensible. + +## Riesgo y reversión + +Eliminar esta página cuando el equipo haya creado su primera memoria real. diff --git a/content/proyectos/_index.md b/content/proyectos/_index.md new file mode 100644 index 0000000..0fc4767 --- /dev/null +++ b/content/proyectos/_index.md @@ -0,0 +1,9 @@ ++++ +title = "Proyectos" +description = "Objetivos compartidos, alcance, responsables, hitos y criterios de éxito." +sort_by = "date" +template = "projects.html" +page_template = "project.html" ++++ + +Los proyectos dan contexto a las tareas y permiten explicar por qué existe cada línea de trabajo. diff --git a/content/proyectos/proyecto-ejemplo.md b/content/proyectos/proyecto-ejemplo.md new file mode 100644 index 0000000..b98bd2c --- /dev/null +++ b/content/proyectos/proyecto-ejemplo.md @@ -0,0 +1,44 @@ ++++ +title = "Adopción de la bitácora operativa" +description = "Proyecto demostrativo para desplegar el flujo de documentación y revisión." +date = 2026-08-12T00:00:00Z +authors = ["sistema"] +slug = "adopcion-bitacora" +template = "project.html" + +[taxonomies] +categories = ["proyectos"] +tags = ["ejemplo", "operaciones"] +systems = ["bitacora"] + +[extra] +id = "PRJ-DEMO-001" +kind = "project" +status = "planned" +owners = [] +target_date = "2026-09-30" +created_by = "sistema" +author_fingerprint = "" ++++ + +## Objetivo + +Conseguir que todas las intervenciones relevantes del equipo queden documentadas, revisadas y localizables en un único repositorio. + +## Alcance + +- Registrar administradores. +- Crear memorias desde sesiones asistidas por IA. +- Celebrar una revisión semanal basada en la cola pendiente. +- Adoptar proyectos y tareas compartidas. + +## Hitos + +- Primer administrador registrado. +- Primera memoria real revisada por todo el equipo. +- Primer mes con cobertura operativa completa. + +## Criterios de éxito + +- Toda memoria de cambio relevante se publica el mismo día. +- Ningún pendiente de revisión supera dos reuniones semanales. diff --git a/content/revision/_index.md b/content/revision/_index.md new file mode 100644 index 0000000..188be64 --- /dev/null +++ b/content/revision/_index.md @@ -0,0 +1,8 @@ ++++ +title = "Revisión semanal" +description = "Memorias que aún requieren confirmación de uno o más administradores." +template = "review.html" +in_search_index = false ++++ + +Esta página es la agenda operativa de la reunión semanal. Una memoria desaparece cuando todos los revisores requeridos han registrado su revisión para el hash actual del contenido. diff --git a/content/tareas/_index.md b/content/tareas/_index.md new file mode 100644 index 0000000..7713a02 --- /dev/null +++ b/content/tareas/_index.md @@ -0,0 +1,9 @@ ++++ +title = "Tareas" +description = "Trabajo visible para el equipo, con prioridad, estimación, fecha objetivo y tiempo acumulado." +sort_by = "date" +template = "tasks.html" +page_template = "task.html" ++++ + +El estado actual y el tiempo real se calculan a partir de eventos anexables; la definición original de la tarea permanece legible en Markdown. diff --git a/content/tareas/tarea-ejemplo.md b/content/tareas/tarea-ejemplo.md new file mode 100644 index 0000000..131a5f8 --- /dev/null +++ b/content/tareas/tarea-ejemplo.md @@ -0,0 +1,36 @@ ++++ +title = "Registrar al equipo inicial" +description = "Tarea demostrativa vinculada al proyecto de adopción." +date = 2026-08-12T00:00:00Z +authors = ["sistema"] +slug = "registrar-equipo-inicial" +template = "task.html" + +[taxonomies] +categories = ["tareas"] +tags = ["ejemplo", "inicio"] +systems = ["bitacora"] + +[extra] +id = "TSK-DEMO-001" +kind = "task" +status = "todo" +priority = "high" +project_id = "PRJ-DEMO-001" +assignees = [] +estimate_minutes = 30 +due = "2026-08-31" +created_by = "sistema" +author_fingerprint = "" ++++ + +## Objetivo + +Registrar las llaves públicas de las personas que administran el clúster y comprobar la resolución de identidad. + +## Criterios de aceptación + +- Existe al menos un `owner` activo. +- Cada administrador tiene una huella SSH única. +- `config/allowed_signers` contiene todas las llaves activas. +- `clusterlog validate --skip-zola` termina sin errores. diff --git a/data/admins.json b/data/admins.json new file mode 100644 index 0000000..e258afb --- /dev/null +++ b/data/admins.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "review_policy": { + "default": "all-active", + "include_author": true + }, + "admins": [ + { + "id": "gen", + "name": "Fernando Rivas", + "email": "fenandos@hotmail.com", + "active": true, + "roles": [ + "admin", + "owner" + ], + "ssh_keys": [ + { + "fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg", + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICKqDgPynfafNBlem/TW9hJZ0Ejp5UFrnUk72hcpJl1v fenan@asu", + "added_at": "2026-08-13T06:42:03.795557478Z" + } + ] + } + ] +} diff --git a/data/generated/site_state.json b/data/generated/site_state.json new file mode 100644 index 0000000..92df20b --- /dev/null +++ b/data/generated/site_state.json @@ -0,0 +1,69 @@ +{ + "version": 2, + "generated_at": "2026-08-13T06:42:03Z", + "source_sha256": "64a404e7ccb669ab6712d92141f5e2446eb3256723075a42bd7037380f285050", + "stats": { + "active_admins": 1, + "pending_reviews": 0, + "open_tasks": 1, + "active_projects": 1 + }, + "review_queue": [], + "recent_memories": [ + { + "entry_id": "MEM-DEMO-001", + "title": "Inicializar la estructura de la bitácora", + "description": "Ejemplo de memoria no operativa que explica el formato esperado.", + "date": "2026-08-12T00:00:00Z", + "author_id": "sistema", + "author_fingerprint": "", + "risk": "low", + "review_required": false, + "review_policy": "all-active", + "reviewer_snapshot": false, + "reviewers": [], + "tags": [ + "ejemplo", + "inicio" + ], + "systems": [ + "bitacora" + ], + "slug": "ejemplo-inicial", + "permalink": "/memorias/ejemplo-inicial/", + "source_path": "content/memorias/ejemplo-inicial.md", + "content_sha256": "46b9387e70d9d9522bb59e1db42a21b4c523f22d50e0d33917a4f2d72ecc7c42" + } + ], + "tasks": [ + { + "task_id": "TSK-DEMO-001", + "title": "Registrar al equipo inicial", + "description": "Tarea demostrativa vinculada al proyecto de adopción.", + "status": "todo", + "priority": "high", + "project_id": "PRJ-DEMO-001", + "assignees": [], + "estimate_minutes": 30, + "actual_minutes": 0, + "due": "2026-08-31", + "running_by": [], + "slug": "registrar-equipo-inicial", + "permalink": "/tareas/registrar-equipo-inicial/", + "source_path": "content/tareas/tarea-ejemplo.md" + } + ], + "projects": [ + { + "project_id": "PRJ-DEMO-001", + "title": "Adopción de la bitácora operativa", + "description": "Proyecto demostrativo para desplegar el flujo de documentación y revisión.", + "status": "planned", + "owners": [], + "target_date": "2026-09-30", + "slug": "adopcion-bitacora", + "permalink": "/proyectos/adopcion-bitacora/", + "source_path": "content/proyectos/proyecto-ejemplo.md" + } + ] +} diff --git a/data/review-events/.gitkeep b/data/review-events/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/task-events/.gitkeep b/data/task-events/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/WEEKLY_REVIEW.md b/docs/WEEKLY_REVIEW.md new file mode 100644 index 0000000..bca036b --- /dev/null +++ b/docs/WEEKLY_REVIEW.md @@ -0,0 +1,84 @@ +# Reunión semanal de revisión operativa + +## Objetivo + +Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad. + +## Preparación individual + +Antes de la reunión, cada administrador ejecuta: + +```bash +clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine +clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub task list --mine +``` + +Debe leer las memorias, anotar preguntas y revisar evidencias. Marcar “revisada” significa comprender el cambio y sus implicaciones; no necesariamente aprobar retrospectivamente una decisión. + +## Agenda sugerida + +### 1. Riesgo alto o crítico + +Abrir primero memorias `critical` y `high`: + +- resultado observado; +- validaciones; +- rollback; +- riesgo residual; +- seguimiento requerido. + +### 2. Resto de memorias pendientes + +Para cada entrada: + +- confirmar sistemas afectados; +- aclarar términos y comandos; +- detectar conocimiento reusable; +- decidir si debe actualizarse un manual o documento; +- crear una tarea cuando exista una acción concreta. + +Cada administrador registra su propia confirmación: + +```bash +clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \ + review mark MEM-... --note "Revisado en reunión semanal" +``` + +No debe existir una sola persona marcando por todo el equipo. + +### 3. Tareas abiertas + +Revisar especialmente: + +- bloqueadas; +- vencidas o próximas a vencer; +- con tiempo real muy superior a la estimación; +- con temporizador activo desde una sesión anterior; +- sin responsable. + +### 4. Proyectos + +Para proyectos planeados, activos o bloqueados: + +- progreso contra objetivo; +- siguiente hito; +- dependencia o riesgo; +- tareas faltantes; +- fecha objetivo. + +### 5. Cierre + +- La cola de memorias debe quedar vacía o con una causa y fecha de seguimiento. +- Toda acción debe convertirse en tarea con responsable. +- Toda mejora repetible debe actualizar un manual. +- Todo cambio de arquitectura debe actualizar Documentación. + +## Evidencia de cierre + +```bash +clusterlog --root . sync --check +clusterlog --root . validate +clusterlog --root . verify commit HEAD +``` + +El secretario de la reunión puede enlazar los IDs discutidos en la minuta corporativa, sin duplicar contenido sensible. diff --git a/docs/adr/0001-static-event-projection.md b/docs/adr/0001-static-event-projection.md new file mode 100644 index 0000000..4272a91 --- /dev/null +++ b/docs/adr/0001-static-event-projection.md @@ -0,0 +1,41 @@ +# ADR-0001: Git como fuente y Zola como proyección + +- **Estado:** aceptada +- **Fecha:** 2026-08-12 + +## Contexto + +El sitio debe mostrar estado compartido —revisiones, tiempo de tareas y estados— aunque Zola genere archivos estáticos. Guardar ese estado en el navegador no sería compartido ni auditable. Introducir una base de datos en el MVP aumentaría operación, permisos, respaldo y superficie de ataque. + +## Decisión + +La fuente de verdad será: + +- Markdown para entidades legibles; +- un archivo JSON inmutable por evento; +- Git para historia y concurrencia; +- `clusterlog sync` para producir `data/generated/site_state.json`; +- Zola para consumir la proyección en modo sólo lectura. + +La proyección es desechable y nunca se edita a mano. + +## Consecuencias positivas + +- El repositorio funciona sin un servicio permanente. +- Toda la historia es inspeccionable con herramientas estándar. +- Los conflictos se reducen al separar eventos. +- La publicación puede ser atómica y cacheable. +- Es posible reconstruir el sitio desde cero. + +## Consecuencias negativas + +- No hay edición web en tiempo real. +- Cada escritura necesita sincronización Git. +- Con volúmenes muy altos de eventos habrá que medir rendimiento y quizá archivar. +- La coherencia depende de CI, validación y disciplina de ramas. + +## Alternativas descartadas + +- `localStorage`: no compartido, fácil de perder y no auditable. +- Archivo JSON central mutable: conflictos frecuentes entre administradores. +- Base de datos desde el MVP: complejidad desproporcionada para el flujo y volumen iniciales. diff --git a/docs/adr/0002-ssh-identity.md b/docs/adr/0002-ssh-identity.md new file mode 100644 index 0000000..5e6dfba --- /dev/null +++ b/docs/adr/0002-ssh-identity.md @@ -0,0 +1,35 @@ +# ADR-0002: Identidad por llave SSH y prueba mediante commit firmado + +- **Estado:** aceptada +- **Fecha:** 2026-08-12 + +## Contexto + +Los administradores ya usan llaves SSH y los agentes operan en su sesión local. Se busca evitar un sistema adicional de usuarios y contraseñas, pero una llave pública almacenada no basta para probar que una operación la realizó quien posee la privada. + +## Decisión + +- El registro `data/admins.json` asocia una o más huellas públicas con cada administrador. +- La CLI resuelve la identidad a partir de una llave local. +- El contenido conserva `admin_id` y huella para trazabilidad. +- Las escrituras de producción usan `--commit` para crear un commit Git firmado por SSH. +- `config/allowed_signers` se genera desde administradores activos. +- CI y protección de rama verifican firmas y autorización. + +## Consecuencias positivas + +- Reutiliza la práctica operativa existente. +- Evita secretos de aplicación dentro del repositorio. +- La evidencia se integra con el historial Git. +- Los agentes pueden usar el mismo contrato sin conocer la llave privada. + +## Consecuencias negativas + +- La configuración inicial de Git/OpenSSH requiere cuidado. +- Rotación y revocación deben gobernarse explícitamente. +- Una operación sin `--commit` queda atribuida sólo de manera declarativa. +- El proveedor Git debe aplicar políticas de firma y rama. + +## Regla de interpretación + +La huella es identidad declarada; una firma válida y autorizada es evidencia criptográfica. Nunca deben tratarse como equivalentes. diff --git a/docs/adr/0003-review-hash-snapshot.md b/docs/adr/0003-review-hash-snapshot.md new file mode 100644 index 0000000..d3d0bb9 --- /dev/null +++ b/docs/adr/0003-review-hash-snapshot.md @@ -0,0 +1,31 @@ +# ADR-0003: Revisiones por hash y conjunto capturado + +- **Estado:** aceptada +- **Fecha:** 2026-08-12 + +## Contexto + +Un booleano `revisada` no indica quién revisó ni qué versión vio. Además, añadir un administrador meses después podría reabrir todo el histórico si la política se recalculara siempre contra el equipo actual. + +## Decisión + +Al crear una memoria: + +- se captura el conjunto de revisores requerido; +- se calcula un hash SHA-256 sobre la versión de contenido; +- cada revisión registra administrador, huella, fecha y hash; +- sólo cuentan revisiones cuyo hash coincide con el actual; +- la cola se completa cuando todos los IDs capturados tienen un evento válido. + +## Consecuencias positivas + +- Editar una memoria invalida automáticamente el consenso anterior. +- La cola histórica no cambia al incorporar administradores futuros. +- Es posible explicar exactamente por qué una entrada está pendiente. +- Los eventos permanecen inmutables y auditables. + +## Consecuencias negativas + +- Corregir incluso un detalle menor puede reabrir la revisión. +- El equipo debe decidir cuándo una corrección editorial merece conservar o repetir consenso; el MVP prioriza seguridad y vuelve a abrir siempre. +- Bajas de personal requieren una política de excepción explícita, todavía no automatizada. diff --git a/examples/manual.json b/examples/manual.json new file mode 100644 index 0000000..81732dd --- /dev/null +++ b/examples/manual.json @@ -0,0 +1,9 @@ +{ + "section": "manuales", + "title": "Reemplazo seguro de un nodo de cómputo", + "description": "Runbook para drenar, reemplazar y reincorporar un nodo", + "tags": ["runbook", "hardware"], + "systems": ["compute", "kubernetes"], + "weight": 20, + "body_markdown": "## Prerrequisitos\n\n- Ventana aprobada.\n- Capacidad suficiente en los nodos restantes.\n- Acceso fuera de banda probado.\n\n## Procedimiento\n\n1. Confirmar salud del clúster.\n2. Drenar el nodo según la política de cargas.\n3. Verificar reubicación.\n4. Reemplazar el equipo.\n5. Reincorporar y validar.\n\n## Reversión\n\nRehabilitar el nodo anterior únicamente cuando sea seguro y documentar cualquier desviación.\n" +} diff --git a/examples/memory.json b/examples/memory.json new file mode 100644 index 0000000..3e6d5b6 --- /dev/null +++ b/examples/memory.json @@ -0,0 +1,26 @@ +{ + "title": "Actualizar firmware del switch spine-01", + "description": "Cambio controlado y validación de enlaces del spine principal", + "tags": ["red", "firmware"], + "systems": ["spine-01"], + "risk": "high", + "ticket": "CHG-1042", + "review_required": true, + "review_policy": "all-active-except-author", + "context": "La versión instalada contiene una corrección aprobada para pérdida intermitente de telemetría.", + "changes": [ + "Respaldé la configuración vigente.", + "Instalé la imagen aprobada y reinicié durante la ventana de mantenimiento." + ], + "commands": [ + "show version", + "show interfaces status" + ], + "validation": [ + "Todos los uplinks quedaron en estado up.", + "No aparecieron errores nuevos en los registros durante 20 minutos." + ], + "rollback": "Arrancar la imagen anterior y restaurar la configuración respaldada.", + "duration_minutes": 75, + "agent": "claude-code" +} diff --git a/examples/project.json b/examples/project.json new file mode 100644 index 0000000..fb68f26 --- /dev/null +++ b/examples/project.json @@ -0,0 +1,27 @@ +{ + "title": "Estandarizar observabilidad del clúster", + "description": "Unificar métricas, registros, alertas y tableros operativos", + "objective": "Lograr cobertura observable y criterios de alerta consistentes para todos los componentes críticos.", + "owners": [], + "target_date": "2026-12-15", + "status": "planned", + "tags": ["observabilidad"], + "systems": ["prometheus", "loki", "grafana"], + "scope": [ + "Inventario de alertas", + "Dashboards base", + "Retención y capacidad" + ], + "out_of_scope": [ + "Sustituir el sistema corporativo de incidentes" + ], + "milestones": [ + "Completar inventario", + "Publicar estándares", + "Migrar componentes críticos" + ], + "success_criteria": [ + "100% de nodos reporta métricas base.", + "Todas las alertas críticas tienen owner y runbook." + ] +} diff --git a/examples/task.json b/examples/task.json new file mode 100644 index 0000000..8cd97f7 --- /dev/null +++ b/examples/task.json @@ -0,0 +1,18 @@ +{ + "title": "Probar restauración de etcd", + "description": "Ejercicio controlado de recuperación del plano de control", + "project_id": "", + "priority": "high", + "assignees": [], + "estimate_minutes": 120, + "due": "2026-09-15", + "tags": ["backup", "dr"], + "systems": ["etcd"], + "objective": "Validar el RTO y corregir el runbook de restauración.", + "acceptance_criteria": [ + "La restauración termina sin pérdida fuera del punto respaldado.", + "El RTO medido es menor a 60 minutos.", + "El manual refleja todos los pasos observados." + ], + "notes": "Ejecutar en un entorno aislado con snapshots aprobados." +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..738ca52 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module example.org/bitacora-cluster + +go 1.23 diff --git a/internal/clusterlog/cli_integration_test.go b/internal/clusterlog/cli_integration_test.go new file mode 100644 index 0000000..0d896f7 --- /dev/null +++ b/internal/clusterlog/cli_integration_test.go @@ -0,0 +1,91 @@ +package clusterlog + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func runJSONCommand(t *testing.T, root, keyPath, stdin string, now time.Time, args ...string) ResultEnvelope { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + app := New(Options{ + Root: root, JSON: true, SSHKey: keyPath, Stdout: &stdout, Stderr: &stderr, + Stdin: strings.NewReader(stdin), Now: func() time.Time { return now }, Executable: "clusterlog", + }) + if err := app.Run(args); err != nil { + t.Fatalf("command %v failed: %v\nstderr: %s", args, err, stderr.String()) + } + var envelope ResultEnvelope + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("invalid command JSON for %v: %v\n%s", args, err, stdout.String()) + } + if !envelope.OK { + t.Fatalf("command returned non-ok envelope: %#v", envelope) + } + return envelope +} + +func envelopeDataMap(t *testing.T, envelope ResultEnvelope) map[string]any { + t.Helper() + data, ok := envelope.Data.(map[string]any) + if !ok { + t.Fatalf("unexpected data type: %T", envelope.Data) + } + return data +} + +func TestCLIAdminMemoryReviewFlow(t *testing.T) { + root := testProjectRoot(t) + keyPath := filepath.Join(root, "ana.pub") + if err := os.WriteFile(keyPath, []byte(testOpenSSHPublicKey(9, "ana@test")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 12, 14, 0, 0, 123, time.UTC) + runJSONCommand(t, root, keyPath, "", now, + "admin", "add", "--id", "ana", "--name", "Ana Operadora", "--email", "ana@example.test") + + memoryJSON := `{ + "title": "Reiniciar controlador de almacenamiento", + "description": "Corrección controlada de un controlador degradado", + "risk": "high", + "tags": ["storage", "mantenimiento"], + "systems": ["ceph"], + "changes": ["Se drenó el nodo", "Se reinició el controlador"], + "validation": ["El clúster volvió a HEALTH_OK"], + "rollback": "Reintegrar el controlador anterior", + "agent": "claude-code" + }` + envelope := runJSONCommand(t, root, keyPath, memoryJSON, now.Add(time.Minute), + "memory", "create", "--from-json", "-") + data := envelopeDataMap(t, envelope) + entryID, ok := data["entry_id"].(string) + if !ok || entryID == "" { + t.Fatalf("missing entry id: %#v", data) + } + state, err := New(Options{}).BuildState(root) + if err != nil { + t.Fatal(err) + } + if len(state.ReviewQueue) != 1 || !contains(state.ReviewQueue[0].MissingReviewers, "ana") { + t.Fatalf("unexpected review queue: %#v", state.ReviewQueue) + } + + runJSONCommand(t, root, keyPath, "", now.Add(2*time.Minute), "review", "mark", entryID, "--note", "Validado") + state, err = New(Options{}).BuildState(root) + if err != nil { + t.Fatal(err) + } + if len(state.ReviewQueue) != 0 { + t.Fatalf("reviewed item remained pending: %#v", state.ReviewQueue) + } + files, err := reviewEventFiles(root) + if err != nil || len(files) != 1 { + t.Fatalf("expected one independent review event file, got %v, err=%v", files, err) + } +} diff --git a/internal/clusterlog/commands.go b/internal/clusterlog/commands.go new file mode 100644 index 0000000..e1aae74 --- /dev/null +++ b/internal/clusterlog/commands.go @@ -0,0 +1,427 @@ +package clusterlog + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func (a *App) Run(args []string) error { + if len(args) == 0 { + return NewError(ExitUsage, "missing_command", usageText(a.Options.Executable), nil) + } + command := args[0] + if command == "help" || command == "--help" || command == "-h" { + return a.printResult("help", usageText(a.Options.Executable)) + } + if command == "version" { + return a.printResult("version", map[string]any{"name": "clusterlog", "version": Version}) + } + if command == "schema" { + return a.runSchema(args[1:]) + } + + root, err := a.ResolveRoot() + if err != nil { + return err + } + switch command { + case "identity": + return a.runIdentity(root, args[1:]) + case "admin": + return a.runAdmin(root, args[1:]) + case "memory": + return a.runMemory(root, args[1:]) + case "review": + return a.runReview(root, args[1:]) + case "task": + return a.runTask(root, args[1:]) + case "project": + return a.runProject(root, args[1:]) + case "content": + return a.runContent(root, args[1:]) + case "sync": + return a.runSync(root, args[1:]) + case "validate": + return a.runValidate(root, args[1:]) + case "verify": + return a.runVerify(root, args[1:]) + default: + return NewError(ExitUsage, "unknown_command", "comando desconocido: "+command, usageText(a.Options.Executable)) + } +} + +func usageText(executable string) string { + return fmt.Sprintf(`%s [opciones globales] + +Opciones globales (antes del comando): + --root DIR raíz del proyecto + --json salida estable para agentes + --ssh-key PATH llave pública SSH o ruta base + --commit crea un commit Git SSH firmado con sólo los archivos modificados + --dry-run valida y muestra el resultado sin escribir + +Comandos: + identity + admin add | admin list + memory create + review list | review mark ID + task create | start ID | stop ID | adjust ID | status ID | done ID | list + project create | list + content create + sync [--check] + validate + verify commit [SHA] + schema [memory|task|project|content] +`, executable) +} + +func newFlagSet(name string) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(os.Stderr) + return fs +} + +func wasSet(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + +func (a *App) runIdentity(root string, args []string) error { + if len(args) != 0 { + return NewError(ExitUsage, "unexpected_arguments", "identity no acepta argumentos", args) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + return a.printResult("identity", identity) +} + +func (a *App) runAdmin(root string, args []string) error { + if len(args) == 0 { + return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", nil) + } + if args[0] == "list" { + fs := newFlagSet("admin list") + activeOnly := fs.Bool("active", false, "muestra sólo administradores activos") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + registry, err := loadAdmins(root) + if err != nil { + return err + } + admins := []Admin{} + for _, admin := range registry.Admins { + if !*activeOnly || admin.Active { + admins = append(admins, admin) + } + } + return a.printResult("admin.list", map[string]any{ + "count": len(admins), "admins": admins, "review_policy": registry.ReviewPolicy, + }) + } + if args[0] != "add" { + return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", args[0]) + } + fs := newFlagSet("admin add") + id := fs.String("id", "", "identificador estable") + name := fs.String("name", "", "nombre") + email := fs.String("email", "", "correo") + keyPath := fs.String("key", "", "llave pública") + rolesCSV := fs.String("roles", "admin", "roles separados por coma") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + adminID := slugify(*id) + if adminID == "" || strings.TrimSpace(*name) == "" || !strings.Contains(*email, "@") { + return NewError(ExitUsage, "invalid_admin", "id, name y un email válido son obligatorios", nil) + } + path := firstNonEmpty(*keyPath, a.Options.SSHKey, os.Getenv("CLUSTERLOG_SSH_KEY")) + if path == "" { + return NewError(ExitUsage, "missing_ssh_key", "indique --key o --ssh-key para registrar la llave pública", nil) + } + canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path) + if err != nil { + return NewError(ExitIdentity, "invalid_ssh_key", err.Error(), path) + } + registry, err := loadAdmins(root) + if err != nil { + return err + } + var actor Identity + if len(registry.Admins) > 0 { + actor, err = a.ResolveIdentity(root) + if err != nil { + return err + } + if !hasRole(actor.Admin, "owner") { + return NewError(ExitIdentity, "not_authorized", "sólo un administrador con rol owner puede agregar administradores", actor.Admin.ID) + } + } + for _, admin := range registry.Admins { + if strings.EqualFold(admin.ID, adminID) || strings.EqualFold(admin.Email, *email) { + return NewError(ExitConflict, "admin_exists", "ya existe un administrador con ese id o correo", admin.ID) + } + for _, key := range admin.SSHKeys { + if key.Fingerprint == fingerprint { + return NewError(ExitConflict, "key_exists", "la llave SSH ya pertenece a otro administrador", admin.ID) + } + } + } + roles := splitCSV(*rolesCSV) + if len(registry.Admins) == 0 && !contains(roles, "owner") { + roles = append(roles, "owner") + } + roles = uniqueSorted(roles) + newAdmin := Admin{ + ID: adminID, Name: strings.TrimSpace(*name), Email: strings.TrimSpace(*email), + Active: true, Roles: roles, + SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: canonical, AddedAt: a.Options.Now().UTC().Format(time.RFC3339Nano)}}, + } + registry.Admins = append(registry.Admins, newAdmin) + sort.Slice(registry.Admins, func(i, j int) bool { return registry.Admins[i].ID < registry.Admins[j].ID }) + if a.Options.DryRun { + return a.printResult("admin.add", map[string]any{"admin": newAdmin, "dry_run": true}) + } + if err := saveAdmins(root, registry); err != nil { + return err + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + commitIdentity := actor + if len(registry.Admins) == 1 { + commitIdentity = Identity{Admin: newAdmin, Fingerprint: fingerprint, PublicKey: canonical, PublicPath: publicPath, SigningKey: signingPath} + } + paths := []string{filepath.Join(root, "data", "admins.json"), filepath.Join(root, "config", "allowed_signers")} + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, commitIdentity, "admin: add "+newAdmin.ID, paths) + if err != nil { + return err + } + warnings := []string{} + if !a.Options.Commit { + warnings = append(warnings, "proteja data/admins.json y exija commits SSH firmados antes de usarlo en producción") + } + return a.printResult("admin.add", map[string]any{"admin": newAdmin, "state": state.Stats, "commit": sha}, warnings...) +} + +func (a *App) runMemory(root string, args []string) error { + if len(args) == 0 || args[0] != "create" { + return NewError(ExitUsage, "memory_usage", "uso: memory create --title ... [--from-json ARCHIVO|-]", nil) + } + fs := newFlagSet("memory create") + title := fs.String("title", "", "título") + description := fs.String("description", "", "resumen") + tags := fs.String("tags", "", "tags separados por coma") + systems := fs.String("systems", "", "sistemas separados por coma") + risk := fs.String("risk", "", "low|medium|high|critical") + ticket := fs.String("ticket", "", "ticket relacionado") + review := fs.Bool("review", true, "requiere revisión") + reviewPolicy := fs.String("review-policy", "", "all-active|all-active-except-author") + bodyFile := fs.String("body-file", "", "Markdown o -") + fromJSON := fs.String("from-json", "", "JSON o -") + duration := fs.String("duration", "", "minutos, 90m, 2h") + agent := fs.String("agent", "", "agente que preparó el texto") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + var input MemoryInput + if *fromJSON != "" { + if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil { + return err + } + } + if wasSet(fs, "title") { + input.Title = *title + } + if wasSet(fs, "description") { + input.Description = *description + } + if wasSet(fs, "tags") { + input.Tags = splitCSV(*tags) + } + if wasSet(fs, "systems") { + input.Systems = splitCSV(*systems) + } + if wasSet(fs, "risk") { + input.Risk = *risk + } + if wasSet(fs, "ticket") { + input.Ticket = *ticket + } + if wasSet(fs, "review") { + input.ReviewRequired = review + } + if wasSet(fs, "review-policy") { + input.ReviewPolicy = *reviewPolicy + } + if wasSet(fs, "agent") { + input.Agent = *agent + } + if *duration != "" { + minutes, err := parseDurationMinutes(*duration) + if err != nil { + return NewError(ExitUsage, "invalid_duration", err.Error(), nil) + } + input.DurationMinutes = minutes + } + if *bodyFile != "" { + data, err := readAll(*bodyFile, a.Options.Stdin) + if err != nil { + return err + } + input.BodyMarkdown = string(data) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + result, warnings, err := a.createMemory(root, identity, input) + if err != nil { + return err + } + if a.Options.DryRun { + return a.printResult("memory.create", result, warnings...) + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + createdPath := filepath.FromSlash(result["path"].(string)) + paths := []string{createdPath} + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, identity, fmt.Sprintf("memory(%s): %s", result["entry_id"], input.Title), paths) + if err != nil { + return err + } + result["commit"] = sha + result["pending_reviews"] = state.Stats.PendingReviews + return a.printResult("memory.create", result, warnings...) +} + +func (a *App) runReview(root string, args []string) error { + if len(args) == 0 { + return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID [--note TEXTO]", nil) + } + switch args[0] { + case "list": + fs := newFlagSet("review list") + mine := fs.Bool("mine", false, "sólo pendientes del administrador actual") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + state, err := a.BuildState(root) + if err != nil { + return err + } + queue := state.ReviewQueue + if *mine { + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + filtered := []ReviewQueueItem{} + for _, item := range queue { + if contains(item.MissingReviewers, identity.Admin.ID) { + filtered = append(filtered, item) + } + } + queue = filtered + } + return a.printResult("review.list", map[string]any{"count": len(queue), "items": queue}) + case "mark": + if len(args) < 2 { + return NewError(ExitUsage, "missing_entry_id", "indique el ID de la memoria", nil) + } + entryID := args[1] + fs := newFlagSet("review mark") + note := fs.String("note", "", "nota breve") + if err := fs.Parse(args[2:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + memory, _, err := findMemory(root, entryID) + if err != nil { + return err + } + if !memory.ReviewRequired { + return NewError(ExitValidation, "review_not_required", "la memoria no requiere revisión", memory.EntryID) + } + registry, err := loadAdmins(root) + if err != nil { + return err + } + if memory.ReviewerSnapshot { + if !contains(memory.Reviewers, identity.Admin.ID) { + return NewError(ExitValidation, "review_not_required_for_admin", "el administrador no forma parte del conjunto de revisores capturado para esta memoria", map[string]any{ + "admin_id": identity.Admin.ID, "reviewers": memory.Reviewers, + }) + } + } else { + policy := firstNonEmpty(memory.ReviewPolicy, registry.ReviewPolicy.Default) + if (policy == "all-active-except-author" || (policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)) && identity.Admin.ID == memory.AuthorID { + return NewError(ExitValidation, "review_not_required_for_author", "la política excluye al autor de la revisión", identity.Admin.ID) + } + } + log, err := loadReviewLog(root) + if err != nil { + return err + } + for _, event := range log.Events { + if normalizeID(event.EntryID) == memory.EntryID && event.AdminID == identity.Admin.ID && event.ContentSHA256 == memory.ContentSHA256 { + state, err := a.BuildState(root) + if err != nil { + return err + } + return a.printResult("review.mark", map[string]any{"entry_id": memory.EntryID, "admin_id": identity.Admin.ID, "already_reviewed": true, "pending_reviews": state.Stats.PendingReviews}) + } + } + eventID, err := generateID("REV", a.Options.Now()) + if err != nil { + return err + } + event := ReviewEvent{ID: eventID, EntryID: memory.EntryID, AdminID: identity.Admin.ID, Fingerprint: identity.Fingerprint, ReviewedAt: a.Options.Now().UTC().Format(time.RFC3339Nano), ContentSHA256: memory.ContentSHA256, Note: strings.TrimSpace(*note)} + if a.Options.DryRun { + return a.printResult("review.mark", map[string]any{"event": event, "dry_run": true}) + } + eventPath, err := writeReviewEvent(root, event) + if err != nil { + return err + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + paths := []string{eventPath} + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, identity, fmt.Sprintf("review(%s): %s", memory.EntryID, identity.Admin.ID), paths) + if err != nil { + return err + } + return a.printResult("review.mark", map[string]any{"event": event, "pending_reviews": state.Stats.PendingReviews, "commit": sha}) + default: + return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID", args[0]) + } +} diff --git a/internal/clusterlog/commands_content.go b/internal/clusterlog/commands_content.go new file mode 100644 index 0000000..5d9a80d --- /dev/null +++ b/internal/clusterlog/commands_content.go @@ -0,0 +1,227 @@ +package clusterlog + +import ( + "fmt" + "path/filepath" +) + +func (a *App) runProject(root string, args []string) error { + if len(args) == 0 { + return NewError(ExitUsage, "project_usage", "uso: project create | list", nil) + } + switch args[0] { + case "create": + fs := newFlagSet("project create") + title := fs.String("title", "", "título") + description := fs.String("description", "", "resumen") + objective := fs.String("objective", "", "objetivo") + owners := fs.String("owners", "", "administradores separados por coma") + targetDate := fs.String("target-date", "", "fecha YYYY-MM-DD") + status := fs.String("status", "", "planned|active|blocked|done|cancelled") + tags := fs.String("tags", "", "tags separados por coma") + systems := fs.String("systems", "", "sistemas separados por coma") + scope := fs.String("scope", "", "elementos de alcance separados por coma") + outOfScope := fs.String("out-of-scope", "", "elementos fuera de alcance separados por coma") + milestones := fs.String("milestones", "", "hitos separados por coma") + success := fs.String("success", "", "criterios de éxito separados por coma") + bodyFile := fs.String("body-file", "", "Markdown o -") + fromJSON := fs.String("from-json", "", "JSON o -") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + var input ProjectInput + if *fromJSON != "" { + if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil { + return err + } + } + if wasSet(fs, "title") { + input.Title = *title + } + if wasSet(fs, "description") { + input.Description = *description + } + if wasSet(fs, "objective") { + input.Objective = *objective + } + if wasSet(fs, "owners") { + input.Owners = splitCSV(*owners) + } + if wasSet(fs, "target-date") { + input.TargetDate = *targetDate + } + if wasSet(fs, "status") { + input.Status = *status + } + if wasSet(fs, "tags") { + input.Tags = splitCSV(*tags) + } + if wasSet(fs, "systems") { + input.Systems = splitCSV(*systems) + } + if wasSet(fs, "scope") { + input.Scope = splitCSV(*scope) + } + if wasSet(fs, "out-of-scope") { + input.OutOfScope = splitCSV(*outOfScope) + } + if wasSet(fs, "milestones") { + input.Milestones = splitCSV(*milestones) + } + if wasSet(fs, "success") { + input.Success = splitCSV(*success) + } + if *bodyFile != "" { + data, err := readAll(*bodyFile, a.Options.Stdin) + if err != nil { + return err + } + input.BodyMarkdown = string(data) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + result, warnings, err := a.createProject(root, identity, input) + if err != nil { + return err + } + if a.Options.DryRun { + return a.printResult("project.create", result, warnings...) + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + paths := []string{filepath.FromSlash(result["path"].(string))} + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, identity, fmt.Sprintf("project(%s): %s", result["project_id"], input.Title), paths) + if err != nil { + return err + } + result["commit"] = sha + result["active_projects"] = state.Stats.ActiveProjects + return a.printResult("project.create", result, warnings...) + case "list": + fs := newFlagSet("project list") + status := fs.String("status", "", "filtrar por estado") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + state, err := a.BuildState(root) + if err != nil { + return err + } + items := []ProjectState{} + for _, item := range state.Projects { + if *status == "" || item.Status == *status { + items = append(items, item) + } + } + return a.printResult("project.list", map[string]any{"count": len(items), "items": items}) + default: + return NewError(ExitUsage, "project_usage", "uso: project create | list", args[0]) + } +} + +func (a *App) runContent(root string, args []string) error { + if len(args) == 0 || args[0] != "create" { + return NewError(ExitUsage, "content_usage", "uso: content create --section documentacion|manuales --title ... --body-file ...", nil) + } + fs := newFlagSet("content create") + section := fs.String("section", "", "documentacion|manuales") + title := fs.String("title", "", "título") + description := fs.String("description", "", "resumen") + tags := fs.String("tags", "", "tags separados por coma") + systems := fs.String("systems", "", "sistemas separados por coma") + bodyFile := fs.String("body-file", "", "Markdown o -") + weight := fs.Int("weight", 0, "orden opcional") + fromJSON := fs.String("from-json", "", "JSON o -") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + var input ContentInput + if *fromJSON != "" { + if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil { + return err + } + } + if wasSet(fs, "section") { + input.Section = *section + } + if wasSet(fs, "title") { + input.Title = *title + } + if wasSet(fs, "description") { + input.Description = *description + } + if wasSet(fs, "tags") { + input.Tags = splitCSV(*tags) + } + if wasSet(fs, "systems") { + input.Systems = splitCSV(*systems) + } + if wasSet(fs, "weight") { + input.Weight = *weight + } + if *bodyFile != "" { + data, err := readAll(*bodyFile, a.Options.Stdin) + if err != nil { + return err + } + input.BodyMarkdown = string(data) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + result, warnings, err := a.createContent(root, identity, input) + if err != nil { + return err + } + if a.Options.DryRun { + return a.printResult("content.create", result, warnings...) + } + createdPath := filepath.FromSlash(result["path"].(string)) + sha, err := a.commitPaths(root, identity, fmt.Sprintf("%s(%s): %s", input.Section, result["content_id"], input.Title), []string{createdPath}) + if err != nil { + return err + } + result["commit"] = sha + return a.printResult("content.create", result, warnings...) +} + +func (a *App) runSync(root string, args []string) error { + fs := newFlagSet("sync") + check := fs.Bool("check", false, "no escribe; falla si el estado generado está desactualizado") + if err := fs.Parse(args); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + state, changed, path, err := a.Sync(root, *check) + if err != nil { + return err + } + return a.printResult("sync", map[string]any{ + "path": filepath.ToSlash(path), "changed": changed, "check": *check, + "source_sha256": state.SourceSHA256, "stats": state.Stats, + }) +} + +func (a *App) runVerify(root string, args []string) error { + if len(args) == 0 || args[0] != "commit" { + return NewError(ExitUsage, "verify_usage", "uso: verify commit [SHA]", nil) + } + sha := "HEAD" + if len(args) > 1 { + sha = args[1] + } + if len(args) > 2 { + return NewError(ExitUsage, "unexpected_arguments", "uso: verify commit [SHA]", args[2:]) + } + if err := verifyCommit(root, sha); err != nil { + return err + } + return a.printResult("verify.commit", map[string]any{"commit": sha, "valid": true}) +} diff --git a/internal/clusterlog/commands_task.go b/internal/clusterlog/commands_task.go new file mode 100644 index 0000000..57f2e3e --- /dev/null +++ b/internal/clusterlog/commands_task.go @@ -0,0 +1,527 @@ +package clusterlog + +import ( + "fmt" + "path/filepath" + "strings" + "time" +) + +func (a *App) runTask(root string, args []string) error { + if len(args) == 0 { + return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", nil) + } + switch args[0] { + case "create": + return a.runTaskCreate(root, args[1:]) + case "start": + return a.runTaskStart(root, args[1:]) + case "stop": + return a.runTaskStop(root, args[1:]) + case "adjust": + return a.runTaskAdjust(root, args[1:]) + case "status": + return a.runTaskStatus(root, args[1:]) + case "done": + return a.runTaskDone(root, args[1:]) + case "list": + return a.runTaskList(root, args[1:]) + default: + return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", args[0]) + } +} + +func (a *App) runTaskCreate(root string, args []string) error { + fs := newFlagSet("task create") + title := fs.String("title", "", "título") + description := fs.String("description", "", "resumen") + projectID := fs.String("project", "", "ID de proyecto") + priority := fs.String("priority", "", "low|medium|high|urgent") + assignees := fs.String("assignees", "", "administradores separados por coma") + estimate := fs.String("estimate", "", "minutos, 90m, 2h") + due := fs.String("due", "", "fecha YYYY-MM-DD") + tags := fs.String("tags", "", "tags separados por coma") + systems := fs.String("systems", "", "sistemas separados por coma") + objective := fs.String("objective", "", "objetivo") + acceptance := fs.String("acceptance", "", "criterios separados por coma") + notes := fs.String("notes", "", "notas") + bodyFile := fs.String("body-file", "", "Markdown o -") + fromJSON := fs.String("from-json", "", "JSON o -") + if err := fs.Parse(args); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + var input TaskInput + if *fromJSON != "" { + if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil { + return err + } + } + if wasSet(fs, "title") { + input.Title = *title + } + if wasSet(fs, "description") { + input.Description = *description + } + if wasSet(fs, "project") { + input.ProjectID = *projectID + } + if wasSet(fs, "priority") { + input.Priority = *priority + } + if wasSet(fs, "assignees") { + input.Assignees = splitCSV(*assignees) + } + if wasSet(fs, "due") { + input.Due = *due + } + if wasSet(fs, "tags") { + input.Tags = splitCSV(*tags) + } + if wasSet(fs, "systems") { + input.Systems = splitCSV(*systems) + } + if wasSet(fs, "objective") { + input.Objective = *objective + } + if wasSet(fs, "acceptance") { + input.Acceptance = splitCSV(*acceptance) + } + if wasSet(fs, "notes") { + input.Notes = *notes + } + if *estimate != "" { + minutes, err := parseDurationMinutes(*estimate) + if err != nil { + return NewError(ExitUsage, "invalid_estimate", err.Error(), nil) + } + input.EstimateMinutes = minutes + } + if *bodyFile != "" { + data, err := readAll(*bodyFile, a.Options.Stdin) + if err != nil { + return err + } + input.BodyMarkdown = string(data) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + result, warnings, err := a.createTask(root, identity, input) + if err != nil { + return err + } + if a.Options.DryRun { + return a.printResult("task.create", result, warnings...) + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + createdPath := filepath.FromSlash(result["path"].(string)) + paths := []string{createdPath} + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", result["task_id"], input.Title), paths) + if err != nil { + return err + } + result["commit"] = sha + result["open_tasks"] = state.Stats.OpenTasks + return a.printResult("task.create", result, warnings...) +} + +func (a *App) runTaskStart(root string, args []string) error { + if len(args) < 1 { + return NewError(ExitUsage, "missing_task_id", "uso: task start ID [--note TEXTO]", nil) + } + taskID := normalizeID(args[0]) + fs := newFlagSet("task start") + note := fs.String("note", "", "nota breve") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + task, _, err := findTask(root, taskID) + if err != nil { + return err + } + state, err := a.BuildState(root) + if err != nil { + return err + } + currentStatus := task.Status + for _, current := range state.Tasks { + if current.TaskID == task.TaskID { + currentStatus = current.Status + break + } + } + if statusIsClosed(currentStatus) { + return NewError(ExitConflict, "task_closed", "no se puede iniciar una tarea cerrada", map[string]any{ + "task_id": task.TaskID, "status": currentStatus, + }) + } + log, err := loadTaskLog(root) + if err != nil { + return err + } + if active, ok := taskActiveStart(log, identity.Admin.ID); ok { + if normalizeID(active.TaskID) == task.TaskID { + return a.printResult("task.start", map[string]any{ + "task_id": task.TaskID, "admin_id": identity.Admin.ID, + "already_running": true, "started_at": active.At, + }) + } + return NewError(ExitConflict, "timer_already_running", "el administrador ya tiene otra tarea activa", map[string]any{ + "active_task_id": active.TaskID, + "started_at": active.At, + "hint": "ejecute task stop para la tarea activa", + }) + } + eventID, err := generateID("EVT", a.Options.Now()) + if err != nil { + return err + } + event := TaskEvent{ + ID: eventID, TaskID: task.TaskID, Type: "start", AdminID: identity.Admin.ID, + Fingerprint: identity.Fingerprint, At: a.Options.Now().UTC().Format(time.RFC3339Nano), Note: strings.TrimSpace(*note), + } + if a.Options.DryRun { + return a.printResult("task.start", map[string]any{"event": event, "dry_run": true}) + } + return a.persistTaskEvent(root, identity, event, "start") +} + +func (a *App) runTaskStop(root string, args []string) error { + if len(args) < 1 { + return NewError(ExitUsage, "missing_task_id", "uso: task stop ID [--note TEXTO]", nil) + } + taskID := normalizeID(args[0]) + fs := newFlagSet("task stop") + note := fs.String("note", "", "nota breve") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + task, _, err := findTask(root, taskID) + if err != nil { + return err + } + log, err := loadTaskLog(root) + if err != nil { + return err + } + started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID) + if !running { + return NewError(ExitConflict, "timer_not_running", "no hay un temporizador activo para esta tarea y administrador", map[string]any{ + "task_id": task.TaskID, "admin_id": identity.Admin.ID, + }) + } + startedAt, err := time.Parse(time.RFC3339, started.At) + if err != nil { + return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started) + } + now := a.Options.Now().UTC() + if now.Before(startedAt) { + return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started) + } + elapsed := now.Sub(startedAt) + minutes := int((elapsed + time.Minute - 1) / time.Minute) + if minutes < 1 { + minutes = 1 + } + eventID, err := generateID("EVT", now) + if err != nil { + return err + } + event := TaskEvent{ + ID: eventID, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID, + Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes, Note: strings.TrimSpace(*note), + } + if a.Options.DryRun { + return a.printResult("task.stop", map[string]any{"event": event, "elapsed": formatMinutes(minutes), "dry_run": true}) + } + return a.persistTaskEvent(root, identity, event, "stop") +} + +func (a *App) runTaskAdjust(root string, args []string) error { + if len(args) < 1 { + return NewError(ExitUsage, "missing_task_id", "uso: task adjust ID --minutes N [--note TEXTO]", nil) + } + taskID := normalizeID(args[0]) + fs := newFlagSet("task adjust") + minutes := fs.Int("minutes", 0, "minutos positivos o negativos") + note := fs.String("note", "", "justificación del ajuste") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + if !wasSet(fs, "minutes") || *minutes == 0 { + return NewError(ExitUsage, "invalid_adjustment", "--minutes debe ser distinto de cero", nil) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + task, _, err := findTask(root, taskID) + if err != nil { + return err + } + state, err := a.BuildState(root) + if err != nil { + return err + } + for _, current := range state.Tasks { + if current.TaskID == task.TaskID && current.ActualMinutes+*minutes < 0 { + return NewError(ExitValidation, "negative_actual_time", "el ajuste dejaría el tiempo real por debajo de cero", map[string]any{ + "task_id": task.TaskID, "actual_minutes": current.ActualMinutes, "adjustment": *minutes, + }) + } + } + now := a.Options.Now().UTC() + eventID, err := generateID("EVT", now) + if err != nil { + return err + } + event := TaskEvent{ + ID: eventID, TaskID: task.TaskID, Type: "adjust", AdminID: identity.Admin.ID, + Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: *minutes, Note: strings.TrimSpace(*note), + } + if a.Options.DryRun { + return a.printResult("task.adjust", map[string]any{"event": event, "dry_run": true}) + } + return a.persistTaskEvent(root, identity, event, "adjust") +} + +func (a *App) runTaskStatus(root string, args []string) error { + if len(args) < 1 { + return NewError(ExitUsage, "missing_task_id", "uso: task status ID --status backlog|todo|in_progress|blocked|cancelled [--note TEXTO]", nil) + } + taskID := normalizeID(args[0]) + fs := newFlagSet("task status") + status := fs.String("status", "", "backlog|todo|in_progress|blocked|cancelled") + note := fs.String("note", "", "nota breve") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + newStatus := strings.ToLower(strings.TrimSpace(*status)) + allowed := map[string]bool{"backlog": true, "todo": true, "in_progress": true, "blocked": true, "cancelled": true} + if !allowed[newStatus] { + return NewError(ExitUsage, "invalid_task_status", "use backlog, todo, in_progress, blocked o cancelled; para completar use task done", newStatus) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + task, _, err := findTask(root, taskID) + if err != nil { + return err + } + state, err := a.BuildState(root) + if err != nil { + return err + } + for _, current := range state.Tasks { + if current.TaskID != task.TaskID { + continue + } + if current.Status == newStatus { + return a.printResult("task.status", map[string]any{"task_id": task.TaskID, "status": newStatus, "already_set": true}) + } + if statusIsClosed(newStatus) && len(current.RunningBy) > 0 { + return NewError(ExitConflict, "task_has_running_timers", "detenga todos los temporizadores antes de cerrar la tarea", map[string]any{ + "task_id": task.TaskID, "running_by": current.RunningBy, + }) + } + } + now := a.Options.Now().UTC() + eventID, err := generateID("EVT", now) + if err != nil { + return err + } + event := TaskEvent{ + ID: eventID, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID, + Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: newStatus, Note: strings.TrimSpace(*note), + } + if a.Options.DryRun { + return a.printResult("task.status", map[string]any{"event": event, "dry_run": true}) + } + return a.persistTaskEvent(root, identity, event, "status") +} + +func (a *App) runTaskDone(root string, args []string) error { + if len(args) < 1 { + return NewError(ExitUsage, "missing_task_id", "uso: task done ID [--note TEXTO]", nil) + } + taskID := normalizeID(args[0]) + fs := newFlagSet("task done") + note := fs.String("note", "", "nota breve") + if err := fs.Parse(args[1:]); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + task, _, err := findTask(root, taskID) + if err != nil { + return err + } + stateBefore, err := a.BuildState(root) + if err != nil { + return err + } + for _, current := range stateBefore.Tasks { + if current.TaskID != task.TaskID { + continue + } + if current.Status == "done" { + return a.printResult("task.done", map[string]any{"task_id": task.TaskID, "already_done": true}) + } + otherRunners := []string{} + for _, adminID := range current.RunningBy { + if adminID != identity.Admin.ID { + otherRunners = append(otherRunners, adminID) + } + } + if len(otherRunners) > 0 { + return NewError(ExitConflict, "task_has_other_running_timers", "otros administradores aún registran tiempo en esta tarea", map[string]any{ + "task_id": task.TaskID, "running_by": otherRunners, + }) + } + } + log, err := loadTaskLog(root) + if err != nil { + return err + } + now := a.Options.Now().UTC() + newEvents := []TaskEvent{} + if started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID); running { + startedAt, parseErr := time.Parse(time.RFC3339, started.At) + if parseErr != nil { + return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started) + } + if now.Before(startedAt) { + return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started) + } + minutes := int((now.Sub(startedAt) + time.Minute - 1) / time.Minute) + if minutes < 1 { + minutes = 1 + } + id, idErr := generateID("EVT", now) + if idErr != nil { + return idErr + } + newEvents = append(newEvents, TaskEvent{ + ID: id, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID, + Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes, + Note: "temporizador cerrado al completar la tarea", + }) + } + id, err := generateID("EVT", now) + if err != nil { + return err + } + statusEvent := TaskEvent{ + ID: id, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID, + Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: "done", Note: strings.TrimSpace(*note), + } + newEvents = append(newEvents, statusEvent) + if a.Options.DryRun { + return a.printResult("task.done", map[string]any{"events": newEvents, "dry_run": true}) + } + eventPaths, err := writeTaskEvents(root, newEvents) + if err != nil { + return err + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + paths := append([]string(nil), eventPaths...) + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): done", task.TaskID), paths) + if err != nil { + return err + } + var final TaskState + for _, item := range state.Tasks { + if item.TaskID == task.TaskID { + final = item + break + } + } + return a.printResult("task.done", map[string]any{"task": final, "events": newEvents, "commit": sha}) +} + +func (a *App) runTaskList(root string, args []string) error { + fs := newFlagSet("task list") + status := fs.String("status", "", "filtrar por estado") + mine := fs.Bool("mine", false, "sólo tareas asignadas o ejecutadas por el administrador actual") + projectID := fs.String("project", "", "filtrar por proyecto") + if err := fs.Parse(args); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + state, err := a.BuildState(root) + if err != nil { + return err + } + var adminID string + if *mine { + identity, err := a.ResolveIdentity(root) + if err != nil { + return err + } + adminID = identity.Admin.ID + } + items := []TaskState{} + for _, item := range state.Tasks { + if *status != "" && item.Status != strings.ToLower(*status) { + continue + } + if *projectID != "" && item.ProjectID != normalizeID(*projectID) { + continue + } + if *mine && !contains(item.Assignees, adminID) && !contains(item.RunningBy, adminID) { + continue + } + items = append(items, item) + } + return a.printResult("task.list", map[string]any{"count": len(items), "items": items}) +} + +func (a *App) persistTaskEvent(root string, identity Identity, event TaskEvent, action string) error { + eventPaths, err := writeTaskEvents(root, []TaskEvent{event}) + if err != nil { + return err + } + state, changed, statePath, err := a.Sync(root, false) + if err != nil { + return err + } + paths := append([]string(nil), eventPaths...) + if changed { + paths = append(paths, statePath) + } + sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", event.TaskID, action), paths) + if err != nil { + return err + } + var task TaskState + for _, item := range state.Tasks { + if item.TaskID == normalizeID(event.TaskID) { + task = item + break + } + } + return a.printResult("task."+action, map[string]any{"event": event, "task": task, "commit": sha}) +} diff --git a/internal/clusterlog/create.go b/internal/clusterlog/create.go new file mode 100644 index 0000000..617418a --- /dev/null +++ b/internal/clusterlog/create.go @@ -0,0 +1,434 @@ +package clusterlog + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +type MemoryInput struct { + Title string `json:"title"` + Description string `json:"description"` + Tags []string `json:"tags"` + Systems []string `json:"systems"` + Risk string `json:"risk"` + Ticket string `json:"ticket,omitempty"` + ReviewRequired *bool `json:"review_required,omitempty"` + ReviewPolicy string `json:"review_policy,omitempty"` + BodyMarkdown string `json:"body_markdown,omitempty"` + Context string `json:"context,omitempty"` + Changes []string `json:"changes,omitempty"` + Commands []string `json:"commands,omitempty"` + Validation []string `json:"validation,omitempty"` + Rollback string `json:"rollback,omitempty"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` + DurationMinutes int `json:"duration_minutes,omitempty"` + Agent string `json:"agent,omitempty"` +} + +type TaskInput struct { + Title string `json:"title"` + Description string `json:"description"` + ProjectID string `json:"project_id,omitempty"` + Priority string `json:"priority,omitempty"` + Assignees []string `json:"assignees,omitempty"` + EstimateMinutes int `json:"estimate_minutes,omitempty"` + Due string `json:"due,omitempty"` + Tags []string `json:"tags,omitempty"` + Systems []string `json:"systems,omitempty"` + BodyMarkdown string `json:"body_markdown,omitempty"` + Objective string `json:"objective,omitempty"` + Acceptance []string `json:"acceptance_criteria,omitempty"` + Notes string `json:"notes,omitempty"` +} + +type ProjectInput struct { + Title string `json:"title"` + Description string `json:"description"` + Objective string `json:"objective"` + Owners []string `json:"owners,omitempty"` + TargetDate string `json:"target_date,omitempty"` + Status string `json:"status,omitempty"` + Tags []string `json:"tags,omitempty"` + Systems []string `json:"systems,omitempty"` + BodyMarkdown string `json:"body_markdown,omitempty"` + Scope []string `json:"scope,omitempty"` + OutOfScope []string `json:"out_of_scope,omitempty"` + Milestones []string `json:"milestones,omitempty"` + Success []string `json:"success_criteria,omitempty"` +} + +type ContentInput struct { + Section string `json:"section"` + Title string `json:"title"` + Description string `json:"description"` + Tags []string `json:"tags,omitempty"` + Systems []string `json:"systems,omitempty"` + BodyMarkdown string `json:"body_markdown"` + Weight int `json:"weight,omitempty"` +} + +func decodeJSONInput(path string, stdinReader io.Reader, dst any) error { + data, err := readAll(path, stdinReader) + if err != nil { + return err + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return NewError(ExitUsage, "invalid_json", "el documento de entrada no es JSON válido o contiene campos desconocidos", err.Error()) + } + return nil +} + +func renderMemoryBody(input MemoryInput) string { + if strings.TrimSpace(input.BodyMarkdown) != "" { + return normalizeMarkdownBody(input.BodyMarkdown) + } + var b strings.Builder + b.WriteString("## Contexto\n\n") + b.WriteString(firstNonEmpty(input.Context, "_No especificado._")) + b.WriteString("\n\n## Cambios realizados\n\n") + b.WriteString(bullets(input.Changes)) + b.WriteString("\n## Comandos relevantes (secretos redactados)\n\n") + b.WriteString(codeBlock(input.Commands)) + b.WriteString("\n## Validación\n\n") + b.WriteString(bullets(input.Validation)) + b.WriteString("\n## Riesgo y reversión\n\n") + if input.Rollback == "" { + b.WriteString("_No se documentó un procedimiento de reversión._\n") + } else { + b.WriteString(input.Rollback + "\n") + } + if input.Ticket != "" { + b.WriteString("\n## Referencias\n\n- Ticket: `" + input.Ticket + "`\n") + } + return normalizeMarkdownBody(b.String()) +} + +func renderTaskBody(input TaskInput) string { + if strings.TrimSpace(input.BodyMarkdown) != "" { + return normalizeMarkdownBody(input.BodyMarkdown) + } + var b strings.Builder + b.WriteString("## Objetivo\n\n") + b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._")) + b.WriteString("\n\n## Criterios de aceptación\n\n") + b.WriteString(bullets(input.Acceptance)) + if input.Notes != "" { + b.WriteString("\n## Notas\n\n" + input.Notes + "\n") + } + return normalizeMarkdownBody(b.String()) +} + +func renderProjectBody(input ProjectInput) string { + if strings.TrimSpace(input.BodyMarkdown) != "" { + return normalizeMarkdownBody(input.BodyMarkdown) + } + var b strings.Builder + b.WriteString("## Objetivo\n\n") + b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._")) + b.WriteString("\n\n## Alcance\n\n") + b.WriteString(bullets(input.Scope)) + b.WriteString("\n## Fuera de alcance\n\n") + b.WriteString(bullets(input.OutOfScope)) + b.WriteString("\n## Hitos\n\n") + b.WriteString(bullets(input.Milestones)) + b.WriteString("\n## Criterios de éxito\n\n") + b.WriteString(bullets(input.Success)) + return normalizeMarkdownBody(b.String()) +} + +func (a *App) createMemory(root string, identity Identity, input MemoryInput) (map[string]any, []string, error) { + input.Title = strings.TrimSpace(input.Title) + input.Description = firstNonEmpty(input.Description, input.Title) + input.Risk = strings.ToLower(firstNonEmpty(input.Risk, "low")) + validRisk := map[string]bool{"low": true, "medium": true, "high": true, "critical": true} + if input.Title == "" { + return nil, nil, NewError(ExitUsage, "missing_title", "--title o title en JSON es obligatorio", nil) + } + if !validRisk[input.Risk] { + return nil, nil, NewError(ExitUsage, "invalid_risk", "risk debe ser low, medium, high o critical", input.Risk) + } + reviewRequired := true + if input.ReviewRequired != nil { + reviewRequired = *input.ReviewRequired + } + registry, err := loadAdmins(root) + if err != nil { + return nil, nil, err + } + input.ReviewPolicy = firstNonEmpty(input.ReviewPolicy, registry.ReviewPolicy.Default, "all-active") + if input.ReviewPolicy != "all-active" && input.ReviewPolicy != "all-active-except-author" { + return nil, nil, NewError(ExitUsage, "invalid_review_policy", "review_policy debe ser all-active o all-active-except-author", input.ReviewPolicy) + } + reviewers := []string{} + if reviewRequired { + reviewers = requiredReviewers(registry, input.ReviewPolicy, identity.Admin.ID) + } + now := a.Options.Now().UTC() + id, err := generateID("MEM", now) + if err != nil { + return nil, nil, err + } + slug := slugify(id + "-" + input.Title) + body := renderMemoryBody(input) + front := strings.Builder{} + front.WriteString("+++\n") + fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title)) + fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description)) + fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339)) + fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID})) + fmt.Fprintf(&front, "slug = %s\n", tomlString(slug)) + front.WriteString("template = \"memory.html\"\n\n") + front.WriteString("[taxonomies]\n") + front.WriteString("categories = [\"memorias\"]\n") + fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags))) + fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems))) + front.WriteString("[extra]\n") + fmt.Fprintf(&front, "id = %s\n", tomlString(id)) + front.WriteString("kind = \"memory\"\n") + fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID)) + fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint)) + fmt.Fprintf(&front, "risk = %s\n", tomlString(input.Risk)) + fmt.Fprintf(&front, "ticket = %s\n", tomlString(input.Ticket)) + fmt.Fprintf(&front, "review_required = %t\n", reviewRequired) + fmt.Fprintf(&front, "review_policy = %s\n", tomlString(input.ReviewPolicy)) + front.WriteString("reviewer_snapshot = true\n") + fmt.Fprintf(&front, "reviewers = %s\n", tomlStringArray(reviewers)) + fmt.Fprintf(&front, "started_at = %s\n", tomlString(input.StartedAt)) + fmt.Fprintf(&front, "finished_at = %s\n", tomlString(input.FinishedAt)) + fmt.Fprintf(&front, "duration_minutes = %d\n", input.DurationMinutes) + fmt.Fprintf(&front, "agent = %s\n", tomlString(input.Agent)) + front.WriteString("+++\n\n") + content := front.String() + body + if findings := scanSecrets(content); len(findings) > 0 { + return nil, nil, NewError(ExitValidation, "possible_secret", "la memoria parece contener secretos; redacte antes de registrarla", findings) + } + filename := now.Format("2006-01-02") + "-" + slug + ".md" + path := filepath.Join(root, "content", "memorias", filename) + if _, err := os.Stat(path); err == nil { + return nil, nil, NewError(ExitConflict, "file_exists", "ya existe el archivo de memoria", path) + } + if !a.Options.DryRun { + if err := writeFileAtomic(path, []byte(content), 0o644); err != nil { + return nil, nil, err + } + } + warnings := []string{} + if !a.Options.Commit { + warnings = append(warnings, "la atribución local usa la huella SSH, pero la prueba criptográfica exige un commit firmado; repita con --commit o firme el commit manualmente") + } + return map[string]any{ + "entry_id": id, "path": filepath.ToSlash(path), "slug": slug, + "review_required": reviewRequired, "reviewers": reviewers, "content_sha256": bytesSHA256([]byte(content)), + "dry_run": a.Options.DryRun, + }, warnings, nil +} + +func (a *App) createTask(root string, identity Identity, input TaskInput) (map[string]any, []string, error) { + input.Title = strings.TrimSpace(input.Title) + input.Description = firstNonEmpty(input.Description, input.Title) + input.Priority = strings.ToLower(firstNonEmpty(input.Priority, "medium")) + if input.Title == "" { + return nil, nil, NewError(ExitUsage, "missing_title", "el título de la tarea es obligatorio", nil) + } + if !map[string]bool{"low": true, "medium": true, "high": true, "urgent": true}[input.Priority] { + return nil, nil, NewError(ExitUsage, "invalid_priority", "priority debe ser low, medium, high o urgent", input.Priority) + } + if err := validateDate(input.Due); err != nil { + return nil, nil, NewError(ExitUsage, "invalid_due", err.Error(), nil) + } + if err := ensureProjectExists(root, input.ProjectID); err != nil { + return nil, nil, err + } + registry, err := loadAdmins(root) + if err != nil { + return nil, nil, err + } + for _, assignee := range input.Assignees { + admin, ok := adminByID(registry, assignee) + if !ok || !admin.Active { + return nil, nil, NewError(ExitValidation, "invalid_assignee", "la tarea contiene un responsable inexistente o inactivo", assignee) + } + } + now := a.Options.Now().UTC() + id, err := generateID("TSK", now) + if err != nil { + return nil, nil, err + } + slug := slugify(id + "-" + input.Title) + body := renderTaskBody(input) + var front strings.Builder + front.WriteString("+++\n") + fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title)) + fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description)) + fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339)) + fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID})) + fmt.Fprintf(&front, "slug = %s\n", tomlString(slug)) + front.WriteString("template = \"task.html\"\n\n") + front.WriteString("[taxonomies]\n") + front.WriteString("categories = [\"tareas\"]\n") + fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags))) + fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems))) + front.WriteString("[extra]\n") + fmt.Fprintf(&front, "id = %s\n", tomlString(id)) + front.WriteString("kind = \"task\"\nstatus = \"todo\"\n") + fmt.Fprintf(&front, "priority = %s\n", tomlString(input.Priority)) + fmt.Fprintf(&front, "project_id = %s\n", tomlString(normalizeID(input.ProjectID))) + fmt.Fprintf(&front, "assignees = %s\n", tomlStringArray(uniqueSorted(input.Assignees))) + fmt.Fprintf(&front, "estimate_minutes = %d\n", input.EstimateMinutes) + fmt.Fprintf(&front, "due = %s\n", tomlString(input.Due)) + fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID)) + fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint)) + front.WriteString("+++\n\n") + content := front.String() + body + if findings := scanSecrets(content); len(findings) > 0 { + return nil, nil, NewError(ExitValidation, "possible_secret", "la tarea parece contener secretos; redacte antes de registrarla", findings) + } + path := filepath.Join(root, "content", "tareas", now.Format("2006-01-02")+"-"+slug+".md") + if !a.Options.DryRun { + if err := writeFileAtomic(path, []byte(content), 0o644); err != nil { + return nil, nil, err + } + } + warnings := []string{} + if !a.Options.Commit { + warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado") + } + return map[string]any{"task_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil +} + +func (a *App) createProject(root string, identity Identity, input ProjectInput) (map[string]any, []string, error) { + input.Title = strings.TrimSpace(input.Title) + input.Description = firstNonEmpty(input.Description, input.Objective, input.Title) + input.Status = strings.ToLower(firstNonEmpty(input.Status, "planned")) + if input.Title == "" || strings.TrimSpace(input.Objective) == "" { + return nil, nil, NewError(ExitUsage, "missing_project_fields", "title y objective son obligatorios", nil) + } + if !map[string]bool{"planned": true, "active": true, "blocked": true, "done": true, "cancelled": true, "archived": true}[input.Status] { + return nil, nil, NewError(ExitUsage, "invalid_project_status", "status debe ser planned, active, blocked, done, cancelled o archived", input.Status) + } + if err := validateDate(input.TargetDate); err != nil { + return nil, nil, NewError(ExitUsage, "invalid_target_date", err.Error(), nil) + } + registry, err := loadAdmins(root) + if err != nil { + return nil, nil, err + } + for _, owner := range input.Owners { + admin, ok := adminByID(registry, owner) + if !ok || !admin.Active { + return nil, nil, NewError(ExitValidation, "invalid_owner", "el proyecto contiene un responsable inexistente o inactivo", owner) + } + } + if len(input.Owners) == 0 { + input.Owners = []string{identity.Admin.ID} + } + now := a.Options.Now().UTC() + id, err := generateID("PRJ", now) + if err != nil { + return nil, nil, err + } + slug := slugify(id + "-" + input.Title) + body := renderProjectBody(input) + var front strings.Builder + front.WriteString("+++\n") + fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title)) + fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description)) + fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339)) + fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID})) + fmt.Fprintf(&front, "slug = %s\n", tomlString(slug)) + front.WriteString("template = \"project.html\"\n\n") + front.WriteString("[taxonomies]\n") + front.WriteString("categories = [\"proyectos\"]\n") + fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags))) + fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems))) + front.WriteString("[extra]\n") + fmt.Fprintf(&front, "id = %s\n", tomlString(id)) + front.WriteString("kind = \"project\"\n") + fmt.Fprintf(&front, "status = %s\n", tomlString(input.Status)) + fmt.Fprintf(&front, "owners = %s\n", tomlStringArray(uniqueSorted(input.Owners))) + fmt.Fprintf(&front, "target_date = %s\n", tomlString(input.TargetDate)) + fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID)) + fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint)) + front.WriteString("+++\n\n") + content := front.String() + body + if findings := scanSecrets(content); len(findings) > 0 { + return nil, nil, NewError(ExitValidation, "possible_secret", "el proyecto parece contener secretos; redacte antes de registrarlo", findings) + } + path := filepath.Join(root, "content", "proyectos", now.Format("2006-01-02")+"-"+slug+".md") + if !a.Options.DryRun { + if err := writeFileAtomic(path, []byte(content), 0o644); err != nil { + return nil, nil, err + } + } + warnings := []string{} + if !a.Options.Commit { + warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado") + } + return map[string]any{"project_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil +} + +func (a *App) createContent(root string, identity Identity, input ContentInput) (map[string]any, []string, error) { + input.Section = strings.ToLower(strings.TrimSpace(input.Section)) + if input.Section != "documentacion" && input.Section != "manuales" { + return nil, nil, NewError(ExitUsage, "invalid_section", "section debe ser documentacion o manuales", input.Section) + } + input.Title = strings.TrimSpace(input.Title) + input.Description = firstNonEmpty(input.Description, input.Title) + if input.Title == "" || strings.TrimSpace(input.BodyMarkdown) == "" { + return nil, nil, NewError(ExitUsage, "missing_content_fields", "title y body_markdown son obligatorios", nil) + } + now := a.Options.Now().UTC() + idPrefix := "DOC" + if input.Section == "manuales" { + idPrefix = "RUN" + } + id, err := generateID(idPrefix, now) + if err != nil { + return nil, nil, err + } + slug := slugify(id + "-" + input.Title) + var front strings.Builder + front.WriteString("+++\n") + fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title)) + fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description)) + fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339)) + fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID})) + fmt.Fprintf(&front, "slug = %s\n", tomlString(slug)) + front.WriteString("template = \"page.html\"\n") + if input.Weight != 0 { + fmt.Fprintf(&front, "weight = %d\n", input.Weight) + } + front.WriteString("\n[taxonomies]\n") + fmt.Fprintf(&front, "categories = %s\n", tomlStringArray([]string{input.Section})) + fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags))) + fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems))) + front.WriteString("[extra]\n") + fmt.Fprintf(&front, "id = %s\n", tomlString(id)) + fmt.Fprintf(&front, "kind = %s\n", tomlString(input.Section)) + fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID)) + fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint)) + front.WriteString("+++\n\n") + content := front.String() + normalizeMarkdownBody(input.BodyMarkdown) + if findings := scanSecrets(content); len(findings) > 0 { + return nil, nil, NewError(ExitValidation, "possible_secret", "el contenido parece incluir secretos; redacte antes de registrarlo", findings) + } + path := filepath.Join(root, "content", input.Section, now.Format("2006-01-02")+"-"+slug+".md") + if !a.Options.DryRun { + if err := writeFileAtomic(path, []byte(content), 0o644); err != nil { + return nil, nil, err + } + } + warnings := []string{} + if !a.Options.Commit { + warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado") + } + return map[string]any{"content_id": id, "section": input.Section, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil +} diff --git a/internal/clusterlog/events.go b/internal/clusterlog/events.go new file mode 100644 index 0000000..30ab305 --- /dev/null +++ b/internal/clusterlog/events.go @@ -0,0 +1,114 @@ +package clusterlog + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + reviewEventsDirName = "review-events" + taskEventsDirName = "task-events" +) + +func listJSONFiles(dir string) ([]string, error) { + files := []string{} + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return files, nil + } + return nil, err + } + for _, entry := range entries { + if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".json" { + continue + } + files = append(files, filepath.Join(dir, entry.Name())) + } + sort.Strings(files) + return files, nil +} + +func reviewEventFiles(root string) ([]string, error) { + return listJSONFiles(filepath.Join(root, "data", reviewEventsDirName)) +} + +func taskEventFiles(root string) ([]string, error) { + return listJSONFiles(filepath.Join(root, "data", taskEventsDirName)) +} + +func eventFilePath(root, dirName, id string) string { + return filepath.Join(root, "data", dirName, strings.ToLower(id)+".json") +} + +func writeEventFile(path string, value any) error { + if _, err := os.Stat(path); err == nil { + return NewError(ExitConflict, "event_exists", "ya existe un evento con el mismo identificador", filepath.ToSlash(path)) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return writeJSONAtomic(path, value) +} + +func writeReviewEvent(root string, event ReviewEvent) (string, error) { + path := eventFilePath(root, reviewEventsDirName, event.ID) + return path, writeEventFile(path, event) +} + +func writeTaskEvents(root string, events []TaskEvent) ([]string, error) { + paths := make([]string, 0, len(events)) + for _, event := range events { + path := eventFilePath(root, taskEventsDirName, event.ID) + if err := writeEventFile(path, event); err != nil { + return paths, err + } + paths = append(paths, path) + } + return paths, nil +} + +func parseEventTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{} + } + return parsed +} + +func sortReviewEvents(events []ReviewEvent) { + sort.SliceStable(events, func(i, j int) bool { + ti := parseEventTime(events[i].ReviewedAt) + tj := parseEventTime(events[j].ReviewedAt) + if ti.Equal(tj) { + return events[i].ID < events[j].ID + } + return ti.Before(tj) + }) +} + +func sortTaskEvents(events []TaskEvent) { + typeOrder := map[string]int{"start": 0, "stop": 1, "adjust": 2, "status": 3} + sort.SliceStable(events, func(i, j int) bool { + ti := parseEventTime(events[i].At) + tj := parseEventTime(events[j].At) + if ti.Equal(tj) { + oi, iOK := typeOrder[events[i].Type] + oj, jOK := typeOrder[events[j].Type] + if !iOK { + oi = 9 + } + if !jOK { + oj = 9 + } + if oi == oj { + return events[i].ID < events[j].ID + } + return oi < oj + } + return ti.Before(tj) + }) +} diff --git a/internal/clusterlog/git.go b/internal/clusterlog/git.go new file mode 100644 index 0000000..c142cfd --- /dev/null +++ b/internal/clusterlog/git.go @@ -0,0 +1,88 @@ +package clusterlog + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +func gitOutput(root string, args ...string) (string, error) { + full := append([]string{"-C", root}, args...) + cmd := exec.Command("git", full...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), message) + } + return strings.TrimSpace(string(output)), nil +} + +func isGitRepo(root string) bool { + output, err := gitOutput(root, "rev-parse", "--is-inside-work-tree") + return err == nil && output == "true" +} + +func (a *App) commitPaths(root string, identity Identity, message string, paths []string) (string, error) { + if !a.Options.Commit { + return "", nil + } + if !isGitRepo(root) { + return "", NewError(ExitGit, "not_git_repo", "--commit requiere que el proyecto sea un repositorio Git", root) + } + rel, err := relativePaths(root, paths) + if err != nil { + return "", err + } + addArgs := append([]string{"add", "--"}, rel...) + if _, err := gitOutput(root, addArgs...); err != nil { + return "", NewError(ExitGit, "git_add_failed", err.Error(), rel) + } + + args := []string{ + "-c", "user.name=" + identity.Admin.Name, + "-c", "user.email=" + identity.Admin.Email, + "-c", "gpg.format=ssh", + "-c", "user.signingkey=" + identity.SigningKey, + "commit", "-S", "-m", message, "--", + } + args = append(args, rel...) + if _, err := gitOutput(root, args...); err != nil { + return "", NewError(ExitGit, "git_commit_failed", + "no se pudo crear el commit SSH firmado", map[string]any{ + "cause": err.Error(), + "signing_key": identity.SigningKey, + "hint": "compruebe OpenSSH/ssh-keygen y que la llave privada esté disponible o cargada en ssh-agent", + }) + } + sha, err := gitOutput(root, "rev-parse", "HEAD") + if err != nil { + return "", err + } + return sha, nil +} + +func verifyCommit(root, sha string) error { + if sha == "" { + sha = "HEAD" + } + allowed := filepath.Join(root, "config", "allowed_signers") + _, err := gitOutput(root, + "-c", "gpg.format=ssh", + "-c", "gpg.ssh.allowedSignersFile="+allowed, + "verify-commit", sha, + ) + if err != nil { + return NewError(ExitValidation, "invalid_commit_signature", "el commit no tiene una firma SSH válida y autorizada", map[string]any{ + "commit": sha, + "cause": err.Error(), + }) + } + return nil +} diff --git a/internal/clusterlog/identity.go b/internal/clusterlog/identity.go new file mode 100644 index 0000000..6e441f1 --- /dev/null +++ b/internal/clusterlog/identity.go @@ -0,0 +1,226 @@ +package clusterlog + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +func loadAdmins(root string) (AdminRegistry, error) { + var registry AdminRegistry + if err := loadJSON(filepath.Join(root, "data", "admins.json"), ®istry); err != nil { + return registry, err + } + if registry.Version == 0 { + registry.Version = 1 + } + if registry.ReviewPolicy.Default == "" { + registry.ReviewPolicy.Default = "all-active" + registry.ReviewPolicy.IncludeAuthor = true + } + return registry, nil +} + +func saveAdmins(root string, registry AdminRegistry) error { + if err := writeJSONAtomic(filepath.Join(root, "data", "admins.json"), registry); err != nil { + return err + } + return writeAllowedSigners(root, registry) +} + +func writeAllowedSigners(root string, registry AdminRegistry) error { + lines := []string{ + "# Archivo generado por clusterlog. Contiene únicamente llaves públicas.", + "# principal namespaces=\"git\" tipo base64", + } + seen := map[string]bool{} + for _, admin := range registry.Admins { + if !admin.Active { + continue + } + for _, key := range admin.SSHKeys { + fields := strings.Fields(key.PublicKey) + if len(fields) < 2 { + continue + } + line := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1]) + if !seen[line] { + lines = append(lines, line) + seen[line] = true + } + } + } + sort.Strings(lines[2:]) + return writeFileAtomic(filepath.Join(root, "config", "allowed_signers"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + +func parsePublicKeyLine(line string) (canonical, fingerprint string, err error) { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 { + return "", "", fmt.Errorf("la llave pública debe tener formato OpenSSH: tipo base64 [comentario]") + } + decoded, err := base64.StdEncoding.DecodeString(fields[1]) + if err != nil { + return "", "", fmt.Errorf("base64 inválido en llave pública: %w", err) + } + sum := sha256.Sum256(decoded) + fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]) + canonical = fields[0] + " " + fields[1] + if len(fields) > 2 { + canonical += " " + strings.Join(fields[2:], " ") + } + return canonical, fingerprint, nil +} + +func readPublicKey(path string) (canonical, fingerprint, publicPath, signingPath string, err error) { + path = strings.TrimSpace(path) + if path == "" { + return "", "", "", "", fmt.Errorf("ruta de llave vacía") + } + expanded, err := expandPath(path) + if err != nil { + return "", "", "", "", err + } + original := expanded + // user.signingkey suele apuntar a la llave privada; para identificar al + // administrador siempre leemos la contraparte pública cuando existe. + if !strings.HasSuffix(expanded, ".pub") { + if _, pubErr := os.Stat(expanded + ".pub"); pubErr == nil { + expanded += ".pub" + } + } + info, statErr := os.Stat(expanded) + if statErr != nil { + return "", "", "", "", statErr + } + if info.IsDir() { + return "", "", "", "", fmt.Errorf("%s es un directorio", expanded) + } + data, err := os.ReadFile(expanded) + if err != nil { + return "", "", "", "", err + } + canonical, fingerprint, err = parsePublicKeyLine(string(data)) + if err != nil { + return "", "", "", "", err + } + publicPath = expanded + signingPath = expanded + if original != expanded { + signingPath = original + } else if strings.HasSuffix(expanded, ".pub") { + privateCandidate := strings.TrimSuffix(expanded, ".pub") + if _, err := os.Stat(privateCandidate); err == nil { + signingPath = privateCandidate + } + } + return canonical, fingerprint, publicPath, signingPath, nil +} + +func expandPath(path string) (string, error) { + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + path = home + } else { + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + return filepath.Abs(path) +} + +func candidateKeyPaths(explicit string) []string { + candidates := []string{} + if explicit != "" { + candidates = append(candidates, explicit) + } + if env := os.Getenv("CLUSTERLOG_SSH_KEY"); env != "" { + candidates = append(candidates, env) + } + if output, err := exec.Command("git", "config", "--get", "user.signingkey").Output(); err == nil { + value := strings.TrimSpace(string(output)) + if strings.HasPrefix(value, "key::") { + // Una llave inline se identifica, pero no sirve como ruta para firmar. + } else if value != "" { + candidates = append(candidates, value) + } + } + if home, err := os.UserHomeDir(); err == nil { + for _, name := range []string{"id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"} { + candidates = append(candidates, filepath.Join(home, ".ssh", name)) + } + } + seen := map[string]bool{} + out := []string{} + for _, candidate := range candidates { + if candidate == "" || seen[candidate] { + continue + } + seen[candidate] = true + out = append(out, candidate) + } + return out +} + +func (a *App) ResolveIdentity(root string) (Identity, error) { + registry, err := loadAdmins(root) + if err != nil { + return Identity{}, err + } + attempted := []string{} + for _, path := range candidateKeyPaths(a.Options.SSHKey) { + canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path) + if err != nil { + attempted = append(attempted, fmt.Sprintf("%s: %v", path, err)) + continue + } + for _, admin := range registry.Admins { + if !admin.Active { + continue + } + for _, key := range admin.SSHKeys { + if key.Fingerprint == fingerprint { + return Identity{ + Admin: admin, Fingerprint: fingerprint, PublicKey: canonical, + PublicPath: publicPath, SigningKey: signingPath, + }, nil + } + } + } + attempted = append(attempted, fmt.Sprintf("%s: %s no está registrada", path, fingerprint)) + } + return Identity{}, NewError(ExitIdentity, "identity_not_found", + "ninguna llave SSH local coincide con un administrador activo", map[string]any{ + "attempted": attempted, + "hint": "use --ssh-key ~/.ssh/id_ed25519.pub o CLUSTERLOG_SSH_KEY", + }) +} + +func adminByID(registry AdminRegistry, id string) (Admin, bool) { + id = normalizeID(id) + for _, admin := range registry.Admins { + if normalizeID(admin.ID) == id { + return admin, true + } + } + return Admin{}, false +} + +func activeAdminIDs(registry AdminRegistry) []string { + ids := []string{} + for _, admin := range registry.Admins { + if admin.Active { + ids = append(ids, admin.ID) + } + } + sort.Strings(ids) + return ids +} diff --git a/internal/clusterlog/markdown.go b/internal/clusterlog/markdown.go new file mode 100644 index 0000000..d9ea0f8 --- /dev/null +++ b/internal/clusterlog/markdown.go @@ -0,0 +1,209 @@ +package clusterlog + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +func parseMarkdown(path string) (PageMeta, error) { + data, err := os.ReadFile(path) + if err != nil { + return PageMeta{}, err + } + text := strings.ReplaceAll(string(data), "\r\n", "\n") + if !strings.HasPrefix(text, "+++\n") { + return PageMeta{}, fmt.Errorf("%s no tiene front matter TOML delimitado por +++", path) + } + rest := strings.TrimPrefix(text, "+++\n") + idx := strings.Index(rest, "\n+++\n") + if idx < 0 { + if strings.HasSuffix(rest, "\n+++") { + idx = len(rest) - len("\n+++") + } else { + return PageMeta{}, fmt.Errorf("%s tiene front matter sin cierre +++", path) + } + } + rawFront := rest[:idx] + bodyStart := idx + len("\n+++\n") + body := "" + if bodyStart <= len(rest) { + body = rest[bodyStart:] + } + + meta := PageMeta{ + Path: path, + Body: body, + Root: map[string]any{}, + Taxonomies: map[string][]string{}, + Extra: map[string]any{}, + RawFront: rawFront, + ContentHash: bytesSHA256(data), + } + + section := "root" + scanner := bufio.NewScanner(strings.NewReader(rawFront)) + scanner.Buffer(make([]byte, 1024), 1024*1024) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(stripTOMLComment(scanner.Text())) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + continue + } + key, raw, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + raw = strings.TrimSpace(raw) + value, err := parseTOMLValue(raw) + if err != nil { + return PageMeta{}, fmt.Errorf("%s:%d: %w", path, lineNo, err) + } + switch section { + case "root": + meta.Root[key] = value + case "taxonomies": + values, ok := value.([]string) + if ok { + meta.Taxonomies[key] = values + } + case "extra": + meta.Extra[key] = value + default: + // El parser intencionalmente ignora secciones no usadas por clusterlog. + } + } + if err := scanner.Err(); err != nil { + return PageMeta{}, err + } + return meta, nil +} + +func stripTOMLComment(line string) string { + inString := false + escaped := false + for i, r := range line { + if escaped { + escaped = false + continue + } + if r == '\\' && inString { + escaped = true + continue + } + if r == '"' { + inString = !inString + continue + } + if r == '#' && !inString { + return line[:i] + } + } + return line +} + +func parseTOMLValue(raw string) (any, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + if strings.HasPrefix(raw, "\"") { + value, err := strconv.Unquote(raw) + if err != nil { + return nil, fmt.Errorf("cadena TOML inválida: %w", err) + } + return value, nil + } + if raw == "true" { + return true, nil + } + if raw == "false" { + return false, nil + } + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + return parseTOMLStringArray(raw) + } + if n, err := strconv.Atoi(raw); err == nil { + return n, nil + } + // Fechas TOML y otros escalares simples se conservan como texto. + return raw, nil +} + +func parseTOMLStringArray(raw string) ([]string, error) { + content := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")) + if content == "" { + return []string{}, nil + } + var values []string + var current strings.Builder + inString := false + escaped := false + flush := func() error { + item := strings.TrimSpace(current.String()) + current.Reset() + if item == "" { + return nil + } + value, err := strconv.Unquote(item) + if err != nil { + return fmt.Errorf("arreglo TOML inválido: %w", err) + } + values = append(values, value) + return nil + } + for _, r := range content { + if escaped { + current.WriteRune(r) + escaped = false + continue + } + if r == '\\' && inString { + current.WriteRune(r) + escaped = true + continue + } + if r == '"' { + inString = !inString + current.WriteRune(r) + continue + } + if r == ',' && !inString { + if err := flush(); err != nil { + return nil, err + } + continue + } + current.WriteRune(r) + } + if inString { + return nil, fmt.Errorf("arreglo TOML con cadena sin cerrar") + } + if err := flush(); err != nil { + return nil, err + } + return values, nil +} + +func pageSlug(meta PageMeta) string { + if value := meta.String("slug"); value != "" { + return value + } + return slugify(strings.TrimSuffix(filepathBase(meta.Path), ".md")) +} + +func filepathBase(path string) string { + path = strings.ReplaceAll(path, "\\", "/") + if idx := strings.LastIndex(path, "/"); idx >= 0 { + return path[idx+1:] + } + return path +} diff --git a/internal/clusterlog/markdown_test.go b/internal/clusterlog/markdown_test.go new file mode 100644 index 0000000..446dabb --- /dev/null +++ b/internal/clusterlog/markdown_test.go @@ -0,0 +1,57 @@ +package clusterlog + +import ( + "path/filepath" + "testing" +) + +func TestParseMarkdownFrontMatter(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "page.md") + mustWriteTestFile(t, path, `+++ +title = "Prueba # literal" +description = "Página de prueba" +date = 2026-08-12T08:30:00Z +authors = ["ana", "bob"] +slug = "pagina-prueba" + +[taxonomies] +tags = ["red", "cambio"] +systems = ["spine-01"] + +[extra] +id = "MEM-TEST-001" +review_required = true +duration_minutes = 45 +reviewers = ["ana", "bob"] # comentario ++++ + +## Cuerpo + +Contenido. +`) + meta, err := parseMarkdown(path) + if err != nil { + t.Fatal(err) + } + if meta.String("title") != "Prueba # literal" { + t.Fatalf("unexpected title: %q", meta.String("title")) + } + if meta.ExtraString("id") != "MEM-TEST-001" || !meta.ExtraBool("review_required") || meta.ExtraInt("duration_minutes") != 45 { + t.Fatalf("unexpected extra metadata: %#v", meta.Extra) + } + if got := meta.Taxonomies["tags"]; len(got) != 2 || got[0] != "red" || got[1] != "cambio" { + t.Fatalf("unexpected tags: %#v", got) + } + if meta.ContentHash == "" || meta.Body == "" { + t.Fatal("expected content hash and body") + } +} + +func TestParseMarkdownRejectsUnclosedFrontMatter(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.md") + mustWriteTestFile(t, path, "+++\ntitle = \"sin cierre\"\n") + if _, err := parseMarkdown(path); err == nil { + t.Fatal("expected invalid front matter to fail") + } +} diff --git a/internal/clusterlog/schema.go b/internal/clusterlog/schema.go new file mode 100644 index 0000000..a569e4a --- /dev/null +++ b/internal/clusterlog/schema.go @@ -0,0 +1,164 @@ +package clusterlog + +import ( + "encoding/json" + "strings" +) + +type inputSchema struct { + Schema string `json:"$schema"` + ID string `json:"$id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + Required []string `json:"required,omitempty"` + Properties map[string]map[string]any `json:"properties"` + Additional bool `json:"additionalProperties"` + Examples []any `json:"examples,omitempty"` +} + +func property(kind, description string) map[string]any { + return map[string]any{"type": kind, "description": description} +} + +func stringArray(description string) map[string]any { + return map[string]any{ + "type": "array", "description": description, + "items": map[string]any{"type": "string"}, "uniqueItems": true, + } +} + +func enumProperty(description string, values ...string) map[string]any { + items := make([]any, len(values)) + for i, value := range values { + items[i] = value + } + return map[string]any{"type": "string", "description": description, "enum": items} +} + +func schemaFor(name string) (inputSchema, bool) { + base := inputSchema{ + Schema: "https://json-schema.org/draft/2020-12/schema", + ID: "https://clusterlog.local/schemas/" + name + ".json", + Type: "object", Additional: false, + } + switch name { + case "memory": + base.Title = "Entrada de memoria operativa" + base.Description = "Contrato de entrada para clusterlog memory create --from-json. Nunca incluya secretos sin redactar." + base.Required = []string{"title"} + base.Properties = map[string]map[string]any{ + "title": property("string", "Título breve y accionable"), + "description": property("string", "Resumen para listados"), + "tags": stringArray("Etiquetas temáticas"), + "systems": stringArray("Sistemas o componentes afectados"), + "risk": enumProperty("Nivel de riesgo", "low", "medium", "high", "critical"), + "ticket": property("string", "Ticket, incidente o cambio relacionado"), + "review_required": property("boolean", "Incluye la entrada en la cola de revisión"), + "review_policy": enumProperty("Quién debe revisar", "all-active", "all-active-except-author"), + "body_markdown": property("string", "Cuerpo Markdown completo; sustituye las secciones estructuradas"), + "context": property("string", "Contexto de la intervención"), + "changes": stringArray("Cambios realizados"), + "commands": stringArray("Comandos relevantes con secretos redactados"), + "validation": stringArray("Comprobaciones ejecutadas"), + "rollback": property("string", "Procedimiento de reversión"), + "started_at": property("string", "Inicio RFC3339"), + "finished_at": property("string", "Fin RFC3339"), + "duration_minutes": property("integer", "Duración total en minutos"), + "agent": property("string", "Agente que preparó el borrador, por ejemplo claude-code"), + } + base.Examples = []any{MemoryInput{ + Title: "Actualizar firmware del switch spine-01", Description: "Cambio controlado y validación de enlaces", + Tags: []string{"red", "firmware"}, Systems: []string{"spine-01"}, Risk: "high", Ticket: "CHG-1042", + Changes: []string{"Respaldé la configuración", "Instalé la versión aprobada"}, + Commands: []string{"show version", "show interfaces status"}, + Validation: []string{"Todos los uplinks quedaron up", "Sin errores nuevos en logs"}, + Rollback: "Arrancar la imagen anterior y restaurar la configuración respaldada.", Agent: "claude-code", + }} + case "task": + base.Title = "Tarea compartida" + base.Description = "Contrato de entrada para clusterlog task create --from-json." + base.Required = []string{"title"} + base.Properties = map[string]map[string]any{ + "title": property("string", "Título de la tarea"), + "description": property("string", "Resumen"), + "project_id": property("string", "Proyecto relacionado, si existe"), + "priority": enumProperty("Prioridad", "low", "medium", "high", "urgent"), + "assignees": stringArray("IDs de administradores responsables"), + "estimate_minutes": property("integer", "Estimación en minutos"), + "due": property("string", "Fecha límite YYYY-MM-DD"), + "tags": stringArray("Etiquetas"), + "systems": stringArray("Sistemas afectados"), + "body_markdown": property("string", "Cuerpo Markdown completo"), + "objective": property("string", "Resultado esperado"), + "acceptance_criteria": stringArray("Criterios verificables"), + "notes": property("string", "Notas de ejecución"), + } + base.Examples = []any{TaskInput{Title: "Probar restauración de etcd", Priority: "high", EstimateMinutes: 120, Due: "2026-09-15", Objective: "Validar el RTO del plano de control", Acceptance: []string{"Restauración documentada", "RTO menor a 60 minutos"}}} + case "project": + base.Title = "Proyecto de operación" + base.Description = "Contrato de entrada para clusterlog project create --from-json." + base.Required = []string{"title", "objective"} + base.Properties = map[string]map[string]any{ + "title": property("string", "Nombre del proyecto"), + "description": property("string", "Resumen ejecutivo"), + "objective": property("string", "Objetivo medible"), + "owners": stringArray("IDs de administradores responsables"), + "target_date": property("string", "Fecha objetivo YYYY-MM-DD"), + "status": enumProperty("Estado", "planned", "active", "blocked", "done", "cancelled", "archived"), + "tags": stringArray("Etiquetas"), + "systems": stringArray("Sistemas incluidos"), + "body_markdown": property("string", "Cuerpo Markdown completo"), + "scope": stringArray("Elementos dentro del alcance"), + "out_of_scope": stringArray("Elementos fuera del alcance"), + "milestones": stringArray("Hitos"), + "success_criteria": stringArray("Criterios de éxito"), + } + base.Examples = []any{ProjectInput{Title: "Estandarizar observabilidad", Objective: "Unificar métricas, logs y alertas del clúster", Status: "planned", Scope: []string{"Inventario de alertas", "Dashboards base"}, Success: []string{"100% de nodos reportando métricas"}}} + case "content": + base.Title = "Documento o manual" + base.Description = "Contrato de entrada para clusterlog content create --from-json." + base.Required = []string{"section", "title", "body_markdown"} + base.Properties = map[string]map[string]any{ + "section": enumProperty("Sección principal", "documentacion", "manuales"), + "title": property("string", "Título"), + "description": property("string", "Resumen"), + "tags": stringArray("Etiquetas"), + "systems": stringArray("Sistemas relacionados"), + "body_markdown": property("string", "Contenido Markdown"), + "weight": property("integer", "Orden opcional dentro de la sección"), + } + base.Examples = []any{ContentInput{Section: "manuales", Title: "Reemplazo seguro de un nodo de cómputo", Tags: []string{"runbook"}, BodyMarkdown: "## Prerrequisitos\n\n...\n"}} + default: + return inputSchema{}, false + } + return base, true +} + +func (a *App) runSchema(args []string) error { + if len(args) == 0 { + return a.printResult("schema", map[string]any{ + "available": []string{"memory", "task", "project", "content"}, + "usage": a.Options.Executable + " --json schema memory", + "stdin_example": "cat input.json | " + a.Options.Executable + " --json --commit memory create --from-json -", + }) + } + if len(args) != 1 { + return NewError(ExitUsage, "schema_usage", "uso: schema [memory|task|project|content]", args) + } + name := strings.ToLower(args[0]) + schema, ok := schemaFor(name) + if !ok { + return NewError(ExitUsage, "unknown_schema", "esquema desconocido", name) + } + // Convertir a un mapa preserva el documento JSON Schema como dato del sobre de salida. + data, err := json.Marshal(schema) + if err != nil { + return err + } + var document map[string]any + if err := json.Unmarshal(data, &document); err != nil { + return err + } + return a.printResult("schema."+name, document) +} diff --git a/internal/clusterlog/state.go b/internal/clusterlog/state.go new file mode 100644 index 0000000..676179f --- /dev/null +++ b/internal/clusterlog/state.go @@ -0,0 +1,539 @@ +package clusterlog + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const siteStateVersion = 2 + +func loadReviewLog(root string) (ReviewLog, error) { + log := ReviewLog{Version: 1, Events: []ReviewEvent{}} + files, err := reviewEventFiles(root) + if err != nil { + return log, err + } + for _, path := range files { + var event ReviewEvent + if err := loadJSON(path, &event); err != nil { + return log, err + } + log.Events = append(log.Events, event) + } + sortReviewEvents(log.Events) + return log, nil +} + +func loadTaskLog(root string) (TaskLog, error) { + log := TaskLog{Version: 1, Events: []TaskEvent{}} + files, err := taskEventFiles(root) + if err != nil { + return log, err + } + for _, path := range files { + var event TaskEvent + if err := loadJSON(path, &event); err != nil { + return log, err + } + log.Events = append(log.Events, event) + } + sortTaskEvents(log.Events) + return log, nil +} + +func requiredReviewers(registry AdminRegistry, policy, authorID string) []string { + policy = firstNonEmpty(policy, registry.ReviewPolicy.Default) + required := activeAdminIDs(registry) + excludeAuthor := policy == "all-active-except-author" || + (policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor) + if !excludeAuthor || authorID == "" { + return required + } + filtered := make([]string, 0, len(required)) + for _, id := range required { + if id != authorID { + filtered = append(filtered, id) + } + } + return filtered +} + +func (a *App) BuildState(root string) (SiteState, error) { + registry, err := loadAdmins(root) + if err != nil { + return SiteState{}, err + } + reviews, err := loadReviewLog(root) + if err != nil { + return SiteState{}, err + } + tasksLog, err := loadTaskLog(root) + if err != nil { + return SiteState{}, err + } + + memoryFiles, err := listMarkdownFiles(root, "memorias") + if err != nil { + return SiteState{}, err + } + taskFiles, err := listMarkdownFiles(root, "tareas") + if err != nil { + return SiteState{}, err + } + projectFiles, err := listMarkdownFiles(root, "proyectos") + if err != nil { + return SiteState{}, err + } + + reviewFiles, err := reviewEventFiles(root) + if err != nil { + return SiteState{}, err + } + taskEventPaths, err := taskEventFiles(root) + if err != nil { + return SiteState{}, err + } + sourcePaths := []string{filepath.Join(root, "data", "admins.json")} + sourcePaths = append(sourcePaths, reviewFiles...) + sourcePaths = append(sourcePaths, taskEventPaths...) + sourcePaths = append(sourcePaths, memoryFiles...) + sourcePaths = append(sourcePaths, taskFiles...) + sourcePaths = append(sourcePaths, projectFiles...) + digest, err := sourceDigest(sourcePaths) + if err != nil { + return SiteState{}, err + } + + memories := make([]MemoryRecord, 0, len(memoryFiles)) + for _, path := range memoryFiles { + meta, err := parseMarkdown(path) + if err != nil { + return SiteState{}, err + } + id := normalizeID(meta.ExtraString("id")) + if id == "" { + continue + } + rel, _ := filepath.Rel(root, path) + record := MemoryRecord{ + EntryID: id, + Title: meta.String("title"), + Description: meta.String("description"), + Date: meta.String("date"), + AuthorID: meta.ExtraString("author_id"), + AuthorFingerprint: meta.ExtraString("author_fingerprint"), + Risk: firstNonEmpty(meta.ExtraString("risk"), "low"), + Ticket: meta.ExtraString("ticket"), + ReviewRequired: meta.ExtraBool("review_required"), + ReviewPolicy: meta.ExtraString("review_policy"), + ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"), + Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")), + Tags: uniqueSorted(meta.Taxonomies["tags"]), + Systems: uniqueSorted(meta.Taxonomies["systems"]), + Slug: pageSlug(meta), + SourcePath: filepath.ToSlash(rel), + ContentSHA256: meta.ContentHash, + } + record.Permalink = "/memorias/" + record.Slug + "/" + if record.ReviewPolicy == "" { + record.ReviewPolicy = registry.ReviewPolicy.Default + } + memories = append(memories, record) + } + sort.Slice(memories, func(i, j int) bool { + if memories[i].Date == memories[j].Date { + return memories[i].EntryID > memories[j].EntryID + } + return memories[i].Date > memories[j].Date + }) + + activeIDs := activeAdminIDs(registry) + queue := []ReviewQueueItem{} + for _, memory := range memories { + if !memory.ReviewRequired { + continue + } + var required []string + if memory.ReviewerSnapshot { + required = append([]string(nil), memory.Reviewers...) + } else { + required = requiredReviewers(registry, memory.ReviewPolicy, memory.AuthorID) + } + + reviewedSet := map[string]bool{} + for _, event := range reviews.Events { + if normalizeID(event.EntryID) != memory.EntryID || event.ContentSHA256 != memory.ContentSHA256 { + continue + } + if contains(required, event.AdminID) { + reviewedSet[event.AdminID] = true + } + } + reviewed := []string{} + missing := []string{} + for _, id := range required { + if reviewedSet[id] { + reviewed = append(reviewed, id) + } else { + missing = append(missing, id) + } + } + item := ReviewQueueItem{ + EntryID: memory.EntryID, Title: memory.Title, Description: memory.Description, + Date: memory.Date, AuthorID: memory.AuthorID, Risk: memory.Risk, + Tags: memory.Tags, Systems: memory.Systems, Permalink: memory.Permalink, + ContentSHA256: memory.ContentSHA256, ReviewedBy: reviewed, + MissingReviewers: missing, RequiredCount: len(required), ReviewedCount: len(reviewed), + } + if !memory.ReviewerSnapshot && len(activeIDs) == 0 { + item.ConfigurationError = "No hay administradores activos; registre al menos uno antes de cerrar revisiones." + } + if item.ConfigurationError != "" || len(missing) > 0 { + queue = append(queue, item) + } + } + + taskStates := make([]TaskState, 0, len(taskFiles)) + taskIndex := map[string]int{} + for _, path := range taskFiles { + meta, err := parseMarkdown(path) + if err != nil { + return SiteState{}, err + } + id := normalizeID(meta.ExtraString("id")) + if id == "" { + continue + } + rel, _ := filepath.Rel(root, path) + state := TaskState{ + TaskID: id, + RunningBy: []string{}, + Title: meta.String("title"), + Description: meta.String("description"), + Status: firstNonEmpty(meta.ExtraString("status"), "todo"), + Priority: firstNonEmpty(meta.ExtraString("priority"), "medium"), + ProjectID: normalizeID(meta.ExtraString("project_id")), + Assignees: uniqueSorted(meta.ExtraStrings("assignees")), + EstimateMinutes: meta.ExtraInt("estimate_minutes"), + Due: meta.ExtraString("due"), + Slug: pageSlug(meta), + SourcePath: filepath.ToSlash(rel), + } + state.Permalink = "/tareas/" + state.Slug + "/" + taskIndex[id] = len(taskStates) + taskStates = append(taskStates, state) + } + running := map[string]map[string]bool{} + for _, event := range tasksLog.Events { + id := normalizeID(event.TaskID) + idx, ok := taskIndex[id] + if !ok { + continue + } + state := &taskStates[idx] + actor := event.AdminID + switch event.Type { + case "start": + if running[id] == nil { + running[id] = map[string]bool{} + } + running[id][actor] = true + if state.Status == "todo" || state.Status == "backlog" { + state.Status = "in_progress" + } + case "stop": + state.ActualMinutes += event.Minutes + if running[id] != nil { + delete(running[id], actor) + } + case "adjust": + state.ActualMinutes += event.Minutes + case "status": + if event.Status != "" { + state.Status = event.Status + } + } + } + for id, actors := range running { + idx, ok := taskIndex[id] + if !ok { + continue + } + for actor, isRunning := range actors { + if isRunning { + taskStates[idx].RunningBy = append(taskStates[idx].RunningBy, actor) + } + } + sort.Strings(taskStates[idx].RunningBy) + } + sort.Slice(taskStates, func(i, j int) bool { + order := map[string]int{"in_progress": 0, "blocked": 1, "todo": 2, "backlog": 3, "done": 4, "cancelled": 5} + oi, ok := order[taskStates[i].Status] + if !ok { + oi = 9 + } + oj, ok := order[taskStates[j].Status] + if !ok { + oj = 9 + } + if oi == oj { + if taskStates[i].Due == taskStates[j].Due { + return taskStates[i].TaskID < taskStates[j].TaskID + } + if taskStates[i].Due == "" { + return false + } + if taskStates[j].Due == "" { + return true + } + return taskStates[i].Due < taskStates[j].Due + } + return oi < oj + }) + + projects := make([]ProjectState, 0, len(projectFiles)) + for _, path := range projectFiles { + meta, err := parseMarkdown(path) + if err != nil { + return SiteState{}, err + } + id := normalizeID(meta.ExtraString("id")) + if id == "" { + continue + } + rel, _ := filepath.Rel(root, path) + project := ProjectState{ + ProjectID: id, + Title: meta.String("title"), + Description: meta.String("description"), + Status: firstNonEmpty(meta.ExtraString("status"), "planned"), + Owners: uniqueSorted(meta.ExtraStrings("owners")), + TargetDate: meta.ExtraString("target_date"), + Slug: pageSlug(meta), + SourcePath: filepath.ToSlash(rel), + } + project.Permalink = "/proyectos/" + project.Slug + "/" + projects = append(projects, project) + } + sort.Slice(projects, func(i, j int) bool { + return projects[i].ProjectID < projects[j].ProjectID + }) + + openTasks := 0 + for _, task := range taskStates { + if task.Status != "done" && task.Status != "cancelled" { + openTasks++ + } + } + activeProjects := 0 + for _, project := range projects { + switch project.Status { + case "done", "completed", "cancelled", "archived": + default: + activeProjects++ + } + } + recent := memories + if len(recent) > 30 { + recent = recent[:30] + } + return SiteState{ + Version: siteStateVersion, + SourceSHA256: digest, + Stats: SiteStats{ + ActiveAdmins: len(activeIDs), PendingReviews: len(queue), + OpenTasks: openTasks, ActiveProjects: activeProjects, + }, + ReviewQueue: queue, + RecentMemories: recent, + Tasks: taskStates, + Projects: projects, + }, nil +} + +func (a *App) Sync(root string, check bool) (SiteState, bool, string, error) { + state, err := a.BuildState(root) + if err != nil { + return SiteState{}, false, "", err + } + path := filepath.Join(root, "data", "generated", "site_state.json") + var old SiteState + oldErr := loadJSON(path, &old) + upToDate := oldErr == nil && old.Version == state.Version && old.SourceSHA256 == state.SourceSHA256 + if check { + if !upToDate { + return state, false, path, NewError(ExitValidation, "generated_state_stale", + "data/generated/site_state.json no corresponde a las fuentes actuales", map[string]any{ + "expected_source_sha256": state.SourceSHA256, + "actual_source_sha256": old.SourceSHA256, + "hint": "ejecute clusterlog sync", + }) + } + return old, false, path, nil + } + if upToDate { + return old, false, path, nil + } + state.GeneratedAt = a.Options.Now().UTC().Format("2006-01-02T15:04:05Z") + if a.Options.DryRun { + return state, true, path, nil + } + if err := writeJSONAtomic(path, state); err != nil { + return SiteState{}, false, path, err + } + return state, true, path, nil +} + +func findMemory(root, id string) (MemoryRecord, PageMeta, error) { + id = normalizeID(id) + files, err := listMarkdownFiles(root, "memorias") + if err != nil { + return MemoryRecord{}, PageMeta{}, err + } + for _, path := range files { + meta, err := parseMarkdown(path) + if err != nil { + return MemoryRecord{}, PageMeta{}, err + } + if normalizeID(meta.ExtraString("id")) == id { + rel, _ := filepath.Rel(root, path) + record := MemoryRecord{ + EntryID: id, + Title: meta.String("title"), Description: meta.String("description"), + Date: meta.String("date"), AuthorID: meta.ExtraString("author_id"), + AuthorFingerprint: meta.ExtraString("author_fingerprint"), Risk: meta.ExtraString("risk"), + ReviewRequired: meta.ExtraBool("review_required"), ReviewPolicy: meta.ExtraString("review_policy"), + ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"), Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")), + Tags: meta.Taxonomies["tags"], Systems: meta.Taxonomies["systems"], + Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel), ContentSHA256: meta.ContentHash, + } + record.Permalink = "/memorias/" + record.Slug + "/" + return record, meta, nil + } + } + return MemoryRecord{}, PageMeta{}, NewError(ExitUsage, "memory_not_found", "no existe la memoria indicada", id) +} + +func findTask(root, id string) (TaskState, PageMeta, error) { + id = normalizeID(id) + files, err := listMarkdownFiles(root, "tareas") + if err != nil { + return TaskState{}, PageMeta{}, err + } + for _, path := range files { + meta, err := parseMarkdown(path) + if err != nil { + return TaskState{}, PageMeta{}, err + } + if normalizeID(meta.ExtraString("id")) == id { + rel, _ := filepath.Rel(root, path) + state := TaskState{ + TaskID: id, Title: meta.String("title"), Description: meta.String("description"), + Status: firstNonEmpty(meta.ExtraString("status"), "todo"), Priority: meta.ExtraString("priority"), + ProjectID: normalizeID(meta.ExtraString("project_id")), Assignees: meta.ExtraStrings("assignees"), + EstimateMinutes: meta.ExtraInt("estimate_minutes"), Due: meta.ExtraString("due"), + Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel), + } + state.Permalink = "/tareas/" + state.Slug + "/" + return state, meta, nil + } + } + return TaskState{}, PageMeta{}, NewError(ExitUsage, "task_not_found", "no existe la tarea indicada", id) +} + +func taskActiveStart(log TaskLog, adminID string) (TaskEvent, bool) { + active := map[string]TaskEvent{} + for _, event := range log.Events { + if event.AdminID != adminID { + continue + } + id := normalizeID(event.TaskID) + switch event.Type { + case "start": + active[id] = event + case "stop": + delete(active, id) + } + } + if len(active) == 0 { + return TaskEvent{}, false + } + keys := make([]string, 0, len(active)) + for id := range active { + keys = append(keys, id) + } + sort.Strings(keys) + return active[keys[0]], true +} + +func taskIsRunning(log TaskLog, taskID, adminID string) (TaskEvent, bool) { + taskID = normalizeID(taskID) + var current TaskEvent + running := false + for _, event := range log.Events { + if normalizeID(event.TaskID) != taskID || event.AdminID != adminID { + continue + } + switch event.Type { + case "start": + current = event + running = true + case "stop": + running = false + } + } + return current, running +} + +func ensureProjectExists(root, id string) error { + if strings.TrimSpace(id) == "" { + return nil + } + id = normalizeID(id) + files, err := listMarkdownFiles(root, "proyectos") + if err != nil { + return err + } + for _, path := range files { + meta, err := parseMarkdown(path) + if err != nil { + return err + } + if normalizeID(meta.ExtraString("id")) == id { + return nil + } + } + return NewError(ExitValidation, "project_not_found", "la tarea referencia un proyecto inexistente", id) +} + +func generatedStateExists(root string) bool { + _, err := os.Stat(filepath.Join(root, "data", "generated", "site_state.json")) + return err == nil +} + +func statusIsClosed(status string) bool { + switch strings.ToLower(status) { + case "done", "completed", "cancelled", "archived": + return true + default: + return false + } +} + +func pageIDDuplicates(records []string) []string { + counts := map[string]int{} + for _, id := range records { + counts[id]++ + } + out := []string{} + for id, count := range counts { + if id != "" && count > 1 { + out = append(out, fmt.Sprintf("%s (%d)", id, count)) + } + } + sort.Strings(out) + return out +} diff --git a/internal/clusterlog/state_test.go b/internal/clusterlog/state_test.go new file mode 100644 index 0000000..0146074 --- /dev/null +++ b/internal/clusterlog/state_test.go @@ -0,0 +1,193 @@ +package clusterlog + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestReviewQueueUsesContentHashAndReviewerSnapshot(t *testing.T) { + root := testProjectRoot(t) + ana := testAdmin(t, "ana", 1, "owner", "admin") + bob := testAdmin(t, "bob", 2, "admin") + registry := AdminRegistry{ + Version: 1, + ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, + Admins: []Admin{ana, bob}, + } + if err := saveAdmins(root, registry); err != nil { + t.Fatal(err) + } + memoryPath := filepath.Join(root, "content", "memorias", "memory.md") + mustWriteTestFile(t, memoryPath, `+++ +title = "Cambio de red" +description = "Ajuste controlado" +date = 2026-08-12T10:00:00Z +authors = ["ana"] +slug = "cambio-red" + +[taxonomies] +tags = ["red"] +systems = ["spine-01"] + +[extra] +id = "MEM-TEST-001" +author_id = "ana" +author_fingerprint = "`+ana.SSHKeys[0].Fingerprint+`" +risk = "high" +review_required = true +review_policy = "all-active" +reviewer_snapshot = true +reviewers = ["ana", "bob"] ++++ + +## Cambio + +Versión uno. +`) + app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) }}) + state, err := app.BuildState(root) + if err != nil { + t.Fatal(err) + } + if len(state.ReviewQueue) != 1 || len(state.ReviewQueue[0].MissingReviewers) != 2 { + t.Fatalf("unexpected initial queue: %#v", state.ReviewQueue) + } + memory, _, err := findMemory(root, "MEM-TEST-001") + if err != nil { + t.Fatal(err) + } + event := ReviewEvent{ + ID: "REV-TEST-001", EntryID: memory.EntryID, AdminID: "ana", + Fingerprint: ana.SSHKeys[0].Fingerprint, ReviewedAt: "2026-08-12T12:01:00Z", + ContentSHA256: memory.ContentSHA256, + } + if _, err := writeReviewEvent(root, event); err != nil { + t.Fatal(err) + } + state, err = app.BuildState(root) + if err != nil { + t.Fatal(err) + } + if got := state.ReviewQueue[0].MissingReviewers; len(got) != 1 || got[0] != "bob" { + t.Fatalf("unexpected missing reviewers after review: %#v", got) + } + + // Adding a new administrator must not reopen old entries because the + // required reviewer set is captured when the memory is created. + carol := testAdmin(t, "carol", 3, "admin") + registry.Admins = append(registry.Admins, carol) + if err := saveAdmins(root, registry); err != nil { + t.Fatal(err) + } + state, err = app.BuildState(root) + if err != nil { + t.Fatal(err) + } + if state.ReviewQueue[0].RequiredCount != 2 || contains(state.ReviewQueue[0].MissingReviewers, "carol") { + t.Fatalf("snapshot unexpectedly changed: %#v", state.ReviewQueue[0]) + } + + // Any edit changes the hash, invalidating the old review automatically. + data, err := os.ReadFile(memoryPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(memoryPath, append(data, []byte("\nCorrección posterior.\n")...), 0o644); err != nil { + t.Fatal(err) + } + state, err = app.BuildState(root) + if err != nil { + t.Fatal(err) + } + if got := state.ReviewQueue[0].MissingReviewers; len(got) != 2 || !contains(got, "ana") || !contains(got, "bob") { + t.Fatalf("old reviews were not invalidated: %#v", got) + } +} + +func TestTaskProjectionFromIndependentEventFiles(t *testing.T) { + root := testProjectRoot(t) + ana := testAdmin(t, "ana", 1, "owner", "admin") + if err := saveAdmins(root, AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{ana}}); err != nil { + t.Fatal(err) + } + mustWriteTestFile(t, filepath.Join(root, "content", "tareas", "task.md"), `+++ +title = "Probar respaldo" +description = "Prueba de tarea" +date = 2026-08-12T10:00:00Z +authors = ["ana"] +slug = "probar-respaldo" + +[taxonomies] +tags = ["backup"] +systems = ["etcd"] + +[extra] +id = "TSK-TEST-001" +status = "todo" +priority = "high" +project_id = "" +assignees = ["ana"] +estimate_minutes = 60 +due = "2026-08-20" ++++ + +## Objetivo + +Validar el respaldo. +`) + events := []TaskEvent{ + {ID: "EVT-3", TaskID: "TSK-TEST-001", Type: "status", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:30:00Z", Status: "blocked"}, + {ID: "EVT-1", TaskID: "TSK-TEST-001", Type: "start", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:00:00Z"}, + {ID: "EVT-2", TaskID: "TSK-TEST-001", Type: "stop", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:25:00Z", Minutes: 25}, + {ID: "EVT-4", TaskID: "TSK-TEST-001", Type: "adjust", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:31:00Z", Minutes: 5}, + } + // Intentionally write out of chronological order: loading must sort by At. + if _, err := writeTaskEvents(root, events); err != nil { + t.Fatal(err) + } + state, err := New(Options{}).BuildState(root) + if err != nil { + t.Fatal(err) + } + if len(state.Tasks) != 1 { + t.Fatalf("unexpected tasks: %#v", state.Tasks) + } + got := state.Tasks[0] + if got.ActualMinutes != 30 || got.Status != "blocked" || len(got.RunningBy) != 0 || got.RunningBy == nil { + t.Fatalf("unexpected task projection: %#v", got) + } +} + +func TestSyncRegeneratesWhenProjectionVersionChanges(t *testing.T) { + root := testProjectRoot(t) + app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 15, 0, 0, 0, time.UTC) }}) + state, changed, path, err := app.Sync(root, false) + if err != nil { + t.Fatal(err) + } + if !changed || state.Version != siteStateVersion { + t.Fatalf("unexpected initial sync: changed=%v state=%#v", changed, state) + } + + var stale SiteState + if err := loadJSON(path, &stale); err != nil { + t.Fatal(err) + } + stale.Version = siteStateVersion - 1 + if err := writeJSONAtomic(path, stale); err != nil { + t.Fatal(err) + } + + if _, _, _, err := app.Sync(root, true); err == nil { + t.Fatal("sync --check accepted a stale projection version") + } + state, changed, _, err = app.Sync(root, false) + if err != nil { + t.Fatal(err) + } + if !changed || state.Version != siteStateVersion { + t.Fatalf("projection was not regenerated: changed=%v state=%#v", changed, state) + } +} diff --git a/internal/clusterlog/store.go b/internal/clusterlog/store.go new file mode 100644 index 0000000..00e93b5 --- /dev/null +++ b/internal/clusterlog/store.go @@ -0,0 +1,361 @@ +package clusterlog + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" +) + +func loadJSON(path string, dst any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := json.Unmarshal(data, dst); err != nil { + return fmt.Errorf("JSON inválido en %s: %w", path, err) + } + return nil +} + +func writeJSONAtomic(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return writeFileAtomic(path, data, 0o644) +} + +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".clusterlog-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +func readAll(path string, stdin io.Reader) ([]byte, error) { + if path == "" { + return nil, nil + } + if path == "-" { + return io.ReadAll(stdin) + } + return os.ReadFile(path) +} + +func fileSHA256(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), nil +} + +func bytesSHA256(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func generateID(prefix string, now time.Time) (string, error) { + random := make([]byte, 3) + if _, err := rand.Read(random); err != nil { + return "", err + } + return fmt.Sprintf("%s-%s-%s", strings.ToUpper(prefix), now.UTC().Format("20060102"), strings.ToUpper(hex.EncodeToString(random))), nil +} + +func normalizeID(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func slugify(value string) string { + replacements := map[rune]rune{ + 'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'Á': 'a', 'À': 'a', 'Ä': 'a', 'Â': 'a', + 'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'É': 'e', 'È': 'e', 'Ë': 'e', 'Ê': 'e', + 'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'Í': 'i', 'Ì': 'i', 'Ï': 'i', 'Î': 'i', + 'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'Ó': 'o', 'Ò': 'o', 'Ö': 'o', 'Ô': 'o', + 'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'Ú': 'u', 'Ù': 'u', 'Ü': 'u', 'Û': 'u', + 'ñ': 'n', 'Ñ': 'n', 'ç': 'c', 'Ç': 'c', + } + var b strings.Builder + lastDash := false + for _, r := range value { + if replacement, ok := replacements[r]; ok { + r = replacement + } + r = unicode.ToLower(r) + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func splitCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + seen := map[string]bool{} + out := []string{} + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + +func parseDurationMinutes(value string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + if n, err := strconv.Atoi(value); err == nil { + if n < 0 { + return 0, fmt.Errorf("la duración no puede ser negativa") + } + return n, nil + } + duration, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("duración inválida %q; use minutos, 90m, 2h o 1h30m", value) + } + if duration < 0 { + return 0, fmt.Errorf("la duración no puede ser negativa") + } + return int(duration.Round(time.Minute) / time.Minute), nil +} + +func formatMinutes(minutes int) string { + if minutes <= 0 { + return "0m" + } + hours := minutes / 60 + rest := minutes % 60 + if hours == 0 { + return fmt.Sprintf("%dm", rest) + } + if rest == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh%dm", hours, rest) +} + +func listMarkdownFiles(root, section string) ([]string, error) { + base := filepath.Join(root, "content", section) + entries := []string{} + err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if filepath.Ext(path) != ".md" || filepath.Base(path) == "_index.md" { + return nil + } + entries = append(entries, path) + return nil + }) + if os.IsNotExist(err) { + return nil, nil + } + sort.Strings(entries) + return entries, err +} + +func sourceDigest(paths []string) (string, error) { + sort.Strings(paths) + h := sha256.New() + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + _, _ = h.Write([]byte(filepath.ToSlash(path))) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(data) + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func uniqueSorted(values []string) []string { + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = true + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func hasRole(admin Admin, role string) bool { + return contains(admin.Roles, role) +} + +func relativePaths(root string, paths []string) ([]string, error) { + out := make([]string, 0, len(paths)) + for _, path := range paths { + rel, err := filepath.Rel(root, path) + if err != nil { + return nil, err + } + out = append(out, filepath.ToSlash(rel)) + } + return out, nil +} + +func validateDate(value string) error { + if value == "" { + return nil + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("fecha inválida %q; use YYYY-MM-DD", value) + } + return nil +} + +var secretPatterns = []struct { + Name string + Pattern *regexp.Regexp +}{ + {"llave privada OpenSSH", regexp.MustCompile(`(?i)BEGIN[ ]+OPENSSH[ ]+PRIVATE[ ]+KEY`)}, + {"llave privada PEM", regexp.MustCompile(`(?i)BEGIN[ ]+(RSA|EC|DSA)?[ ]*PRIVATE[ ]+KEY`)}, + {"token GitHub", regexp.MustCompile(`\b(ghp|github_pat)_[A-Za-z0-9_]{20,}\b`)}, + {"access key AWS", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, + {"token Slack", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`)}, + {"contraseña explícita", regexp.MustCompile(`(?im)\b(password|passwd|contrase(?:ñ|n)a)\s*[:=]\s*[^<\s][^\n]{3,}`)}, +} + +func scanSecrets(text string) []string { + findings := []string{} + for _, item := range secretPatterns { + if item.Pattern.MatchString(text) { + findings = append(findings, item.Name) + } + } + return uniqueSorted(findings) +} + +func tomlString(value string) string { return strconv.Quote(value) } + +func tomlStringArray(values []string) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, tomlString(value)) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +func bullets(values []string) string { + if len(values) == 0 { + return "_No especificado._\n" + } + var b strings.Builder + for _, value := range values { + fmt.Fprintf(&b, "- %s\n", value) + } + return b.String() +} + +func codeBlock(values []string) string { + if len(values) == 0 { + return "_No se registraron comandos._\n" + } + return "```console\n" + strings.Join(values, "\n") + "\n```\n" +} + +func normalizeMarkdownBody(body string) string { + body = strings.ReplaceAll(body, "\r\n", "\n") + return strings.TrimSpace(body) + "\n" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func readLines(r io.Reader) ([]string, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 1024), 1024*1024) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +func canonicalJSON(value any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/clusterlog/store_test.go b/internal/clusterlog/store_test.go new file mode 100644 index 0000000..6eda170 --- /dev/null +++ b/internal/clusterlog/store_test.go @@ -0,0 +1,58 @@ +package clusterlog + +import "testing" + +func TestSlugifySpanishText(t *testing.T) { + got := slugify(" Actualización del Clúster: Niño / ÁRBOL ") + want := "actualizacion-del-cluster-nino-arbol" + if got != want { + t.Fatalf("slugify() = %q, want %q", got, want) + } +} + +func TestParseDurationMinutes(t *testing.T) { + tests := map[string]int{ + "90": 90, + "90m": 90, + "2h": 120, + "1h30m": 90, + } + for input, want := range tests { + got, err := parseDurationMinutes(input) + if err != nil { + t.Fatalf("parseDurationMinutes(%q): %v", input, err) + } + if got != want { + t.Errorf("parseDurationMinutes(%q) = %d, want %d", input, got, want) + } + } + if _, err := parseDurationMinutes("-5m"); err == nil { + t.Fatal("expected negative duration to fail") + } +} + +func TestScanSecrets(t *testing.T) { + text := "password = supersecreto\n-----BEGIN OPENSSH PRIVATE KEY-----" + findings := scanSecrets(text) + if !contains(findings, "contraseña explícita") || !contains(findings, "llave privada OpenSSH") { + t.Fatalf("unexpected findings: %#v", findings) + } + if findings := scanSecrets("kubectl get nodes\nshow version"); len(findings) != 0 { + t.Fatalf("false positive findings: %#v", findings) + } +} + +func TestPublicKeyFingerprintIsStable(t *testing.T) { + key := testOpenSSHPublicKey(7, "ana@test") + canonical1, fingerprint1, err := parsePublicKeyLine(key) + if err != nil { + t.Fatal(err) + } + canonical2, fingerprint2, err := parsePublicKeyLine(" " + key + " ") + if err != nil { + t.Fatal(err) + } + if canonical1 != canonical2 || fingerprint1 != fingerprint2 { + t.Fatalf("key parsing is not stable: %q/%q vs %q/%q", canonical1, fingerprint1, canonical2, fingerprint2) + } +} diff --git a/internal/clusterlog/test_helpers_test.go b/internal/clusterlog/test_helpers_test.go new file mode 100644 index 0000000..ff22caf --- /dev/null +++ b/internal/clusterlog/test_helpers_test.go @@ -0,0 +1,78 @@ +package clusterlog + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +func testProjectRoot(t *testing.T) string { + t.Helper() + root := t.TempDir() + mustWriteTestFile(t, filepath.Join(root, "zola.toml"), "title = \"test\"\n") + for _, section := range []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"} { + if err := os.MkdirAll(filepath.Join(root, "content", section), 0o755); err != nil { + t.Fatal(err) + } + } + for _, dir := range []string{ + filepath.Join(root, "data", reviewEventsDirName), + filepath.Join(root, "data", taskEventsDirName), + filepath.Join(root, "config"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + registry := AdminRegistry{ + Version: 1, + ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, + Admins: []Admin{}, + } + if err := saveAdmins(root, registry); err != nil { + t.Fatal(err) + } + return root +} + +func mustWriteTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} + +func testOpenSSHPublicKey(seed byte, comment string) string { + var blob bytes.Buffer + writeSSHString := func(value []byte) { + _ = binary.Write(&blob, binary.BigEndian, uint32(len(value))) + _, _ = blob.Write(value) + } + writeSSHString([]byte("ssh-ed25519")) + key := bytes.Repeat([]byte{seed}, 32) + writeSSHString(key) + encoded := base64.StdEncoding.EncodeToString(blob.Bytes()) + if comment == "" { + return "ssh-ed25519 " + encoded + } + return "ssh-ed25519 " + encoded + " " + comment +} + +func testAdmin(t *testing.T, id string, seed byte, roles ...string) Admin { + t.Helper() + publicKey := testOpenSSHPublicKey(seed, id+"@test") + _, fingerprint, err := parsePublicKeyLine(publicKey) + if err != nil { + t.Fatal(err) + } + return Admin{ + ID: id, Name: id, Email: id + "@example.test", Active: true, Roles: roles, + SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: publicKey, AddedAt: "2026-08-12T00:00:00Z"}}, + } +} diff --git a/internal/clusterlog/types.go b/internal/clusterlog/types.go new file mode 100644 index 0000000..4610939 --- /dev/null +++ b/internal/clusterlog/types.go @@ -0,0 +1,377 @@ +package clusterlog + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const Version = "0.1.0" + +const ( + ExitOK = 0 + ExitUsage = 2 + ExitIdentity = 3 + ExitValidation = 4 + ExitConflict = 5 + ExitGit = 6 + ExitInternal = 10 +) + +type CLIError struct { + Code int + Kind string + Message string + Details any +} + +func (e *CLIError) Error() string { return e.Message } + +func NewError(code int, kind, message string, details any) *CLIError { + return &CLIError{Code: code, Kind: kind, Message: message, Details: details} +} + +type Options struct { + Root string + JSON bool + SSHKey string + Commit bool + DryRun bool + Stdout io.Writer + Stderr io.Writer + Stdin io.Reader + Now func() time.Time + Executable string +} + +type App struct { + Options Options +} + +func New(options Options) *App { + if options.Stdout == nil { + options.Stdout = os.Stdout + } + if options.Stderr == nil { + options.Stderr = os.Stderr + } + if options.Stdin == nil { + options.Stdin = os.Stdin + } + if options.Now == nil { + options.Now = time.Now + } + if options.Executable == "" { + options.Executable = "clusterlog" + } + return &App{Options: options} +} + +type ReviewPolicy struct { + Default string `json:"default"` + IncludeAuthor bool `json:"include_author"` +} + +type SSHKey struct { + Fingerprint string `json:"fingerprint"` + PublicKey string `json:"public_key"` + AddedAt string `json:"added_at,omitempty"` +} + +type Admin struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Active bool `json:"active"` + Roles []string `json:"roles,omitempty"` + SSHKeys []SSHKey `json:"ssh_keys"` +} + +type AdminRegistry struct { + Version int `json:"version"` + ReviewPolicy ReviewPolicy `json:"review_policy"` + Admins []Admin `json:"admins"` +} + +type Identity struct { + Admin Admin `json:"admin"` + Fingerprint string `json:"fingerprint"` + PublicKey string `json:"public_key"` + PublicPath string `json:"public_key_path"` + SigningKey string `json:"signing_key_path"` +} + +type ReviewEvent struct { + ID string `json:"id"` + EntryID string `json:"entry_id"` + AdminID string `json:"admin_id"` + Fingerprint string `json:"fingerprint"` + ReviewedAt string `json:"reviewed_at"` + ContentSHA256 string `json:"content_sha256"` + Note string `json:"note,omitempty"` +} + +type ReviewLog struct { + Version int `json:"version"` + Events []ReviewEvent `json:"events"` +} + +type TaskEvent struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Type string `json:"type"` + AdminID string `json:"admin_id"` + Fingerprint string `json:"fingerprint"` + At string `json:"at"` + Status string `json:"status,omitempty"` + Minutes int `json:"minutes,omitempty"` + Note string `json:"note,omitempty"` +} + +type TaskLog struct { + Version int `json:"version"` + Events []TaskEvent `json:"events"` +} + +type PageMeta struct { + Path string + Body string + Root map[string]any + Taxonomies map[string][]string + Extra map[string]any + RawFront string + ContentHash string +} + +func (p PageMeta) String(key string) string { + if v, ok := p.Root[key]; ok { + return fmt.Sprint(v) + } + return "" +} + +func (p PageMeta) ExtraString(key string) string { + if v, ok := p.Extra[key]; ok { + return fmt.Sprint(v) + } + return "" +} + +func (p PageMeta) ExtraBool(key string) bool { + v, ok := p.Extra[key] + if !ok { + return false + } + b, _ := v.(bool) + return b +} + +func (p PageMeta) ExtraInt(key string) int { + v, ok := p.Extra[key] + if !ok { + return 0 + } + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + default: + return 0 + } +} + +func (p PageMeta) ExtraStrings(key string) []string { + v, ok := p.Extra[key] + if !ok { + return nil + } + switch values := v.(type) { + case []string: + return values + case []any: + out := make([]string, 0, len(values)) + for _, item := range values { + out = append(out, fmt.Sprint(item)) + } + return out + default: + return nil + } +} + +type MemoryRecord struct { + EntryID string `json:"entry_id"` + Title string `json:"title"` + Description string `json:"description"` + Date string `json:"date"` + AuthorID string `json:"author_id"` + AuthorFingerprint string `json:"author_fingerprint"` + Risk string `json:"risk"` + Ticket string `json:"ticket,omitempty"` + ReviewRequired bool `json:"review_required"` + ReviewPolicy string `json:"review_policy"` + ReviewerSnapshot bool `json:"reviewer_snapshot"` + Reviewers []string `json:"reviewers"` + Tags []string `json:"tags"` + Systems []string `json:"systems"` + Slug string `json:"slug"` + Permalink string `json:"permalink"` + SourcePath string `json:"source_path"` + ContentSHA256 string `json:"content_sha256"` +} + +type ReviewQueueItem struct { + EntryID string `json:"entry_id"` + Title string `json:"title"` + Description string `json:"description"` + Date string `json:"date"` + AuthorID string `json:"author_id"` + Risk string `json:"risk"` + Tags []string `json:"tags"` + Systems []string `json:"systems"` + Permalink string `json:"permalink"` + ContentSHA256 string `json:"content_sha256"` + ReviewedBy []string `json:"reviewed_by"` + MissingReviewers []string `json:"missing_reviewers"` + RequiredCount int `json:"required_count"` + ReviewedCount int `json:"reviewed_count"` + ConfigurationError string `json:"configuration_error,omitempty"` +} + +type TaskState struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + ProjectID string `json:"project_id,omitempty"` + Assignees []string `json:"assignees"` + EstimateMinutes int `json:"estimate_minutes"` + ActualMinutes int `json:"actual_minutes"` + Due string `json:"due,omitempty"` + RunningBy []string `json:"running_by"` + Slug string `json:"slug"` + Permalink string `json:"permalink"` + SourcePath string `json:"source_path"` +} + +type ProjectState struct { + ProjectID string `json:"project_id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Owners []string `json:"owners"` + TargetDate string `json:"target_date,omitempty"` + Slug string `json:"slug"` + Permalink string `json:"permalink"` + SourcePath string `json:"source_path"` +} + +type SiteStats struct { + ActiveAdmins int `json:"active_admins"` + PendingReviews int `json:"pending_reviews"` + OpenTasks int `json:"open_tasks"` + ActiveProjects int `json:"active_projects"` +} + +type SiteState struct { + Version int `json:"version"` + GeneratedAt string `json:"generated_at"` + SourceSHA256 string `json:"source_sha256"` + Stats SiteStats `json:"stats"` + ReviewQueue []ReviewQueueItem `json:"review_queue"` + RecentMemories []MemoryRecord `json:"recent_memories"` + Tasks []TaskState `json:"tasks"` + Projects []ProjectState `json:"projects"` +} + +type ResultEnvelope struct { + OK bool `json:"ok"` + Command string `json:"command,omitempty"` + Data any `json:"data,omitempty"` + Error any `json:"error,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +func (a *App) printResult(command string, data any, warnings ...string) error { + if a.Options.JSON { + enc := json.NewEncoder(a.Options.Stdout) + enc.SetIndent("", " ") + return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings}) + } + switch v := data.(type) { + case string: + fmt.Fprintln(a.Options.Stdout, v) + default: + bytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Fprintln(a.Options.Stdout, string(bytes)) + } + for _, warning := range warnings { + fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning) + } + return nil +} + +func PrintError(w io.Writer, jsonMode bool, err error) int { + var cliErr *CLIError + if !errors.As(err, &cliErr) { + cliErr = NewError(ExitInternal, "internal", err.Error(), nil) + } + if jsonMode { + payload := ResultEnvelope{OK: false, Error: map[string]any{ + "kind": cliErr.Kind, + "message": cliErr.Message, + "details": cliErr.Details, + "exit_code": cliErr.Code, + }} + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(payload) + } else { + fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message) + if cliErr.Details != nil { + bytes, _ := json.MarshalIndent(cliErr.Details, "", " ") + fmt.Fprintln(w, string(bytes)) + } + } + return cliErr.Code +} + +func (a *App) ResolveRoot() (string, error) { + if a.Options.Root != "" { + root, err := filepath.Abs(a.Options.Root) + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil { + return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root) + } + return root, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + current := cwd + for { + if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil { + return current, nil + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd) +} diff --git a/internal/clusterlog/validate.go b/internal/clusterlog/validate.go new file mode 100644 index 0000000..adf858a --- /dev/null +++ b/internal/clusterlog/validate.go @@ -0,0 +1,495 @@ +package clusterlog + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +type ValidationIssue struct { + Level string `json:"level"` + Code string `json:"code"` + Path string `json:"path,omitempty"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +type ValidationReport struct { + Valid bool `json:"valid"` + Errors []ValidationIssue `json:"errors"` + Warnings []ValidationIssue `json:"warnings"` + Checks map[string]int `json:"checks"` + ZolaChecked bool `json:"zola_checked"` +} + +type pageRecord struct { + Section string + Meta PageMeta + ID string +} + +func (r *ValidationReport) add(level, code, path, message string, details any) { + issue := ValidationIssue{Level: level, Code: code, Path: filepath.ToSlash(path), Message: message, Details: details} + if level == "warning" { + r.Warnings = append(r.Warnings, issue) + } else { + r.Errors = append(r.Errors, issue) + } +} + +func valueStrings(value any) []string { + switch v := value.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + out = append(out, fmt.Sprint(item)) + } + return out + case string: + if v != "" { + return []string{v} + } + } + return nil +} + +func fingerprintBelongs(registry AdminRegistry, adminID, fingerprint string) bool { + admin, ok := adminByID(registry, adminID) + if !ok { + return false + } + for _, key := range admin.SSHKeys { + if key.Fingerprint == fingerprint { + return true + } + } + return false +} + +func validTaskStatus(status string) bool { + switch status { + case "backlog", "todo", "in_progress", "blocked", "done", "cancelled": + return true + default: + return false + } +} + +func validProjectStatus(status string) bool { + switch status { + case "planned", "active", "blocked", "done", "completed", "cancelled", "archived": + return true + default: + return false + } +} + +func (a *App) runValidate(root string, args []string) error { + fs := newFlagSet("validate") + skipZola := fs.Bool("skip-zola", false, "omite zola check") + requireZola := fs.Bool("require-zola", false, "falla si Zola no está instalado") + if err := fs.Parse(args); err != nil { + return NewError(ExitUsage, "invalid_flags", err.Error(), nil) + } + report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}} + + registry, err := loadAdmins(root) + if err != nil { + report.add("error", "admins_invalid", filepath.Join(root, "data", "admins.json"), err.Error(), nil) + } else { + a.validateAdmins(root, registry, &report) + } + + pages := a.validatePages(root, registry, &report) + a.validateEvents(root, registry, pages, &report) + + if len(report.Errors) == 0 { + if _, _, _, err := a.Sync(root, true); err != nil { + report.add("error", "generated_state_stale", filepath.Join(root, "data", "generated", "site_state.json"), err.Error(), nil) + } else { + report.Checks["generated_state"] = 1 + } + } + + if !*skipZola { + zolaPath, lookupErr := exec.LookPath("zola") + if lookupErr != nil { + if *requireZola { + report.add("error", "zola_missing", "", "Zola no está instalado o no está en PATH", nil) + } else { + report.add("warning", "zola_missing", "", "Zola no está instalado; se omitió la validación de plantillas y enlaces", nil) + } + } else { + report.ZolaChecked = true + cmd := exec.Command(zolaPath, "check") + cmd.Dir = root + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Run(); err != nil { + report.add("error", "zola_check_failed", filepath.Join(root, "zola.toml"), "zola check falló", strings.TrimSpace(output.String())) + } else { + report.Checks["zola"] = 1 + } + } + } + + report.Valid = len(report.Errors) == 0 + if !report.Valid { + return NewError(ExitValidation, "validation_failed", "la validación del proyecto encontró errores", report) + } + warnings := make([]string, 0, len(report.Warnings)) + for _, issue := range report.Warnings { + warnings = append(warnings, issue.Message) + } + return a.printResult("validate", report, warnings...) +} + +func (a *App) validateAdmins(root string, registry AdminRegistry, report *ValidationReport) { + if registry.ReviewPolicy.Default != "all-active" && registry.ReviewPolicy.Default != "all-active-except-author" { + report.add("error", "invalid_default_review_policy", filepath.Join(root, "data", "admins.json"), "review_policy.default no es válido", registry.ReviewPolicy.Default) + } + ids := map[string]string{} + emails := map[string]string{} + fingerprints := map[string]string{} + ownerCount := 0 + for _, admin := range registry.Admins { + report.Checks["admins"]++ + idKey := normalizeID(admin.ID) + if admin.ID == "" || slugify(admin.ID) != admin.ID { + report.add("error", "invalid_admin_id", filepath.Join(root, "data", "admins.json"), "el ID del administrador debe ser un slug estable en minúsculas", admin.ID) + } + if prior, ok := ids[idKey]; ok { + report.add("error", "duplicate_admin_id", filepath.Join(root, "data", "admins.json"), "ID de administrador duplicado", []string{prior, admin.ID}) + } + ids[idKey] = admin.ID + emailKey := strings.ToLower(strings.TrimSpace(admin.Email)) + if emailKey == "" || !strings.Contains(emailKey, "@") { + report.add("error", "invalid_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador inválido", admin.ID) + } + if prior, ok := emails[emailKey]; ok { + report.add("error", "duplicate_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador duplicado", []string{prior, admin.ID}) + } + emails[emailKey] = admin.ID + if hasRole(admin, "owner") && admin.Active { + ownerCount++ + } + if admin.Active && len(admin.SSHKeys) == 0 { + report.add("error", "active_admin_without_key", filepath.Join(root, "data", "admins.json"), "un administrador activo debe tener al menos una llave SSH", admin.ID) + } + for _, key := range admin.SSHKeys { + report.Checks["ssh_keys"]++ + _, computed, err := parsePublicKeyLine(key.PublicKey) + if err != nil { + report.add("error", "invalid_public_key", filepath.Join(root, "data", "admins.json"), err.Error(), admin.ID) + continue + } + if key.Fingerprint != computed { + report.add("error", "fingerprint_mismatch", filepath.Join(root, "data", "admins.json"), "la huella almacenada no corresponde a la llave pública", map[string]any{"admin_id": admin.ID, "stored": key.Fingerprint, "computed": computed}) + } + if prior, ok := fingerprints[key.Fingerprint]; ok { + report.add("error", "duplicate_ssh_key", filepath.Join(root, "data", "admins.json"), "una llave SSH está asignada a más de un administrador", []string{prior, admin.ID}) + } + fingerprints[key.Fingerprint] = admin.ID + } + } + if len(registry.Admins) > 0 && ownerCount == 0 { + report.add("error", "missing_owner", filepath.Join(root, "data", "admins.json"), "debe existir al menos un administrador activo con rol owner", nil) + } + + allowedPath := filepath.Join(root, "config", "allowed_signers") + allowedData, err := os.ReadFile(allowedPath) + if err != nil { + report.add("error", "allowed_signers_missing", allowedPath, "no se pudo leer el archivo de firmantes autorizados", err.Error()) + return + } + allowed := string(allowedData) + for _, admin := range registry.Admins { + if !admin.Active { + continue + } + for _, key := range admin.SSHKeys { + fields := strings.Fields(key.PublicKey) + if len(fields) < 2 { + continue + } + expected := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1]) + if !strings.Contains(allowed, expected) { + report.add("error", "allowed_signer_missing", allowedPath, "falta una llave activa en allowed_signers", map[string]any{"admin_id": admin.ID, "fingerprint": key.Fingerprint}) + } + } + } +} + +func (a *App) validatePages(root string, registry AdminRegistry, report *ValidationReport) []pageRecord { + sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"} + pages := []pageRecord{} + ids := []string{} + for _, section := range sections { + files, err := listMarkdownFiles(root, section) + if err != nil { + report.add("error", "content_walk_failed", filepath.Join(root, "content", section), err.Error(), nil) + continue + } + for _, path := range files { + report.Checks["content_pages"]++ + data, readErr := os.ReadFile(path) + if readErr != nil { + report.add("error", "content_read_failed", path, readErr.Error(), nil) + continue + } + if findings := scanSecrets(string(data)); len(findings) > 0 { + report.add("error", "possible_secret", path, "el contenido parece incluir secretos", findings) + } + meta, parseErr := parseMarkdown(path) + if parseErr != nil { + report.add("error", "front_matter_invalid", path, parseErr.Error(), nil) + continue + } + id := normalizeID(meta.ExtraString("id")) + if id == "" { + report.add("error", "missing_page_id", path, "la página no tiene extra.id", nil) + } + ids = append(ids, id) + pages = append(pages, pageRecord{Section: section, Meta: meta, ID: id}) + if strings.TrimSpace(meta.String("title")) == "" { + report.add("error", "missing_title", path, "la página no tiene título", nil) + } + authors := valueStrings(meta.Root["authors"]) + for _, author := range authors { + if _, ok := adminByID(registry, author); !ok && author != "sistema" { + report.add("error", "unknown_author", path, "la página referencia un autor no registrado", author) + } + } + } + } + for _, duplicate := range pageIDDuplicates(ids) { + report.add("error", "duplicate_page_id", filepath.Join(root, "content"), "hay IDs de contenido duplicados", duplicate) + } + + projectIDs := map[string]bool{} + for _, page := range pages { + if page.Section == "proyectos" { + projectIDs[page.ID] = true + } + } + for _, page := range pages { + path := page.Meta.Path + switch page.Section { + case "memorias": + if !strings.HasPrefix(page.ID, "MEM-") { + report.add("error", "invalid_memory_id", path, "el ID de memoria debe iniciar con MEM-", page.ID) + } + authorID := page.Meta.ExtraString("author_id") + if _, ok := adminByID(registry, authorID); !ok && authorID != "sistema" { + report.add("error", "unknown_memory_author", path, "author_id no corresponde a un administrador", authorID) + } + fingerprint := page.Meta.ExtraString("author_fingerprint") + if authorID != "sistema" && !fingerprintBelongs(registry, authorID, fingerprint) { + report.add("error", "memory_fingerprint_mismatch", path, "author_fingerprint no pertenece al autor", map[string]any{"author_id": authorID, "fingerprint": fingerprint}) + } + policy := page.Meta.ExtraString("review_policy") + if policy != "" && policy != "all-active" && policy != "all-active-except-author" { + report.add("error", "invalid_review_policy", path, "review_policy no es válido", policy) + } + if page.Meta.ExtraBool("reviewer_snapshot") { + seenReviewers := map[string]bool{} + for _, reviewer := range page.Meta.ExtraStrings("reviewers") { + if seenReviewers[reviewer] { + report.add("error", "duplicate_reviewer", path, "un revisor aparece más de una vez", reviewer) + } + seenReviewers[reviewer] = true + if _, ok := adminByID(registry, reviewer); !ok { + report.add("error", "unknown_reviewer", path, "el conjunto capturado referencia un administrador inexistente", reviewer) + } + } + } + case "tareas": + if !strings.HasPrefix(page.ID, "TSK-") { + report.add("error", "invalid_task_id", path, "el ID de tarea debe iniciar con TSK-", page.ID) + } + priority := page.Meta.ExtraString("priority") + if priority != "low" && priority != "medium" && priority != "high" && priority != "urgent" { + report.add("error", "invalid_task_priority", path, "priority no es válida", priority) + } + status := firstNonEmpty(page.Meta.ExtraString("status"), "todo") + if !validTaskStatus(status) { + report.add("error", "invalid_task_status", path, "status no es válido", status) + } + if due := page.Meta.ExtraString("due"); validateDate(due) != nil { + report.add("error", "invalid_due", path, "due debe usar YYYY-MM-DD", due) + } + projectID := normalizeID(page.Meta.ExtraString("project_id")) + if projectID != "" && !projectIDs[projectID] { + report.add("error", "unknown_project", path, "project_id no existe", projectID) + } + for _, assignee := range page.Meta.ExtraStrings("assignees") { + admin, ok := adminByID(registry, assignee) + if !ok || !admin.Active { + report.add("error", "invalid_assignee", path, "assignee no existe o está inactivo", assignee) + } + } + case "proyectos": + if !strings.HasPrefix(page.ID, "PRJ-") { + report.add("error", "invalid_project_id", path, "el ID de proyecto debe iniciar con PRJ-", page.ID) + } + status := firstNonEmpty(page.Meta.ExtraString("status"), "planned") + if !validProjectStatus(status) { + report.add("error", "invalid_project_status", path, "status no es válido", status) + } + if target := page.Meta.ExtraString("target_date"); validateDate(target) != nil { + report.add("error", "invalid_target_date", path, "target_date debe usar YYYY-MM-DD", target) + } + for _, owner := range page.Meta.ExtraStrings("owners") { + admin, ok := adminByID(registry, owner) + if !ok || !admin.Active { + report.add("error", "invalid_owner", path, "owner no existe o está inactivo", owner) + } + } + case "documentacion": + if !strings.HasPrefix(page.ID, "DOC-") { + report.add("error", "invalid_document_id", path, "el ID de documentación debe iniciar con DOC-", page.ID) + } + case "manuales": + if !strings.HasPrefix(page.ID, "RUN-") { + report.add("error", "invalid_manual_id", path, "el ID de manual debe iniciar con RUN-", page.ID) + } + } + } + return pages +} + +func (a *App) validateEvents(root string, registry AdminRegistry, pages []pageRecord, report *ValidationReport) { + memories := map[string]pageRecord{} + tasks := map[string]pageRecord{} + for _, page := range pages { + if page.Section == "memorias" { + memories[page.ID] = page + } + if page.Section == "tareas" { + tasks[page.ID] = page + } + } + reviews, err := loadReviewLog(root) + if err != nil { + report.add("error", "review_log_invalid", filepath.Join(root, "data", reviewEventsDirName), err.Error(), nil) + } else { + seen := map[string]bool{} + for _, event := range reviews.Events { + report.Checks["review_events"]++ + if seen[event.ID] { + report.add("error", "duplicate_review_event", filepath.Join(root, "data", reviewEventsDirName), "ID de evento de revisión duplicado", event.ID) + } + seen[event.ID] = true + if _, ok := memories[normalizeID(event.EntryID)]; !ok { + report.add("warning", "orphan_review_event", filepath.Join(root, "data", reviewEventsDirName), "el evento apunta a una memoria que ya no está en el árbol actual", event.EntryID) + } + if _, ok := adminByID(registry, event.AdminID); !ok { + report.add("error", "unknown_review_admin", filepath.Join(root, "data", reviewEventsDirName), "admin_id de revisión no existe", event.AdminID) + } + if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) { + report.add("error", "review_fingerprint_mismatch", filepath.Join(root, "data", reviewEventsDirName), "la huella del evento no pertenece al administrador", event.ID) + } + if _, parseErr := time.Parse(time.RFC3339, event.ReviewedAt); parseErr != nil { + report.add("error", "invalid_review_time", filepath.Join(root, "data", reviewEventsDirName), "reviewed_at no es RFC3339", event.ID) + } + if len(event.ContentSHA256) != 64 { + report.add("error", "invalid_review_hash", filepath.Join(root, "data", reviewEventsDirName), "content_sha256 no tiene longitud SHA-256", event.ID) + } + } + } + + log, err := loadTaskLog(root) + if err != nil { + report.add("error", "task_log_invalid", filepath.Join(root, "data", taskEventsDirName), err.Error(), nil) + return + } + seen := map[string]bool{} + activeTimer := map[string]TaskEvent{} + totalMinutes := map[string]int{} + for _, event := range log.Events { + report.Checks["task_events"]++ + if seen[event.ID] { + report.add("error", "duplicate_task_event", filepath.Join(root, "data", taskEventsDirName), "ID de evento de tarea duplicado", event.ID) + } + seen[event.ID] = true + if _, ok := tasks[normalizeID(event.TaskID)]; !ok { + report.add("error", "unknown_task_event_target", filepath.Join(root, "data", taskEventsDirName), "el evento apunta a una tarea inexistente", event.TaskID) + } + if _, ok := adminByID(registry, event.AdminID); !ok { + report.add("error", "unknown_task_admin", filepath.Join(root, "data", taskEventsDirName), "admin_id de evento no existe", event.AdminID) + } + if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) { + report.add("error", "task_fingerprint_mismatch", filepath.Join(root, "data", taskEventsDirName), "la huella del evento no pertenece al administrador", event.ID) + } + if _, parseErr := time.Parse(time.RFC3339, event.At); parseErr != nil { + report.add("error", "invalid_task_event_time", filepath.Join(root, "data", taskEventsDirName), "at no es RFC3339", event.ID) + } + switch event.Type { + case "start": + if active, ok := activeTimer[event.AdminID]; ok { + code := "duplicate_task_start" + message := "el administrador inició dos veces la misma tarea sin detenerla" + if normalizeID(active.TaskID) != normalizeID(event.TaskID) { + code = "concurrent_task_timers" + message = "el administrador tiene más de un temporizador activo" + } + report.add("error", code, filepath.Join(root, "data", taskEventsDirName), message, map[string]any{ + "admin_id": event.AdminID, "active_event": active.ID, "new_event": event.ID, + }) + } + activeTimer[event.AdminID] = event + case "stop": + active, ok := activeTimer[event.AdminID] + if !ok || normalizeID(active.TaskID) != normalizeID(event.TaskID) { + report.add("error", "task_stop_without_start", filepath.Join(root, "data", taskEventsDirName), "el evento stop no corresponde a un temporizador activo", event.ID) + } else { + delete(activeTimer, event.AdminID) + } + if event.Minutes <= 0 { + report.add("error", "invalid_stop_minutes", filepath.Join(root, "data", taskEventsDirName), "un evento stop debe registrar minutos positivos", event.ID) + } + totalMinutes[normalizeID(event.TaskID)] += event.Minutes + case "adjust": + totalMinutes[normalizeID(event.TaskID)] += event.Minutes + if event.Minutes == 0 { + report.add("error", "invalid_adjust_minutes", filepath.Join(root, "data", taskEventsDirName), "un ajuste no puede ser cero", event.ID) + } + case "status": + if !validTaskStatus(event.Status) { + report.add("error", "invalid_status_event", filepath.Join(root, "data", taskEventsDirName), "status no es válido", event.Status) + } + default: + report.add("error", "invalid_task_event_type", filepath.Join(root, "data", taskEventsDirName), "tipo de evento desconocido", event.Type) + } + } + + for taskID, minutes := range totalMinutes { + if minutes < 0 { + report.add("error", "negative_task_time", filepath.Join(root, "data", taskEventsDirName), "los eventos dejan el tiempo acumulado por debajo de cero", map[string]any{ + "task_id": taskID, "actual_minutes": minutes, + }) + } + } + + // La lectura estable de errores facilita comparaciones en CI y por agentes. + sort.Slice(report.Errors, func(i, j int) bool { + if report.Errors[i].Path == report.Errors[j].Path { + return report.Errors[i].Code < report.Errors[j].Code + } + return report.Errors[i].Path < report.Errors[j].Path + }) + sort.Slice(report.Warnings, func(i, j int) bool { + if report.Warnings[i].Path == report.Warnings[j].Path { + return report.Warnings[i].Code < report.Warnings[j].Code + } + return report.Warnings[i].Path < report.Warnings[j].Path + }) +} diff --git a/scripts/bootstrap-theme.sh b/scripts/bootstrap-theme.sh new file mode 100755 index 0000000..656b53c --- /dev/null +++ b/scripts/bootstrap-theme.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REF="${ZOLA386_REF:-d9c9726435d1347341f2171f9aa751291f1db93d}" +DEST="$ROOT/themes/zola.386" +SOURCE="${ZOLA386_SOURCE:-}" +TMP="" + +cleanup() { + if [[ -n "$TMP" && -d "$TMP" ]]; then + rm -rf "$TMP" + fi +} +trap cleanup EXIT + +if [[ -z "$SOURCE" ]]; then + TMP="$(mktemp -d)" + SOURCE="$TMP/source" + git init -q "$SOURCE" + git -C "$SOURCE" remote add origin https://github.com/lopes/zola.386.git + git -C "$SOURCE" fetch -q --depth 1 origin "$REF" + git -C "$SOURCE" checkout -q --detach FETCH_HEAD +else + SOURCE="$(cd "$SOURCE" && pwd)" +fi + +for required in sass static theme.toml; do + if [[ ! -e "$SOURCE/$required" ]]; then + echo "Falta $required en el tema fuente: $SOURCE" >&2 + exit 1 + fi +done + +rm -rf "$DEST" +mkdir -p "$DEST" +mkdir -p "$DEST/templates" +cp -R "$SOURCE/sass" "$DEST/sass" +cp -R "$SOURCE/static" "$DEST/static" +cp "$SOURCE/theme.toml" "$DEST/theme.toml" +[[ -f "$SOURCE/LICENSE" ]] && cp "$SOURCE/LICENSE" "$DEST/LICENSE" +[[ -f "$SOURCE/README.md" ]] && cp "$SOURCE/README.md" "$DEST/UPSTREAM_README.md" +printf '%s\n' "$REF" > "$DEST/.upstream-revision" + +cat </dev/null 2>&1; then + echo "Falta el comando requerido para la prueba E2E: $command" >&2 + exit 1 + fi +done +if [[ ! -x "$CLI" ]]; then + echo "No existe un binario ejecutable en $CLI; ejecute make build." >&2 + exit 1 +fi + +TMP="$(mktemp -d)" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT +cp -a "$ROOT/." "$TMP/" +CLI="$TMP/bin/clusterlog" + +for section in memorias tareas proyectos; do + find "$TMP/content/$section" -maxdepth 1 -type f -name '*.md' ! -name '_index.md' -delete +done +find "$TMP/data/review-events" -type f ! -name '.gitkeep' -delete +find "$TMP/data/task-events" -type f ! -name '.gitkeep' -delete +cat > "$TMP/data/admins.json" <<'JSON' +{ + "version": 1, + "review_policy": {"default": "all-active", "include_author": true}, + "admins": [] +} +JSON +: > "$TMP/config/allowed_signers" + +# Material público ficticio: sólo ejercita resolución de huella, nunca firma. +cat > "$TMP/ana.pub" <<'KEY' +ssh-ed25519 ZmFrZS1rZXktbWF0ZXJpYWwtYW5hLTAwMDAwMDAwMDAwMDAwMA== ana@test +KEY +cat > "$TMP/bob.pub" <<'KEY' +ssh-ed25519 ZmFrZS1rZXktbWF0ZXJpYWwtYm9iLTAwMDAwMDAwMDAwMDAwMA== bob@test +KEY + +run_json() { + local output + output=$("$@") + jq -e '.ok == true' <<<"$output" >/dev/null + printf '%s' "$output" +} + +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \ + admin add --id ana --name 'Ana Admin' --email ana@example.org \ + --key "$TMP/ana.pub" --roles admin >/dev/null +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \ + admin add --id bob --name 'Bob Admin' --email bob@example.org \ + --key "$TMP/bob.pub" --roles admin >/dev/null + +cat > "$TMP/memory.json" <<'JSON' +{ + "title": "Actualizar kubelet de nodos de prueba", + "description": "Cambio controlado en dos nodos", + "tags": ["kubernetes", "mantenimiento"], + "systems": ["worker-test-01", "worker-test-02"], + "risk": "medium", + "review_required": true, + "review_policy": "all-active", + "context": "Validar la actualización antes de la ventana productiva.", + "changes": ["Drenar nodos", "Actualizar paquete", "Reintegrar nodos"], + "validation": ["Nodos Ready", "Pods reprogramados"], + "rollback": "Reinstalar la versión anterior." +} +JSON +memory=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \ + memory create --from-json "$TMP/memory.json") +mem_id=$(jq -er '.data.entry_id' <<<"$memory") +mem_path=$(jq -er '.data.path' <<<"$memory") + +queue=$(run_json "$CLI" --root "$TMP" --json review list) +[[ $(jq -er '.data.count' <<<"$queue") -eq 1 ]] +[[ $(jq -er '.data.items[0].required_count' <<<"$queue") -eq 2 ]] +[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 0 ]] + +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \ + review mark "$mem_id" --note 'Revisado por Ana' >/dev/null +queue=$(run_json "$CLI" --root "$TMP" --json review list) +[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 1 ]] +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \ + review mark "$mem_id" --note 'Revisado por Bob' >/dev/null +queue=$(run_json "$CLI" --root "$TMP" --json review list) +[[ $(jq -er '.data.count' <<<"$queue") -eq 0 ]] + +# Cambiar el contenido invalida las confirmaciones de la versión anterior. +printf '\n## Seguimiento\n\nSe agregó una observación posterior.\n' >> "$mem_path" +run_json "$CLI" --root "$TMP" --json sync >/dev/null +queue=$(run_json "$CLI" --root "$TMP" --json review list) +[[ $(jq -er '.data.count' <<<"$queue") -eq 1 ]] +[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 0 ]] +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" review mark "$mem_id" >/dev/null +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" review mark "$mem_id" >/dev/null +queue=$(run_json "$CLI" --root "$TMP" --json review list) +[[ $(jq -er '.data.count' <<<"$queue") -eq 0 ]] + +cat > "$TMP/project.json" <<'JSON' +{ + "title": "Renovar plataforma de monitoreo", + "description": "Actualizar y normalizar la observabilidad", + "objective": "Reducir puntos ciegos operativos.", + "owners": ["ana"], + "target_date": "2099-12-01", + "status": "active", + "tags": ["observabilidad"], + "systems": ["prometheus"], + "scope": ["Métricas y alertas"], + "out_of_scope": ["Mesa de ayuda"], + "milestones": ["Inventario", "Migración"], + "success_criteria": ["Cobertura completa de nodos"] +} +JSON +project=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \ + project create --from-json "$TMP/project.json") +project_id=$(jq -er '.data.project_id' <<<"$project") + +cat > "$TMP/task.json" </dev/null +run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \ + task adjust "$task_id" --minutes 15 --note 'Trabajo previo' >/dev/null +done=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \ + task done "$task_id" --note 'Terminado') +[[ $(jq -er '.data.task.status' <<<"$done") == done ]] +[[ $(jq -er '.data.task.actual_minutes' <<<"$done") -ge 15 ]] +[[ $(jq -er '.data.task.running_by | length' <<<"$done") -eq 0 ]] + +tasks=$(run_json "$CLI" --root "$TMP" --json task list --project "$project_id") +[[ $(jq -er '.data.count' <<<"$tasks") -eq 1 ]] +[[ $(jq -er '.data.items[0].status' <<<"$tasks") == done ]] +projects=$(run_json "$CLI" --root "$TMP" --json project list --status active) +[[ $(jq -er '.data.count' <<<"$projects") -eq 1 ]] +run_json "$CLI" --root "$TMP" --json sync --check >/dev/null +validation=$(run_json "$CLI" --root "$TMP" --json validate --skip-zola) +[[ $(jq -er '.data.valid' <<<"$validation") == true ]] +[[ $(find "$TMP/data/review-events" -type f -name '*.json' | wc -l) -eq 4 ]] +[[ $(find "$TMP/data/task-events" -type f -name '*.json' | wc -l) -ge 4 ]] + +printf 'E2E OK: memoria %s, proyecto %s, tarea %s; revisión por hash y eventos independientes verificados.\n' \ + "$mem_id" "$project_id" "$task_id" diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..c399449 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}" + +"$BIN" --root "$ROOT" --json sync >/dev/null +"$BIN" --root "$ROOT" --json sync --check >/dev/null +"$BIN" --root "$ROOT" --json validate --skip-zola >/dev/null +"$BIN" --json schema memory >/dev/null +"$BIN" --json schema task >/dev/null +"$BIN" --json schema project >/dev/null +"$BIN" --json schema content >/dev/null + +echo "Smoke test correcto." diff --git a/scripts/verify-signatures-ci.sh b/scripts/verify-signatures-ci.sh new file mode 100755 index 0000000..763e204 --- /dev/null +++ b/scripts/verify-signatures-ci.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}" +BASE_SHA="${BASE_SHA:-}" +HEAD_SHA="${HEAD_SHA:-HEAD}" + +for command in git jq; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Falta el comando requerido para verificar firmas: $command" >&2 + exit 1 + fi +done +if [[ ! -x "$CLI" ]]; then + echo "No existe un binario ejecutable en $CLI; ejecute make build." >&2 + exit 1 +fi + +active_admins=$(jq '[.admins[] | select(.active == true)] | length' "$ROOT/data/admins.json") +if [[ "$active_admins" -eq 0 ]]; then + echo "Firmas: bootstrap sin administradores activos; comprobación omitida." + exit 0 +fi + +if [[ -z "$BASE_SHA" ]]; then + echo "BASE_SHA es obligatorio cuando existen administradores activos." >&2 + exit 1 +fi +if ! git -C "$ROOT" cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + echo "El commit base no está disponible: $BASE_SHA" >&2 + exit 1 +fi +if ! git -C "$ROOT" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then + echo "El commit de cabeza no está disponible: $HEAD_SHA" >&2 + exit 1 +fi + +mapfile -t commits < <(git -C "$ROOT" rev-list --reverse "$BASE_SHA..$HEAD_SHA") +if [[ ${#commits[@]} -eq 0 ]]; then + echo "Firmas: no hay commits nuevos en el rango." + exit 0 +fi + +for commit in "${commits[@]}"; do + "$CLI" --root "$ROOT" verify commit "$commit" >/dev/null + echo "Firma SSH autorizada: $commit" +done diff --git a/static/css/ops.css b/static/css/ops.css new file mode 100644 index 0000000..b26c799 --- /dev/null +++ b/static/css/ops.css @@ -0,0 +1,131 @@ +:root { + --ops-bg: #08131f; + --ops-panel: #102437; + --ops-panel-2: #17324a; + --ops-text: #eef6f7; + --ops-muted: #a9bec9; + --ops-accent: #5df2c2; + --ops-warning: #ffd166; + --ops-danger: #ff6b6b; + --ops-border: #3c6277; + --ops-shadow: 6px 6px 0 #02070b; +} + +html { scroll-behavior: smooth; } +body { background: var(--ops-bg); color: var(--ops-text); } +a { color: var(--ops-accent); text-decoration-thickness: 2px; text-underline-offset: 3px; } +code, kbd, pre { font-family: "Lucida Console", Monaco, monospace; } +code { overflow-wrap: anywhere; } +.container { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; } + +.ops-header { position: sticky; top: 0; z-index: 20; background: rgba(8, 19, 31, .97); border-bottom: 3px double var(--ops-border); } +.ops-header__inner { min-height: 72px; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding-block: .6rem; } +.ops-brand { color: var(--ops-text); font-size: 1.15rem; font-weight: 800; text-decoration: none; text-transform: uppercase; letter-spacing: .04em; } +.ops-prompt { color: var(--ops-accent); font-family: monospace; font-size: .8rem; } +.ops-nav { margin-left: auto; display: flex; flex-wrap: wrap; gap: .25rem .75rem; align-items: center; } +.ops-nav a { color: var(--ops-text); text-decoration: none; padding: .35rem .1rem; border-bottom: 2px solid transparent; } +.ops-nav a:hover, .ops-nav a:focus { color: var(--ops-accent); border-color: var(--ops-accent); } +.ops-nav__review { color: var(--ops-warning) !important; } +.ops-nav__review span { display: inline-grid; place-items: center; min-width: 1.5rem; min-height: 1.5rem; border: 1px solid currentColor; } + +.ops-search { padding-top: 1rem; } +.ops-search > label { display: block; color: var(--ops-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; margin-bottom: .3rem; } +.ops-search__row { display: flex; align-items: center; background: #02070b; border: 2px solid var(--ops-border); } +.ops-search input { flex: 1; min-width: 0; background: transparent; color: var(--ops-text); border: 0; padding: .7rem .8rem; font: inherit; outline: none; } +.ops-search input:focus { box-shadow: inset 0 0 0 2px var(--ops-accent); } +.ops-search kbd { margin-right: .55rem; color: var(--ops-muted); } +.ops-search-results { position: relative; z-index: 10; background: var(--ops-panel); border: 2px solid var(--ops-border); border-top: 0; box-shadow: var(--ops-shadow); max-height: 65vh; overflow: auto; } +.ops-search-result { display: block; color: var(--ops-text); text-decoration: none; padding: .7rem .9rem; border-bottom: 1px solid var(--ops-border); } +.ops-search-result:hover, .ops-search-result:focus { background: var(--ops-panel-2); } +.ops-search-result strong { display: block; color: var(--ops-accent); } +.ops-search-result small { color: var(--ops-muted); } + +.ops-main { padding-block: 2.5rem 4rem; min-height: 70vh; } +.ops-hero { border: 4px double var(--ops-border); background: linear-gradient(135deg, var(--ops-panel), #0a1a29); padding: clamp(1.4rem, 4vw, 3.5rem); box-shadow: var(--ops-shadow); } +.ops-hero h1, .ops-page-header h1 { margin: .1em 0 .35em; line-height: 1.04; text-wrap: balance; } +.ops-lead { color: var(--ops-muted); font-size: 1.08rem; max-width: 72ch; } +.ops-eyebrow { margin: 0; color: var(--ops-accent); font-family: monospace; font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; } + +.ops-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: .85rem; margin: 1.5rem 0 2.5rem; } +.ops-stat { display: flex; flex-direction: column; gap: .1rem; padding: 1rem; background: var(--ops-panel); border: 2px solid var(--ops-border); color: var(--ops-text); text-decoration: none; } +.ops-stat strong { color: var(--ops-accent); font-size: 1.9rem; } +.ops-stat span { color: var(--ops-muted); } + +.ops-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } +.ops-grid--sections { margin-bottom: 3rem; } +.ops-panel { min-height: 165px; padding: 1.15rem; background: var(--ops-panel); border: 2px solid var(--ops-border); box-shadow: 4px 4px 0 #02070b; color: var(--ops-text); text-decoration: none; transition: transform .12s ease; } +.ops-panel:hover { transform: translate(-2px, -2px); border-color: var(--ops-accent); } +.ops-panel h2 { color: var(--ops-text); margin: .55rem 0 .35rem; } +.ops-panel p { color: var(--ops-muted); } +.ops-panel__code { color: var(--ops-accent); font-family: monospace; border: 1px solid currentColor; padding: .15rem .35rem; } +.ops-panel--attention { border-color: var(--ops-warning); } +.ops-panel--attention .ops-panel__code { color: var(--ops-warning); } + +.ops-section-heading { display: flex; justify-content: space-between; align-items: end; gap: 1rem; margin: 2rem 0 1rem; border-bottom: 2px solid var(--ops-border); } +.ops-section-heading h2 { margin-top: .1rem; } +.ops-page-header { padding-bottom: 1.5rem; margin-bottom: 1.5rem; border-bottom: 4px double var(--ops-border); } +.ops-page-header > p:not(.ops-eyebrow) { color: var(--ops-muted); max-width: 78ch; } +.ops-meta { display: flex; flex-wrap: wrap; gap: .5rem 1rem; color: var(--ops-muted); font-size: .9rem; } +.ops-tags { display: flex; flex-wrap: wrap; gap: .4rem .65rem; margin-top: .7rem; color: var(--ops-accent); font-family: monospace; font-size: .86rem; } +.ops-tags a, .ops-tags span { color: inherit; } + +.ops-list { display: grid; gap: 1rem; } +.ops-list-item { padding: 1rem 1.1rem; background: var(--ops-panel); border-left: 5px solid var(--ops-border); } +.ops-list-item:hover { border-left-color: var(--ops-accent); } +.ops-list-item h2, .ops-list-item h3 { margin: .45rem 0 .3rem; } +.ops-list-item p { color: var(--ops-muted); margin: .25rem 0; } +.ops-list-item__meta { display: flex; flex-wrap: wrap; gap: .4rem .75rem; align-items: center; color: var(--ops-muted); font-size: .82rem; } +.ops-list-item__meta code { color: var(--ops-accent); } +.ops-list-item--review { border-left-color: var(--ops-warning); } + +.badge { display: inline-block; font-family: monospace; font-size: .72rem; padding: .14rem .35rem; border: 1px solid currentColor; text-transform: uppercase; } +.badge--low { color: var(--ops-accent); } +.badge--medium { color: #80d8ff; } +.badge--high { color: var(--ops-warning); } +.badge--critical { color: var(--ops-danger); } +.badge--status { color: var(--ops-warning); } + +.ops-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; align-items: start; } +.ops-card { background: var(--ops-panel); border: 2px solid var(--ops-border); padding: 1rem; box-shadow: 4px 4px 0 #02070b; } +.ops-card__top { display: flex; justify-content: space-between; align-items: center; gap: .5rem; } +.ops-card h2 { font-size: 1.15rem; margin: .8rem 0 .35rem; } +.ops-card p { color: var(--ops-muted); } +.ops-card dl { display: grid; grid-template-columns: auto 1fr; gap: .25rem .7rem; font-size: .88rem; } +.ops-card dt { color: var(--ops-muted); } +.ops-card dd { margin: 0; text-align: right; } +.ops-running { color: var(--ops-accent) !important; font-family: monospace; } + +.ops-article { max-width: 900px; } +.ops-prose { line-height: 1.72; } +.ops-prose h2 { margin-top: 2em; border-bottom: 1px solid var(--ops-border); padding-bottom: .2em; } +.ops-prose h3 { margin-top: 1.6em; } +.ops-prose blockquote { border-left: 4px solid var(--ops-accent); margin-left: 0; padding: .6rem 1rem; background: var(--ops-panel); } +pre { overflow: auto; background: #02070b; color: var(--ops-text); padding: .9rem; border: 1px solid var(--ops-border); } +.ops-callout { margin: 1rem 0 1.5rem; padding: 1rem; background: #1b2b25; border: 2px solid var(--ops-accent); } +.ops-source { display: grid; gap: .3rem; margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--ops-border); color: var(--ops-muted); } +.ops-review-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; } +.ops-review-grid > div { display: grid; gap: .25rem; padding: .7rem; background: #091723; } +.reviewer { color: var(--ops-muted); } +.reviewer--done { color: var(--ops-accent); } +.reviewer--missing { color: var(--ops-warning); } +.ops-error { color: var(--ops-danger) !important; font-weight: 700; } +.ops-empty { padding: 2rem; border: 2px dashed var(--ops-border); color: var(--ops-muted); text-align: center; } +.ops-empty--success { border-color: var(--ops-accent); color: var(--ops-accent); } +.ops-footer { display: flex; justify-content: space-between; gap: 1rem; border-top: 3px double var(--ops-border); padding-block: 1.5rem 2.5rem; color: var(--ops-muted); font-size: .85rem; } + +@media (max-width: 920px) { + .ops-stats, .ops-grid, .ops-board { grid-template-columns: repeat(2, 1fr); } + .ops-nav { width: 100%; margin-left: 0; } +} +@media (max-width: 620px) { + .ops-stats, .ops-grid, .ops-board, .ops-review-grid { grid-template-columns: 1fr; } + .ops-header { position: static; } + .ops-footer { flex-direction: column; } + .ops-panel { min-height: 0; } +} + +.ops-meeting-section { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 2px dashed currentColor; +} diff --git a/static/js/ops-search.js b/static/js/ops-search.js new file mode 100644 index 0000000..d8ffe3b --- /dev/null +++ b/static/js/ops-search.js @@ -0,0 +1,142 @@ +(() => { + "use strict"; + + const input = document.getElementById("ops-search-input"); + const results = document.getElementById("ops-search-results"); + if (!input || !results) return; + + const indexURL = input.dataset.indexUrl; + let documentsPromise = null; + + const normalize = (value) => String(value ?? "") + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLocaleLowerCase("es") + .replace(/\s+/g, " ") + .trim(); + + const loadDocuments = () => { + if (documentsPromise) return documentsPromise; + documentsPromise = fetch(indexURL, { credentials: "same-origin" }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((index) => { + const docs = index?.documentStore?.docs ?? {}; + return Object.entries(docs).map(([ref, doc]) => ({ ref, ...doc })); + }); + return documentsPromise; + }; + + const scoreDocument = (doc, tokens, wholeQuery) => { + const title = normalize(doc.title); + const description = normalize(doc.description); + const content = normalize(doc.content ?? doc.body); + const path = normalize(doc.path ?? doc.ref); + const fullText = `${title} ${description} ${path} ${content}`; + + if (!tokens.every((token) => fullText.includes(token))) return -1; + + let score = 0; + if (title === wholeQuery) score += 100; + if (title.startsWith(wholeQuery)) score += 40; + if (title.includes(wholeQuery)) score += 25; + if (description.includes(wholeQuery)) score += 12; + + for (const token of tokens) { + if (title.includes(token)) score += 12; + if (description.includes(token)) score += 6; + if (path.includes(token)) score += 4; + if (content.includes(token)) score += 2; + } + return score; + }; + + const clearResults = () => { + results.hidden = true; + results.replaceChildren(); + }; + + const appendMessage = (title, detail) => { + const item = document.createElement("div"); + item.className = "ops-search-result"; + const strong = document.createElement("strong"); + strong.textContent = title; + const small = document.createElement("small"); + small.textContent = detail; + item.append(strong, small); + results.replaceChildren(item); + results.hidden = false; + }; + + const renderMatches = (matches) => { + const fragment = document.createDocumentFragment(); + for (const { doc } of matches) { + const link = document.createElement("a"); + link.className = "ops-search-result"; + link.href = doc.permalink ?? doc.ref ?? doc.path ?? "/"; + + const title = document.createElement("strong"); + title.textContent = doc.title || link.href; + const detail = document.createElement("small"); + detail.textContent = doc.description || doc.path || ""; + link.append(title, detail); + fragment.append(link); + } + results.replaceChildren(fragment); + results.hidden = false; + }; + + let requestSerial = 0; + const render = async (rawQuery) => { + const serial = ++requestSerial; + const query = normalize(rawQuery); + const tokens = [...new Set(query.split(" ").filter((token) => token.length > 1))]; + if (query.length < 2 || tokens.length === 0) { + clearResults(); + return; + } + + try { + const documents = await loadDocuments(); + if (serial !== requestSerial) return; + const matches = documents + .map((doc) => ({ doc, score: scoreDocument(doc, tokens, query) })) + .filter((item) => item.score >= 0) + .sort((a, b) => b.score - a.score || String(a.doc.title).localeCompare(String(b.doc.title), "es")) + .slice(0, 12); + + if (matches.length === 0) { + appendMessage("Sin resultados", "Pruebe otro término o una etiqueta más corta."); + return; + } + renderMatches(matches); + } catch (error) { + console.warn("No se pudo cargar el índice de búsqueda", error); + appendMessage("Búsqueda no disponible", "Compruebe que el sitio se construyó con el índice habilitado."); + } + }; + + let debounceTimer = null; + input.addEventListener("input", () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => render(input.value), 100); + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + input.value = ""; + clearResults(); + input.blur(); + } + }); + document.addEventListener("keydown", (event) => { + if (event.key === "/" && document.activeElement !== input && !event.metaKey && !event.ctrlKey && !event.altKey) { + event.preventDefault(); + input.focus(); + } + }); + document.addEventListener("click", (event) => { + if (!results.contains(event.target) && event.target !== input) results.hidden = true; + }); +})(); diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..75322bf --- /dev/null +++ b/templates/404.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}404 · {{ config.title }}{% endblock title %} +{% block content %}

ERROR 404

Ruta no encontrada

La referencia no existe en la proyección actual de la bitácora.

Volver al inicio

{% endblock content %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a6028e2 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,49 @@ + + + + + + + + {% block title %}{{ config.title }}{% endblock title %} + + + + + +{% set ops = load_data(path="data/generated/site_state.json", format="json") %} +
+ +
+ + + +
+ {% block content %}{% endblock content %} +
+ +
+ {{ config.extra.footer_text }} + CLI: {{ config.extra.product_short_name }} +
+ + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e3bdb72 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ section.description }}{% endblock description %} +{% block content %} +
+

OPERACIONES / CONOCIMIENTO / AUDITORÍA

+

{{ section.title }}

+
{{ section.content | safe }}
+
+ +
+ {{ ops.stats.pending_reviews }}revisiones pendientes + {{ ops.stats.open_tasks }}tareas abiertas + {{ ops.stats.active_projects }}proyectos activos +
{{ ops.stats.active_admins }}administradores activos
+
+ +
+ DOC

Documentación

Arquitectura y decisiones duraderas.

+ RUN

Manuales

Procedimientos repetibles y reversibles.

+ MEM

Memorias

Historia de operaciones y cambios.

+ PRJ

Proyectos

Objetivos, alcance e hitos.

+ TSK

Tareas

Trabajo visible y tiempo registrado.

+ REV

Revisión semanal

La agenda común del equipo.

+
+ +
+

ACTIVIDAD

Memorias recientes

+ Ver todas → +
+
+ {% for item in ops.recent_memories %} + {% if loop.index <= 6 %} +
+
{{ item.entry_id }}{{ item.risk }}{{ item.date }}
+

{{ item.title }}

+

{{ item.description }}

+
{% for tag in item.tags %}#{{ tag }}{% endfor %}
+
+ {% endif %} + {% endfor %} + {% if ops.recent_memories | length == 0 %}

Todavía no hay memorias operativas.

{% endif %} +
+{% endblock content %} diff --git a/templates/memories.html b/templates/memories.html new file mode 100644 index 0000000..1db8d61 --- /dev/null +++ b/templates/memories.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ section.description }}{% endblock description %} +{% block content %} +
+

HISTORIA TÉCNICA

{{ section.title }}

{{ section.description }}

+
{{ section.content | safe }}
+
+
+{% for page in section.pages %} +
+
{{ page.extra.id }}{{ page.extra.risk }}{{ page.date }}por {{ page.extra.author_id }}
+

{{ page.title }}

+

{{ page.description }}

+
{% for system in page.taxonomies.systems %}@{{ system }}{% endfor %}{% for tag in page.taxonomies.tags %}#{{ tag }}{% endfor %}
+
+{% endfor %} +
+{% endblock content %} diff --git a/templates/memory.html b/templates/memory.html new file mode 100644 index 0000000..6d86771 --- /dev/null +++ b/templates/memory.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ page.description }}{% endblock description %} +{% block content %} +
+
+

{{ page.extra.id }} / MEMORIA OPERATIVA

+

{{ page.title }}

{{ page.description }}

+
+ Autor: {{ page.extra.author_id }} + Fecha: {{ page.date }} + Riesgo: {{ page.extra.risk }} + {% if page.extra.duration_minutes > 0 %}Duración: {{ page.extra.duration_minutes }} min{% endif %} + {% if page.extra.ticket %}Referencia: {{ page.extra.ticket }}{% endif %} +
+
{% for system in page.taxonomies.systems %}@{{ system }}{% endfor %}{% for tag in page.taxonomies.tags %}#{{ tag }}{% endfor %}
+
+ {% if page.extra.review_required %} + + {% endif %} +
{{ page.content | safe }}
+
Huella SSH del autor:{{ page.extra.author_fingerprint }}
+
+{% endblock content %} diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..5752974 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ page.description }}{% endblock description %} +{% block content %} +
+
+

{{ page.extra.id }} / {{ page.extra.kind }}

+

{{ page.title }}

+

{{ page.description }}

+
Autor: {{ page.extra.author_id }}Fecha: {{ page.date }}
+
{% for tag in page.taxonomies.tags %}#{{ tag }}{% endfor %}
+
+
{{ page.content | safe }}
+
+{% endblock content %} diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000..172fdd8 --- /dev/null +++ b/templates/project.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ page.description }}{% endblock description %} +{% block content %} +
+

{{ page.extra.id }} / PROYECTO

{{ page.title }}

{{ page.description }}

+
Estado: {{ page.extra.status }}{% if page.extra.target_date %}Fecha objetivo: {{ page.extra.target_date }}{% endif %}Responsables: {% for owner in page.extra.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if page.extra.owners | length == 0 %}Sin asignar{% endif %}
+
{{ page.content | safe }}
+
+{% endblock content %} diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..3de13db --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ section.description }}{% endblock description %} +{% block content %} +

LÍNEAS DE TRABAJO

{{ section.title }}

{{ section.description }}

{{ section.content | safe }}
+
+{% for project in ops.projects %} +
+
{{ project.project_id }}{{ project.status }}
+

{{ project.title }}

{{ project.description }}

+
{% if project.target_date %}
Objetivo
{{ project.target_date }}
{% endif %}
Responsables
{% for owner in project.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if project.owners | length == 0 %}Sin asignar{% endif %}
+
+{% endfor %} +{% if ops.projects | length == 0 %}

No hay proyectos.

{% endif %} +
+{% endblock content %} diff --git a/templates/review.html b/templates/review.html new file mode 100644 index 0000000..dd952bf --- /dev/null +++ b/templates/review.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ section.description }}{% endblock description %} +{% block content %} +
+

AGENDA DEL EQUIPO

{{ section.title }}

{{ section.description }}

+
{{ section.content | safe }}
+
+ +
+ Flujo de la reunión: abrir la memoria, discutirla y registrar cada confirmación con + clusterlog --commit review mark <ID>. Una edición posterior cambia el hash y vuelve a abrir la revisión. +
+ +
+

Memorias pendientes

+
+ {% for item in ops.review_queue %} +
+
{{ item.entry_id }}{{ item.risk }}{{ item.reviewed_count }}/{{ item.required_count }} revisiones
+

{{ item.title }}

+

{{ item.description }}

+ {% if item.configuration_error %}

{{ item.configuration_error }}

{% endif %} +
+
Revisaron{% for admin in item.reviewed_by %}✓ {{ admin }}{% endfor %}{% if item.reviewed_by | length == 0 %}Nadie aún{% endif %}
+
Faltan{% for admin in item.missing_reviewers %}○ {{ admin }}{% endfor %}{% if item.missing_reviewers | length == 0 %}Completa{% endif %}
+
+
clusterlog --json --commit review mark {{ item.entry_id }}
+
+ {% endfor %} + {% if ops.review_queue | length == 0 %}
Cola limpia.
No hay memorias pendientes para la versión actual de su contenido.
{% endif %} +
+
+ +
+

Tareas abiertas para revisar

+
+ {% for task in ops.tasks %} + {% if task.status != "done" and task.status != "cancelled" %} +
+
{{ task.task_id }}{{ task.status }}{{ task.actual_minutes }}/{{ task.estimate_minutes }} min
+

{{ task.title }}

+

{{ task.description }}

+ {% if task.running_by | length > 0 %}

En ejecución: {{ task.running_by | join(sep=", ") }}

{% endif %} +
+ {% endif %} + {% endfor %} +
+
+ +
+

Proyectos activos o bloqueados

+
+ {% for project in ops.projects %} + {% if project.status == "active" or project.status == "blocked" or project.status == "planned" %} +
+
{{ project.project_id }}{{ project.status }}{% if project.target_date %}Meta {{ project.target_date }}{% endif %}
+

{{ project.title }}

+

{{ project.description }}

+
+ {% endif %} + {% endfor %} +
+
+{% endblock content %} diff --git a/templates/section.html b/templates/section.html new file mode 100644 index 0000000..7a83868 --- /dev/null +++ b/templates/section.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ section.description }}{% endblock description %} +{% block content %} +
+

BIBLIOTECA TÉCNICA

+

{{ section.title }}

+

{{ section.description }}

+
{{ section.content | safe }}
+
+
+{% for page in section.pages %} +
+
{{ page.extra.id }}{{ page.date }}
+

{{ page.title }}

+

{{ page.description }}

+
{% for tag in page.taxonomies.tags %}#{{ tag }}{% endfor %}
+
+{% endfor %} +{% if section.pages | length == 0 %}

No hay páginas en esta sección.

{% endif %} +
+{% endblock content %} diff --git a/templates/task.html b/templates/task.html new file mode 100644 index 0000000..acf87ce --- /dev/null +++ b/templates/task.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ page.description }}{% endblock description %} +{% block content %} +
+

{{ page.extra.id }} / TAREA

{{ page.title }}

{{ page.description }}

+
Estado base: {{ page.extra.status }}Prioridad: {{ page.extra.priority }}Estimación: {{ page.extra.estimate_minutes }} min{% if page.extra.due %}Fecha: {{ page.extra.due }}{% endif %}{% if page.extra.project_id %}Proyecto: {{ page.extra.project_id }}{% endif %}
+ +
{{ page.content | safe }}
+
clusterlog --commit task start {{ page.extra.id }}
+clusterlog --commit task stop {{ page.extra.id }}
+clusterlog --commit task done {{ page.extra.id }}
+
+{% endblock content %} diff --git a/templates/tasks.html b/templates/tasks.html new file mode 100644 index 0000000..a92a8cf --- /dev/null +++ b/templates/tasks.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %} +{% block description %}{{ section.description }}{% endblock description %} +{% block content %} +

TRABAJO COMPARTIDO

{{ section.title }}

{{ section.description }}

{{ section.content | safe }}
+
+{% for task in ops.tasks %} +
+
{{ task.task_id }}{{ task.status }}
+

{{ task.title }}

{{ task.description }}

+
Prioridad
{{ task.priority }}
Estimado
{{ task.estimate_minutes }} min
Real
{{ task.actual_minutes }} min
{% if task.due %}
Fecha
{{ task.due }}
{% endif %}{% if task.project_id %}
Proyecto
{{ task.project_id }}
{% endif %}
+ {% if task.running_by | length > 0 %}

▶ En curso por {% for admin in task.running_by %}{{ admin }}{% if not loop.last %}, {% endif %}{% endfor %}

{% endif %} +
{% for admin in task.assignees %}+{{ admin }}{% endfor %}
+
+{% endfor %} +{% if ops.tasks | length == 0 %}

No hay tareas.

{% endif %} +
+{% endblock content %} diff --git a/templates/taxonomy_list.html b/templates/taxonomy_list.html new file mode 100644 index 0000000..0551c69 --- /dev/null +++ b/templates/taxonomy_list.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}{{ taxonomy.name }} · {{ config.title }}{% endblock title %} +{% block content %} +

ÍNDICE

{{ taxonomy.name }}

+
+{% for term in terms %}

{{ term.name }}

{{ term.page_count }} páginas

{% endfor %} +
+{% endblock content %} diff --git a/templates/taxonomy_single.html b/templates/taxonomy_single.html new file mode 100644 index 0000000..87d8322 --- /dev/null +++ b/templates/taxonomy_single.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}{{ term.name }} · {{ config.title }}{% endblock title %} +{% block content %} +

{{ taxonomy.name }}

{{ term.name }}

{{ term.page_count }} páginas

+
{% for page in term.pages %}{% endfor %}
+{% endblock content %} diff --git a/themes/.gitkeep b/themes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/zola.toml b/zola.toml new file mode 100644 index 0000000..d5eda21 --- /dev/null +++ b/zola.toml @@ -0,0 +1,53 @@ +base_url = "https://bitacora-cluster.example.invalid" +title = "Bitácora Operativa del Clúster" +description = "Documentación, manuales, memorias de cambio, proyectos y tareas del equipo de soporte." +default_language = "es" +theme = "zola.386" +output_dir = "public" +compile_sass = true +minify_html = true +generate_feeds = true +feed_filenames = ["atom.xml", "rss.xml"] +build_search_index = true +generate_sitemap = true +generate_robots_txt = false + +taxonomies = [ + { name = "categories" }, + { name = "tags", feed = true }, + { name = "systems" }, +] + +[markdown] +external_links_target_blank = true +external_links_no_referrer = true +smart_punctuation = true +github_alerts = true +insert_anchor_links = "right" + +[markdown.highlighting] +error_on_missing_language = false +style = "inline" +theme = "nord" + +[search] +include_title = true +include_description = true +include_date = true +include_path = true +include_content = true +truncate_content_length = 12000 +index_format = "elasticlunr_json" + +[slugify] +paths = "on" +taxonomies = "on" +anchors = "on" +paths_keep_dates = false + +[extra] +product_name = "Bitácora Operativa" +product_short_name = "clusterlog" +repository_label = "Repositorio interno" +review_meeting_label = "Revisión semanal" +footer_text = "Fuente auditable: Markdown + eventos JSON + Git"