diff --git a/.clusterlog-version b/.clusterlog-version new file mode 100644 index 0000000..b82608c --- /dev/null +++ b/.clusterlog-version @@ -0,0 +1 @@ +v0.1.0 diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 929199f..0000000 --- a/.editorconfig +++ /dev/null @@ -1,18 +0,0 @@ -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/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1614173..e1b86fd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,18 +8,13 @@ permissions: contents: read jobs: - test-and-build: + validate-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 run: | mkdir -p "$HOME/.local/bin" @@ -31,23 +26,10 @@ jobs: - 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: Descargar plantillas y binario del motor (fenandosr/clusterlog, versión fijada en .clusterlog-version) + run: | + make templates + make build - name: Verificar firmas SSH de commits del pull request if: github.event_name == 'pull_request' @@ -87,12 +69,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Compilar CLI + - name: Descargar binario del motor (versión fijada en .clusterlog-version) run: make build - name: Instalar Runme fijado (v3.17.4, checksum verificado) diff --git a/.gitignore b/.gitignore index 6c74c3e..d337adb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /memorias/ /public/ /bin/ +/templates/ /themes/zola.386/ .DS_Store *.tmp @@ -12,6 +13,7 @@ .direnv/ # No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver -# docs/RUNME.md): un glob amplio aquí podría ocultar Markdown legítimo por -# accidente. clusterlog validate detecta señales concretas en su lugar -# (directorio .runme/ versionado, archivos "*output*" dentro de runbooks/). +# docs/RUNME.md en el repositorio del motor): un glob amplio aquí podría +# ocultar Markdown legítimo por accidente. clusterlog validate detecta +# señales concretas en su lugar (directorio .runme/ versionado, archivos +# "*output*" dentro de runbooks/). diff --git a/AGENTS.md b/AGENTS.md index 83bef10..34ea764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,15 +89,16 @@ segunda CLI. Reglas para un agente: - No genere ni edite manualmente un identificador Lifecycle Identity de Runme. -## Cambios al código del proyecto +## Cambios a esta instancia -Antes de entregar: +Este repositorio no tiene código de la CLI (vive en +[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog)). Un cambio +aquí es Markdown/JSON de contenido o configuración de deployment. Antes de +entregar: ```bash -gofmt -w cmd internal -go test ./... -go vet ./... make build +make templates ./bin/clusterlog --root . sync ./bin/clusterlog --root . sync --check ./bin/clusterlog --root . validate --skip-zola @@ -111,4 +112,5 @@ Cuando Zola esté disponible: zola build ``` -Mantenga compatibilidad del JSON. Cualquier cambio de contrato debe actualizar `schema.go`, `CLI.md`, ejemplos y pruebas. +Si el cambio necesario es a la CLI, sus esquemas o su comportamiento, hágalo +en el repositorio del motor, no aquí. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a0d2eca..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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/CLI.md b/CLI.md deleted file mode 100644 index 826c279..0000000 --- a/CLI.md +++ /dev/null @@ -1,407 +0,0 @@ -# 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. - -## 16. Versión - -```bash -clusterlog version -``` - -No requiere `--root` ni identidad. Devuelve `{"name":"clusterlog","version":"..."}`. -El valor de `version` se fija en tiempo de compilación: - -```bash -go build -ldflags "-X example.org/bitacora-cluster/internal/clusterlog.Version=v0.2.0" ... -``` - -`make build` ya lo hace automáticamente a partir de `git describe --tags --always --dirty` (variable `CLUSTERLOG_VERSION` del `Makefile`); un build local sin tags produce algo como `abc1234-dirty`, no `dev` ni una cadena vacía. - -## 17. Instancias nuevas - -```bash -clusterlog bootstrap-instance \ - --output ./clusterlog-nuevo-deploy \ - --product-name "Bitácora de Ejemplo" \ - --base-url "https://clusterlog.ejemplo.org" -``` - -No requiere `--root`: `--output` es un directorio nuevo, normalmente fuera del repositorio actual. Tampoco usa `--commit` (no hay nada que firmar todavía). Genera: - -- `zola.toml` con `base_url`/`title`/`[extra] product_name` sustituidos y el resto de los valores genéricos del motor; -- `content//_index.md` para `documentacion`, `manuales`, `memorias`, `proyectos`, `tareas` y `revision`; -- `data/admins.json` y `config/allowed_signers` vacíos (mismo formato que produce `admin add`, sin ningún administrador todavía); -- `data/review-events/.gitkeep`, `data/task-events/.gitkeep`, `data/generated/`; -- `.clusterlog-version` con la versión del binario que generó el scaffold; -- `README.md` y `.gitignore` de arranque. - -Falla con `output_not_empty` (código `5`) si `--output` ya existe y no está vacío — nunca sobreescribe una instancia existente. Con `--dry-run` no crea el directorio; sólo muestra qué haría. - -Después de `bootstrap-instance`, el flujo es el mismo que "Registrar al primer administrador" en `README.md`, apuntando `--root` al directorio nuevo: `admin add` → `sync` → `validate --skip-zola`. `validate` antes de la primera `sync` falla con `generated_state_stale` a propósito: la proyección todavía no existe. - -**No incluido todavía** (fases posteriores, ver el ADR de motor/instancia cuando exista): plantillas Tera ni tema empaquetados para descarga, ni un `.gitea/workflows/ci.yml` de instancia — el motor aún no publica releases binarios. diff --git a/HOJA_DE_PROYECTO.md b/HOJA_DE_PROYECTO.md deleted file mode 100644 index 87dd117..0000000 --- a/HOJA_DE_PROYECTO.md +++ /dev/null @@ -1,484 +0,0 @@ -# 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 deleted file mode 100644 index b6bae72..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 index cb4cdc4..7f2f2a0 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,24 @@ SHELL := /usr/bin/env bash -GO ?= go ZOLA ?= zola BINARY ?= bin/clusterlog -CLUSTERLOG_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -X example.org/bitacora-cluster/internal/clusterlog.Version=$(CLUSTERLOG_VERSION) -.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci +.PHONY: all build theme templates sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci all: build +# "build" ya no compila (esta instancia no tiene código Go): descarga el +# binario fijado en .clusterlog-version desde fenandosr/clusterlog, +# verificando su checksum. Se mantiene el nombre "build" para no romper +# RUNBOOK.md ni el hábito de make build/make sync-check de siempre. build: - mkdir -p $(dir $(BINARY)) - $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/clusterlog - -test: - $(GO) test ./... - -vet: - $(GO) vet ./... - -e2e: build - ./scripts/e2e-local.sh + ./scripts/fetch-engine.sh bin $(BINARY) theme: ./scripts/bootstrap-theme.sh +templates: + ./scripts/fetch-engine.sh templates templates + sync: build ./$(BINARY) --root . sync @@ -37,20 +31,20 @@ validate: build validate-local: build ./$(BINARY) --root . validate --skip-zola -site: theme sync validate +site: theme templates sync validate $(ZOLA) build -serve: theme sync +serve: theme templates sync $(ZOLA) serve --interface 127.0.0.1 smoke: build ./scripts/smoke.sh -ci: theme test vet e2e build sync sync-check validate +ci: theme templates build sync sync-check validate $(ZOLA) build clean: - rm -rf bin public themes/zola.386 + rm -rf bin public themes/zola.386 templates runme-install: ./scripts/runme-install.sh diff --git a/README.md b/README.md index aec44c0..782489e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ -# Bitácora Operativa del Clúster +# Bitácora Operativa del Clúster (MCPS) -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. +Instancia de [`clusterlog`](https://github.com/fenandosr/clusterlog): 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 el clúster MCPS. + +Este repositorio contiene únicamente **datos de este deployment** +(`content/`, `data/`, `config/`, `zola.toml`, `.clusterlog-version`). El +código de la CLI, las plantillas y el mecanismo de tema viven en el motor: +[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog) — ver +`docs/adr/0005-motor-instancia.md` de ese repositorio para el porqué de la +separación. La solución separa responsabilidades: @@ -13,7 +23,7 @@ administrador o agente IA | ---- escribe Markdown/eventos JSON | ---- opcionalmente crea commit SSH firmado v - repositorio Git ---- fuente de verdad y auditoría + repositorio Git ---- fuente de verdad y auditoría (esta instancia) | v clusterlog sync ---- genera data/generated/site_state.json @@ -22,7 +32,7 @@ administrador o agente IA Zola ---- lectura, navegación, taxonomías y búsqueda ``` -## Qué incluye el MVP +## Qué incluye - 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. @@ -30,79 +40,45 @@ administrador o agente IA - 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`. +- **No requiere Go ni un checkout del motor**: `make build`/`make templates` descargan el binario y las plantillas fijadas en [`.clusterlog-version`](.clusterlog-version) desde `fenandosr/clusterlog`, verificando su checksum. ## Inicio rápido ```bash -make theme -make build +make theme # tema zola.386 (repositorio externo, pinneado) +make templates # plantillas del motor, versión fijada +make build # binario clusterlog, versión fijada ./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. +El sitio queda disponible en la dirección que muestre Zola. Antes de publicar un cambio a `base_url`/`zola.toml`, manténgalo detrás de VPN, red privada o autenticación corporativa. -## Crear una instancia nueva +## Registrar un administrador nuevo -Este repositorio hoy mezcla la herramienta con el contenido de un deployment. Para empezar un deployment **distinto** sin partir de este contenido, use: - -```bash -clusterlog bootstrap-instance --output ./mi-deploy --product-name "Mi Bitácora" -``` - -Genera `zola.toml`, las seis secciones de `content/` vacías, `data/admins.json`/`config/allowed_signers` vacíos y `.clusterlog-version`. Ver la sección 17 de [`CLI.md`](CLI.md) para el detalle completo; sigue siendo un primer paso — todavía no incluye plantillas/tema empaquetados ni CI de instancia (el motor no publica releases binarios todavía). - -## 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. +Sólo un `owner` activo puede hacerlo (ver `content/manuales/primer-administrador.md` para el procedimiento completo, incluido el alta del segundo administrador). ```bash ./bin/clusterlog \ --root . \ --ssh-key ~/.ssh/id_ed25519.pub \ + --commit \ admin add \ --id ana \ --name "Ana Torres" \ --email ana@example.org \ --key ~/.ssh/id_ed25519.pub \ - --roles owner,admin + --roles 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. @@ -136,6 +112,8 @@ Las instrucciones completas para agentes están en [`AGENTS.md`](AGENTS.md) y [` ## Flujo de revisión semanal +Versión ejecutable (Runme) en [`runbooks/weekly-review.md`](runbooks/weekly-review.md); agenda y criterio en [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): + ```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 @@ -153,12 +131,10 @@ La página `/revision/` contiene la cola pendiente y, como apoyo a la reunión, ## 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 sync-check # comprueba que la proyección está actualizada +make validate-local # valida sin requerir Zola +make site # tema + plantillas + estado + validación completa + sitio 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 ``` @@ -168,39 +144,36 @@ Los procedimientos frecuentes también existen como notebooks ejecutables, sin reemplazar ningún comando canónico: [`RUNBOOK.md`](RUNBOOK.md) es el punto de entrada. Requiere Runme `v3.17.4` (`make runme-install`) sólo si se quiere abrir como notebook o correr por CLI; cada celda es, además, un -bloque de shell normal que sigue funcionando copiado a mano. Detalles, -modelo de identidad y limitaciones conocidas en -[`docs/RUNME.md`](docs/RUNME.md) y la decisión en -[`docs/adr/0004-runme-sidecar.md`](docs/adr/0004-runme-sidecar.md). +bloque de shell normal que sigue funcionando copiado a mano. Modelo de +identidad, limitaciones y la decisión completa viven en el motor: +[`docs/RUNME.md`](https://github.com/fenandosr/clusterlog/blob/main/docs/RUNME.md) +y +[ADR-0004](https://github.com/fenandosr/clusterlog/blob/main/docs/adr/0004-runme-sidecar.md). ## 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 +content/ Markdown fuente por sección (memorias, manuales, ...) 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 +config/allowed_signers firmantes SSH autorizados (generado junto a admins.json) +templates/, themes/ descargados por make templates/make theme — no versionados +static/ ajustes visuales y búsqueda propios de esta instancia +scripts/ fetch del motor, tema, smoke test, verificación de firmas +runbooks/, RUNBOOK.md documentación ejecutable de esta instancia +docs/ guion de la reunión semanal +.clusterlog-version versión fijada del motor que usa esta instancia ``` ## 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. -- [`docs/ADR.md`](docs/ADR.md): decisiones de diseño de **clusterlog** (la herramienta), no del clúster que administra; formato, campos y ejemplo. Registros en [`docs/adr/`](docs/adr/), sin equivalente en la UI del sitio ni en la CLI. -- [`docs/RUNME.md`](docs/RUNME.md): documentación ejecutable con Runme — instalación, modelo de identidad y limitaciones. Punto de entrada: [`RUNBOOK.md`](RUNBOOK.md). -- [`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. +- [`content/manuales/`](content/manuales/): procedimientos operativos de esta instancia (acceso SSH, correo, Caddy, alta de administradores...). +- Documentación del motor (CLI, esquemas, ADRs, `bootstrap-instance`, Runme): [fenandosr/clusterlog](https://github.com/fenandosr/clusterlog). -## Estado del prototipo +## Estado -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. +Instancia activa del clúster MCPS. La CI (`.gitea/workflows/ci.yml`) descarga el binario y las plantillas del motor por versión fijada, sincroniza y valida el contenido, construye con Zola y publica en `clusterlog.mcps-epcm.org` al hacer push a `main`. También verifica cada commit del pull request contra `config/allowed_signers`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index c7cd060..0000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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/RUNBOOK.md b/RUNBOOK.md index f9fc035..61730b3 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -1,9 +1,11 @@ # RUNBOOK -Punto de entrada de la documentación ejecutable de `clusterlog`. Cada celda de -abajo envuelve un comando canónico existente (`make`, `./bin/clusterlog`, un -script en `scripts/`) — ninguna celda contiene lógica propia. Ver -[`docs/RUNME.md`](docs/RUNME.md) para el porqué y el modelo de identidad, y +Punto de entrada de la documentación ejecutable de esta **instancia** de +`clusterlog` (MCPS). Cada celda envuelve un comando canónico existente +(`make`, `./bin/clusterlog`, un script en `scripts/`) — ninguna celda +contiene lógica propia. El desarrollo del motor mismo (compilar, probar, +`bootstrap-instance`) vive en +[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); ver [`runbooks/README.md`](runbooks/README.md) para la convención completa de nombres y etiquetas. @@ -12,45 +14,35 @@ comando de shell normal que también puede copiarse y ejecutarse a mano. ## Runbooks disponibles -- [`runbooks/clusterlog-development.md`](runbooks/clusterlog-development.md) — ciclo de desarrollo (build, test, vet, sync, validate, sitio). - [`runbooks/weekly-review.md`](runbooks/weekly-review.md) — versión ejecutable de la reunión semanal ([`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md) conserva la agenda). - [`runbooks/_template.md`](runbooks/_template.md) — plantilla para runbooks nuevos. ## Comprobaciones rápidas -```sh {"name":"clusterlog-runme-preflight","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} +```sh {"name":"clusterlog-runme-preflight","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"} set -euo pipefail -test -f go.mod && test -f Makefile || { echo "ejecute esto desde la raíz de clusterlog" >&2; exit 1; } -echo "go: $(go version)" -echo "git: $(git --version)" +test -f zola.toml && test -f .clusterlog-version || { echo "ejecute esto desde la raíz de la instancia" >&2; exit 1; } +echo "clusterlog-version: $(cat .clusterlog-version)" if command -v zola >/dev/null 2>&1; then echo "zola: $(zola --version)"; else echo "zola: no instalado (validate --skip-zola sigue funcionando)"; fi if command -v runme >/dev/null 2>&1; then echo "runme: $(runme --version)"; else echo "runme: no instalado (make runme-install)"; fi ``` -```sh {"name":"clusterlog-build","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -make build -``` - -```sh {"name":"clusterlog-test","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -make test -``` - -```sh {"name":"clusterlog-sync-check","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} +```sh {"name":"clusterlog-sync-check","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"} make sync-check ``` -```sh {"name":"clusterlog-validate-local","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} +```sh {"name":"clusterlog-validate-local","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"} make validate-local ``` -```sh {"name":"clusterlog-site-build","tag":"role-developer,env-local,risk-read-only","interactive":"false"} +```sh {"name":"clusterlog-site-build","tag":"role-operator,env-local,risk-read-only","interactive":"false"} make site ``` -`clusterlog-site-build` no lleva `ci-safe`: `make site` incluye `make theme`, -que clona el repositorio del tema por red. La CI principal -(`.gitea/workflows/ci.yml`) ya construye el sitio en su propio paso; repetirlo -aquí sería una segunda copia de la misma lógica, no una celda adicional útil. +`clusterlog-site-build` no lleva `ci-safe`: descarga el tema y las plantillas +del motor por red (`make theme templates`). La CI (`.gitea/workflows/ci.yml`) +ya construye el sitio en su propio paso; repetirlo aquí sería una segunda +copia de la misma lógica, no una celda adicional útil. ```sh {"name":"clusterlog-review-mine","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"} : "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY con la ruta a su llave pública}" @@ -67,29 +59,14 @@ ninguna llave privada real. ## Ejecutar -Ejemplo (nota: éste es texto de referencia, no una celda ejecutable — -usa fence ` ```text ` a propósito para que Runme no lo liste como comando): - -```text -runme run --filename RUNBOOK.md clusterlog-build -``` - -o, sin Runme instalado, copie y corra el comando dentro del bloque -directamente. `make runme-ci` ejecuta: - ```text runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts ``` -`runme run` sí acepta `--tag`/`--all` (verificado con el binario real de -`v3.17.4`: un tag inexistente devuelve `No tasks to execute with the tag -provided`, y `--tag ci-safe --all` corre exactamente las celdas marcadas -`ci-safe`, en orden, deteniéndose en el primer fallo). Como es un filtro -explícito por etiqueta de seguridad — no "correr todo" — este uso puntual sí -es seguro. - -Nunca ejecute `runme run --all --skip-prompts` **sin** `--tag ci-safe` sobre -este archivo ni sobre `runbooks/weekly-review.md`: sin ese filtro, `--all` -también incluiría celdas que mutan estado real. Ésas deben correrse una por -una, a mano, después de revisar sus +es lo que corre `make runme-ci`: exactamente las celdas marcadas `ci-safe`, +en orden, deteniéndose en el primer fallo (verificado con Runme real; ver el +ADR-0004 del motor). Nunca ejecute `runme run --all --skip-prompts` **sin** +`--tag ci-safe` sobre este archivo ni sobre `runbooks/weekly-review.md`: sin +ese filtro, `--all` también incluiría celdas que mutan estado real de esta +instancia. Ésas deben correrse una por una, a mano, después de revisar sus prerrequisitos. diff --git a/SECURITY.md b/SECURITY.md index 6b762e0..4d8b85a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -124,8 +124,9 @@ Ante contenido sensible o firma cuestionable: ## Documentación ejecutable (Runme) `RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como -notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004). Esto no cambia el -modelo de confianza: se agregan las siguientes reglas. +notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004 en el +[repositorio del motor](https://github.com/fenandosr/clusterlog)). Esto no +cambia el modelo de confianza: se agregan las siguientes reglas. - Ejecutar una celda de Runme equivale exactamente a ejecutar el comando local con los permisos del usuario que la corre. No hay una elevación ni diff --git a/SHA256SUMS b/SHA256SUMS deleted file mode 100644 index b201d30..0000000 --- a/SHA256SUMS +++ /dev/null @@ -1 +0,0 @@ -76b832471eead59133fbfffe63dd5e5ef1ec708a2e4c4b9b864d665350616ad5 bin/clusterlog diff --git a/THEME_COMPATIBILITY.md b/THEME_COMPATIBILITY.md deleted file mode 100644 index 919311c..0000000 --- a/THEME_COMPATIBILITY.md +++ /dev/null @@ -1,80 +0,0 @@ -# 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 deleted file mode 100644 index 4be08e7..0000000 --- a/VALIDATION_REPORT.md +++ /dev/null @@ -1,104 +0,0 @@ -# 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 deleted file mode 100644 index 6e8bf73..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/cmd/clusterlog/main.go b/cmd/clusterlog/main.go deleted file mode 100644 index 5c00f17..0000000 --- a/cmd/clusterlog/main.go +++ /dev/null @@ -1,34 +0,0 @@ -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/data/generated/site_state.json b/data/generated/site_state.json index 1e36b59..d940bb7 100644 --- a/data/generated/site_state.json +++ b/data/generated/site_state.json @@ -1,7 +1,7 @@ { "version": 2, - "generated_at": "2026-08-25T01:01:15Z", - "source_sha256": "24c5629b3f812fee92bb28270c4c245f3c4735eef59c8cdadb91d05aeda0813d", + "generated_at": "2026-08-27T06:37:41Z", + "source_sha256": "594001fb9ffeca21d3c081be211f5249968d3f43e857530f45c8d69e25381aec", "stats": { "active_admins": 1, "pending_reviews": 7, diff --git a/docs/ADR.md b/docs/ADR.md deleted file mode 100644 index 515e8f6..0000000 --- a/docs/ADR.md +++ /dev/null @@ -1,113 +0,0 @@ -# Decisiones de arquitectura (ADR) - -## Alcance - -Un ADR (*Architecture Decision Record*) documenta una decisión de diseño sobre **clusterlog**: la herramienta, su modelo de datos, su CLI, su proyección estática y su cadena de confianza. No es el lugar para decisiones operativas del clúster que administra el equipo (topología de nodos, políticas de red, capacidad, etc.); esas pertenecen a **Manuales** o **Memorias** dentro del sitio. - -Un ADR se justifica cuando la decisión: - -- es costosa o difícil de revertir; -- afecta el modelo de datos, la identidad/firma, o el contrato de la CLI; -- descarta una alternativa razonable por un motivo que no es obvio leyendo el código; -- va a ser preguntada de nuevo en el futuro ("¿por qué no usamos X?"). - -No se justifica para elección de librerías internas reversibles, estilo de código, o correcciones de bugs — eso va en el mensaje de commit o, si amerita contexto, en una memoria. - -## Dónde viven y cómo se gestionan - -- Los archivos están en [`docs/adr/`](adr/), uno por decisión. -- **No se ven en la UI del sitio ni se gestionan con la CLI de `clusterlog`.** `docs/adr/` no forma parte de `content/` (lo que Zola proyecta) y no existe ningún comando `clusterlog adr *`. Son Markdown plano, editado a mano, versionado y revisado como el resto del repositorio. -- Se crean, discuten y aprueban por revisión de código normal (pull request), igual que cualquier otro cambio al repositorio. - -## Nombre de archivo - -```text -docs/adr/NNNN-titulo-en-kebab-case.md -``` - -- `NNNN`: número secuencial de 4 dígitos, sin reutilizar ni renumerar al superar un ADR. -- El slug es una versión corta del título en minúsculas, con guiones. - -## Campos y secciones - -Cada ADR sigue esta estructura, en este orden: - -```markdown -# ADR-NNNN: Título de la decisión - -- **Estado:** propuesta | aceptada | rechazada | reemplazada por ADR-MMMM -- **Fecha:** AAAA-MM-DD - -## Contexto - -## Decisión - -## Consecuencias positivas - -## Consecuencias negativas - -## Alternativas descartadas (opcional) - -## Regla de interpretación (opcional) -``` - -| Campo/Sección | Obligatorio | Contenido | -|---|---|---| -| Título | sí | Frase corta que nombra la decisión, no el problema. | -| Estado | sí | Uno de: `propuesta`, `aceptada`, `rechazada`, `reemplazada por ADR-MMMM`. Un ADR aceptado no se borra ni se edita para invertir la decisión; se agrega uno nuevo que lo reemplaza y se actualiza el estado del anterior. | -| Fecha | sí | Fecha en que el estado actual entró en vigor. | -| Contexto | sí | La fuerza o restricción que exige decidir: qué problema, qué tensión, qué no puede seguir igual. Sin esto, la decisión no se puede evaluar más adelante. | -| Decisión | sí | Qué se decidió, en términos concretos y verificables (no aspiracionales). | -| Consecuencias positivas | sí | Efectos deseables que se aceptan a cambio. | -| Consecuencias negativas | sí | Costos, riesgos o deuda que la decisión introduce a sabiendas. Un ADR sin consecuencias negativas es sospechoso. | -| Alternativas descartadas | no | Opciones consideradas y por qué se descartaron. Útil cuando la pregunta "¿por qué no X?" es previsible. | -| Regla de interpretación | no | Aclaración normativa para evitar que la decisión se lea de forma laxa (ver ADR-0002 como ejemplo). | - -## Ejemplo - -```markdown -# ADR-0004: Límite de tamaño para eventos JSON individuales - -- **Estado:** aceptada -- **Fecha:** 2026-08-18 - -## Contexto - -Los eventos de `data/review-events/` y `data/task-events/` son archivos JSON -inmutables, uno por evento. Nada impide hoy que un evento incluya un adjunto -grande o un volcado de log completo, lo que degradaría `clusterlog sync` y -ensuciaría el historial Git con blobs pesados. - -## Decisión - -- Cada archivo de evento se limita a 64 KB. -- La CLI rechaza la escritura si el JSON serializado excede el límite, - con un mensaje que indica el tamaño real y el máximo permitido. -- El límite se valida antes de firmar o comitear, nunca después. - -## Consecuencias positivas - -- El historial Git permanece liviano y rápido de clonar. -- `clusterlog sync` tiene un tiempo de ejecución más predecible. -- Fuerza a resumir en vez de adjuntar evidencia extensa. - -## Consecuencias negativas - -- Evidencia grande (logs completos, capturas) debe vivir fuera del evento - y referenciarse por enlace o ticket. -- El límite es arbitrario y puede requerir ajuste con el uso real. - -## Alternativas descartadas - -- Sin límite: expone el repositorio a crecer sin control y a bloquear CI. -- Comprimir el evento: complica la lectura directa con herramientas - estándar de Git, que es una propiedad central de ADR-0001. -``` - -## Relación con otros tipos de contenido - -- **ADR**: por qué el software está diseñado así. Vive en `docs/adr/`, no se sincroniza al sitio. -- **Manual** (`content/manuales/`): cómo ejecutar un procedimiento repetible sobre el clúster o la bitácora. Sí se publica en el sitio. -- **Memoria**: qué se hizo, cuándo, por quién, con qué validación y reversión, en una sesión concreta. Sí se publica en el sitio. - -Si al escribir una memoria o un manual surge una decisión de diseño reutilizable que no está documentada, es señal de que falta un ADR — no de que deba explicarse de nuevo cada vez. diff --git a/docs/RUNME.md b/docs/RUNME.md deleted file mode 100644 index 4f91689..0000000 --- a/docs/RUNME.md +++ /dev/null @@ -1,212 +0,0 @@ -# Runme como sidecar ejecutable - -Este documento explica cómo y por qué `clusterlog` usa Runme, qué garantiza y -qué no. La decisión formal está en -[`docs/adr/0004-runme-sidecar.md`](adr/0004-runme-sidecar.md); esto es la -guía operativa del día a día. - -## Qué es "sidecar" aquí - -No es un contenedor sidecar de Kubernetes ni un servicio adicional. Es una -capa opcional y adyacente: Runme abre `RUNBOOK.md` y `runbooks/**/*.md` como -notebooks y ejecuta las celdas, pero cada celda sólo invoca `make`, -`./bin/clusterlog` o un script existente en `scripts/`. Si Runme no está -instalado, todo sigue funcionando exactamente igual copiando el comando de la -celda a una terminal — no hay una segunda implementación de la lógica en -ningún lado. - -## Instalación - -Versión fijada: **`v3.17.4`** (nunca `latest`). - -```bash -make runme-install -``` - -Esto corre [`scripts/runme-install.sh`](../scripts/runme-install.sh), que: - -- descarga el binario de `github.com/runmedev/runme` para su combinación de - SO/arquitectura (Linux x86_64/arm64, macOS x86_64/arm64); -- verifica su SHA-256 contra los valores publicados en el `checksums.txt` del - release (verificados de forma independiente con `sha256sum` durante esta - implementación, no copiados a ciegas del sitio de descargas); -- instala en `$HOME/.local/bin` (o `$RUNME_INSTALL_DIR`), nunca sobre - `bin/clusterlog` — el script aborta si detecta un binario `clusterlog` ahí; -- no usa `curl | sh`. - -**Windows**: el script no lo soporta. Descargue manualmente -`runme_windows_{x86_64,arm64}.zip` desde la página de releases y verifique su -checksum contra el mismo `checksums.txt` antes de usarlo. - -`make runme-install` no es un paso obligatorio de ningún flujo existente: -`make build`, `make test`, `clusterlog sync`/`validate` y `zola build` no lo -requieren ni lo disparan. - -## Cómo correrlo - -```bash -make runme-list # runme ls --filename RUNBOOK.md -make runme-check # clusterlog validate --skip-zola + compatibilidad blanda con Runme -make runme-ci # runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts -``` - -`runme-check` nunca escribe en el repositorio. `runme-ci` sólo corre celdas -etiquetadas `ci-safe`. - -## El flag `--tag` es real (corrección respecto al diseño inicial) - -Durante la investigación previa a implementar, la documentación pública de -`docs.runme.dev` que se pudo consultar no mostraba un flag de filtrado por -etiqueta en `runme run`, sólo `--filter` (regex por nombre). El plan inicial -asumió por eso que la "seguridad de CI" tendría que vivir como una lista de -nombres explícita en el `Makefile`. - -Al instalar el binario real de `v3.17.4` y correr `runme run --help`, apareció -`-t, --tag stringArray` ("Run from a specific tag"). Se verificó -empíricamente: - -```console -$ runme run --tag totally-bogus-tag-xyz --filename RUNBOOK.md --dry-run -could not execute command: No tasks to execute with the tag provided - -$ runme run --tag ci-safe --all --skip-prompts --filename RUNBOOK.md - ► Running task clusterlog-runme-preflight... - ... - ► ✓ Task clusterlog-runme-preflight exited with code 0 - ► Running task clusterlog-build... - ... -``` - -Es decir: `--tag` sí filtra de verdad, y `--tag ci-safe --all` corre -exactamente y sólo las celdas marcadas `ci-safe`, en el orden en que aparecen, -deteniéndose en el primer fallo. `make runme-ci` usa esa forma. La lección -que sí se mantiene del plan inicial: **nunca** ejecute `runme run --all ---skip-prompts` sin `--tag ci-safe` — sin el filtro, `--all` corre también -las celdas que mutan estado. - -`runme ls --json` **no** expone las etiquetas de una celda en su salida (se -verificó con `--json` contra `RUNBOOK.md`: el JSON trae `name`, `file`, -`first_command`, `description`, `named`, `run_all`, pero no `tags`). Por eso -la fuente de verdad para "qué es `ci-safe`" es el atributo `tag` en el propio -Markdown, exigido y verificado por `clusterlog validate` -(`internal/clusterlog/runbooks.go`), no algo que se pueda inspeccionar desde -`runme ls`. - -## `excludeFromRunAll` es la defensa real, no la etiqueta - -`runme ls --json` también reveló que **toda** celda es elegible para -`--all` (`"run_all": true`) salvo que declare explícitamente -`"excludeFromRunAll": true`. Una celda `risk-mutating`/`risk-destructive` sin -ese atributo seguiría corriendo si alguien ejecuta `runme run --all` sin -filtro. Por eso `clusterlog validate` exige `excludeFromRunAll:true` + -`interactive:true` + la etiqueta `manual-only` en toda celda -`risk-mutating`/`risk-destructive` — la etiqueta sola no protege nada; el -atributo que Runme sí respeta es `excludeFromRunAll`. - -## Capas de identidad (no confundirlas) - -### Capa 1 — Identidad de contenido (Runme) - -El atributo `name` de una celda y cualquier Lifecycle Identity que Runme -asigne identifican **el documento y la celda**, para poder seguir sus -cambios. No identifican quién la ejecutó ni prueban que se ejecutó. No edite -a mano un identificador Lifecycle Identity generado por Runme. - -### Capa 2 — Evidencia local de sesión (Session Outputs) - -Runme puede, si se habilita, guardar una copia del documento con la salida de -cada celda (hora, duración, código de salida, hostname, salida). Esta -integración **no habilita esa función**. Si en el futuro alguien la activa -localmente: - -- debe quedar fuera de Git, del sitio Zola y de cualquier Gist; -- es evidencia auxiliar de que algo se ejecutó en una máquina, nunca prueba - de autoría ni sustituto de una memoria en `clusterlog`. - -**Limitación documentada**: la documentación pública de Runme -(`docs.runme.dev/usage/auto-save/`) no fija un nombre de archivo exacto para -estos Session Outputs en `v3.17.4` — sólo dice "genera un archivo, agréguelo -a `.gitignore`". Por eso este repositorio **no** agrega un patrón amplio de -`.gitignore` que podría ocultar Markdown legítimo sin darse cuenta. -`clusterlog validate` sí revisa (best-effort, no exhaustivo): - -- que no exista un directorio `.runme/` versionado en el árbol de trabajo; -- que ningún archivo dentro de `runbooks/` tenga "output" en el nombre sin - ser `_template.md` (señal, no prueba, de que podría ser una captura de - sesión y no contenido fuente). - -La defensa real sigue siendo procedimental: revise el diff antes de hacer -`git add`, igual que con cualquier otro archivo generado. - -### Capa 3 — Auditoría canónica de Clusterlog (sin cambios) - -La atribución fuerte sigue siendo exactamente la de siempre: - -```text -huella de llave pública SSH - + -identidad registrada en data/admins.json - + -commit Git firmado por SSH - + -verificación en CI (clusterlog verify commit) -``` - -Runme **no** está enlazado criptográficamente a esto de ninguna forma. Un -runbook crítico que termine en una escritura real sigue el patrón de -`AGENTS.md`: `clusterlog identity` → operación con la herramienta canónica → -validación observada → `--dry-run` → `--commit` en una celda manual, -interactiva y excluida de "Run All" → `clusterlog verify commit`. - -Esta primera iteración **no** agrega un modelo nuevo de eventos de ejecución -(por ejemplo, un futuro `clusterlog runbook record`). Si se necesita, se -describe primero en un ADR con su propio modelo de amenazas y contrato JSON. - -## Abrir y cerrar el modo notebook en VS Code - -1. Instale la extensión recomendada en - [`.vscode/extensions.json`](../.vscode/extensions.json) - (`stateful.runme`). -2. Abra `RUNBOOK.md` o cualquier archivo en `runbooks/`; VS Code lo mostrará - como notebook. -3. Para volver a verlo como texto: clic derecho en la pestaña → "Reopen - Editor With..." → "Text Editor". - -Ningún archivo `.md` fuera de `RUNBOOK.md`/`runbooks/` se fuerza a abrir como -notebook. - -## Limitaciones conocidas de este entorno - -- **Zola local es 0.22.1**, no la `0.23.3` que documenta `README.md` y que - instala la CI. `make validate-local` / `validate --skip-zola` no dependen - de la versión exacta; `make site`/`zola build` sí podrían comportarse - distinto localmente que en CI. -- El escaneo de `clusterlog validate` sobre runbooks es un analizador de - líneas con estado, no un parser completo de CommonMark: reconoce fences de - una sola línea de atributos (`` ```sh {"name":...} ``). Fences de apertura - multilínea no están soportados. -- La detección de "target de make inexistente" busca el patrón textual - `make ` dentro del cuerpo de la celda; puede marcar un falso - positivo si ese texto aparece dentro de un comentario o un `echo` (no - ocurre en los runbooks incluidos, pero es una limitación real del - analizador, no una garantía de que nunca puede pasar). -- La detección de Session Outputs es best-effort (ver arriba); no hay una - garantía de que capture todo lo que Runme pudiera llegar a escribir en el - futuro. -- `runme ls` lista **cualquier** fence de código, incluso uno sin atributos - y con lenguaje `text` usado a propósito como texto de referencia (se - confirmó con el binario real: aparecen como filas "No" en la columna - `NAMED`). No son ejecutables por nombre y `--tag ci-safe --all` los - ignora correctamente, pero ensucian la salida de `runme ls`/`make - runme-list`. No se encontró una forma de excluirlos sin dejar de mostrar - también snippets ilustrativos legítimos. - -## Fuera de alcance de esta iteración - -Terminal web, ejecución desde el sitio Zola, acceso directo al clúster desde -el navegador, un servicio Runme permanente, contenedor sidecar de -Kubernetes, Runme Cloud, publicación de Gists, autenticación propia de -Runme, reemplazo de Git/SSH, ejecución productiva en CI, conversión masiva de -manuales existentes, y runbooks para infraestructura que no existe en este -repositorio (bases de datos, escalamiento, rotación de secretos, despliegue a -producción). diff --git a/docs/adr/0001-static-event-projection.md b/docs/adr/0001-static-event-projection.md deleted file mode 100644 index 4272a91..0000000 --- a/docs/adr/0001-static-event-projection.md +++ /dev/null @@ -1,41 +0,0 @@ -# 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 deleted file mode 100644 index 5e6dfba..0000000 --- a/docs/adr/0002-ssh-identity.md +++ /dev/null @@ -1,35 +0,0 @@ -# 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 deleted file mode 100644 index d3d0bb9..0000000 --- a/docs/adr/0003-review-hash-snapshot.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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/docs/adr/0004-runme-sidecar.md b/docs/adr/0004-runme-sidecar.md deleted file mode 100644 index 5807898..0000000 --- a/docs/adr/0004-runme-sidecar.md +++ /dev/null @@ -1,216 +0,0 @@ -# ADR-0004: Runme como sidecar de documentación ejecutable - -- **Estado:** aceptada -- **Fecha:** 2026-08-26 - -## Contexto - -Los procedimientos frecuentes de `clusterlog` (compilar, probar, sincronizar, -validar, la revisión semanal) viven como bloques de comando dentro de -Markdown que hay que copiar y pegar a mano. Se evaluó integrar Runme -(`v3.17.4`, fijado) para poder abrir esos mismos procedimientos como -notebooks ejecutables desde VS Code o su CLI, sin introducir un segundo lugar -donde vivan los mismos comandos ni debilitar el modelo de identidad/auditoría -existente (huella SSH + commit firmado + `clusterlog verify commit`). - -## Decisión - -Se integra Runme como una capa opcional y adyacente: - -- `RUNBOOK.md` (raíz) y `runbooks/**/*.md` son los únicos documentos que - llevan atributos de celda Runme. -- Cada celda invoca exclusivamente `make`, `./bin/clusterlog` o un script ya - existente en `scripts/`. Ninguna celda reimplementa validación, - autorización, resolución de identidad, rollback o gestión de secretos. -- `clusterlog validate` (no un script aparte) hace cumplir las reglas de - nombres, etiquetas y aislamiento de celdas mutantes — ver - `internal/clusterlog/runbooks.go`. -- La instalación de Runme es opcional y reproducible - (`scripts/runme-install.sh`, checksum verificado); ningún flujo existente - (`make build`, `make test`, `clusterlog sync`/`validate`, `zola build`) - depende de que Runme esté instalado. - -## Significado de "sidecar" - -No es un sidecar de Kubernetes ni un servicio adicional. Es un envoltorio: el -humano o agente interactúa con Markdown+Runme, que a su vez llama `make`, -`clusterlog` o un script — nunca al revés, y nunca con lógica propia. - -## Ubicación de los runbooks - -`RUNBOOK.md` en la raíz (índice) y `runbooks/` como carpeta dedicada, **fuera -de `content/`**. Se confirmó en `zola.toml` que Zola no tiene un -`content_dir` alterno: sólo construye desde `content/`. Poner los runbooks -fuera de esa carpeta significa que Zola nunca los toca — cero riesgo de que -un atributo de celda `{"name":...}` rompa el front matter TOML o el -resaltado de sintaxis de una página publicada. `clusterlog validate` además -hace cumplir esto activamente: si una celda de Runme aparece dentro de -`content/`, es un error (`runme_cell_in_zola_content`), no sólo una -recomendación. - -## Relación con `content/manuales` - -`content/manuales/` no se toca. Sigue siendo prosa + comandos de ejemplo para -lectura humana en el sitio Zola, sin atributos de celda ni ninguna -dependencia de Runme. Los runbooks nuevos no son una migración de los -manuales existentes: son procedimientos de desarrollo/operación de -`clusterlog` mismo que hoy no existían como documento ejecutable. - -## Fuente de verdad - -Opción elegida de las tres consideradas (sección 4.2 del pedido original): -**un archivo raíz delgado que enlaza procedimientos canónicos**, aplicada -así: - -- `docs/WEEKLY_REVIEW.md` conserva la agenda y el criterio de la reunión - (prosa) y enlaza a `runbooks/weekly-review.md` para los comandos - ejecutables, en vez de mantener los mismos bloques de shell en dos - archivos. -- `README.md` enlaza `RUNBOOK.md` y `docs/RUNME.md` en vez de duplicar - ejemplos de comandos. - -No se copian bloques de comando manualmente entre dos documentos en ningún -punto de esta integración. - -## Convención de metadatos - -Atributos de celda como objeto JSON de una sola línea después del lenguaje -del fence: `` ```sh {"name":"...","tag":"...","interactive":"...", -"excludeFromRunAll":true} ``. `clusterlog validate` decodifica ese objeto con -`encoding/json` (no confía en una regex para interpretar el contenido; la -regex sólo reconoce la *forma* de la línea de fence). - -## Taxonomía de etiquetas - -Cuatro categorías, tres obligatorias por celda: - -| Categoría | Valores | Obligatoria | -|---|---|---| -| Audiencia | `role-developer`, `role-operator`, `role-reviewer`, `role-owner` | sí | -| Entorno | `env-local`, `env-ci`, `env-staging`, `env-production` | sí | -| Riesgo | `risk-read-only`, `risk-mutating`, `risk-destructive` | sí | -| Ejecución automatizada | `ci-safe`, `manual-only` | no, mutuamente excluyente con riesgo mutante | - -## Modelo de identidad - -Tres capas, documentadas en detalle en `docs/RUNME.md`: - -1. **Identidad de contenido** (Lifecycle Identity / `name` de Runme): - identifica documento y celda, no a quien ejecuta. -2. **Evidencia local de sesión** (Session Outputs, no habilitados aquí): - auxiliar, nunca auditoría canónica. -3. **Auditoría canónica de Clusterlog**: huella SSH + `data/admins.json` + - commit firmado + `clusterlog verify commit`, sin cambios. - -No se afirma ni se implementa ningún enlace criptográfico entre Runme y un -commit. Un futuro `clusterlog runbook record` queda explícitamente fuera de -esta iteración (ver "Trabajo futuro"). - -## Modelo de evidencia - -Session Outputs permanecen desactivados por defecto en esta integración. Si -se habilitan más adelante, deben quedar fuera de Git, del sitio Zola y de -Gists, y tratarse como evidencia auxiliar redactable, nunca como prueba de -autoría. - -**Limitación reconocida**: la documentación pública de Runme para `v3.17.4` -no fija el nombre exacto de archivo de un Session Output -(`docs.runme.dev/usage/auto-save/` sólo recomienda "agréguelo a -`.gitignore`" sin dar el patrón). Se optó explícitamente por **no** agregar -un glob amplio e inseguro a `.gitignore` que pudiera ocultar Markdown -legítimo. En su lugar, `clusterlog validate` aplica dos señales concretas -(directorio `.runme/` versionado; archivos en `runbooks/` con "output" en el -nombre) como defensa best-effort, documentada como tal. - -## CI/CD - -`.gitea/workflows/ci.yml` (Gitea Actions, no GitHub Actions — este repo no -tiene `.github/`) gana un job nuevo y separado que no reemplaza -`test-and-build`: instala Runme fijado en `v3.17.4` vía -`scripts/runme-install.sh` (checksum verificado), corre `make runme-check` -(reglas de `clusterlog validate`, obligatorias, más un chequeo blando de -compatibilidad de Runme) y `make runme-ci` -(`runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts`). -Permisos mínimos (`contents: read`), `DO_NOT_TRACK=true`, -`SCARF_NO_ANALYTICS=true`, sin llaves ni acceso a producción. - -La CI original de Zola instala herramientas fijadas por `curl` + tar en vez -de una Action de marketplace (ver el paso "Instalar Zola fijado" ya -existente); Runme sigue el mismo patrón por consistencia, no por elección -nueva. - -## Seguridad - -Ver el nuevo apartado en `SECURITY.md`. En resumen: una celda Runme equivale -a ejecutar comandos locales con los permisos de quien la corre; una etiqueta -no es control de acceso, `excludeFromRunAll`/`interactive` son defensas -adicionales; Runme no lee llaves privadas; Runme Cloud, Gists y `runme open` -expuesto a `0.0.0.0` quedan fuera de alcance. - -## Alternativas consideradas - -1. **Celdas dentro de `content/manuales/`.** Descartada: arriesga el front - matter TOML y el resaltado de Zola, y mezcla contenido para lectores con - contenido para ejecución; no encaja con la opción 1 de fuente única - (Markdown único compatible con ambos) porque el pedido de Zola y el de - Runme divergen (TOML `+++` vs. atributos JSON de celda) sin un beneficio - real, dado que `runbooks/` fuera de `content/` ya resuelve el conflicto - sin ese riesgo. -2. **Validación en un script Bash aparte en vez de `clusterlog validate`.** - Descartada para las reglas estructurales: encajan limpio en el patrón - existente de `ValidationReport`/`report.add(...)` y ya tenían pruebas - unitarias equivalentes para páginas/eventos. Sí se mantiene un script - (`scripts/runme-check.sh`) para el chequeo blando que sólo tiene sentido - con el binario real de Runme instalado. -3. **Selección de celdas CI-safe con una lista de nombres en el Makefile en - vez del flag `--tag`.** Fue el plan inicial, basado en documentación - pública de Runme que no mostraba un flag de filtrado por etiqueta. Al - instalar el binario real de `v3.17.4` se confirmó que `runme run --tag` - sí existe y filtra correctamente (ver `docs/RUNME.md`). Se adoptó - `--tag ci-safe --all --skip-prompts` porque es la forma más simple que - sigue siendo una allowlist explícita por etiqueta de seguridad, no un - "correr todo" sin filtro. - -## Consecuencias - -Positivas: - -- Los procedimientos de desarrollo se pueden ejecutar como notebook sin - perder la posibilidad de copiar/pegar el comando a mano. -- `clusterlog validate` ahora también protege contra celdas mutantes mal - etiquetadas, nombres duplicados y fugas de Runme hacia `content/`. -- La instalación reproducible con checksum evita depender de `latest` o de - `curl | sh`. - -Negativas / riesgos aceptados: - -- El analizador de runbooks es un escáner de líneas, no un parser CommonMark - completo; hay casos borde documentados en `docs/RUNME.md`. -- La detección de Session Outputs es best-effort, no una garantía. -- Dos documentos nuevos (`RUNBOOK.md`, `docs/RUNME.md`) que hay que mantener - sincronizados con la realidad del `Makefile`. - -## Limitaciones - -- Zola local (`0.22.1`) no coincide con la versión que exige `README.md` y - que instala la CI (`0.23.3`); no se intentó resolver esto en esta - iteración. -- No se ejecutó ninguna celda `risk-mutating` de verdad durante la - implementación (por diseño: requieren identidad real y quedan fuera del - alcance de una demostración automatizada). -- No existe hoy un enlace criptográfico entre una ejecución de Runme y un - commit; quien necesite esa garantía debe seguir el flujo completo de - `AGENTS.md` fuera de Runme. - -## Trabajo futuro - -- Evaluar un comando `clusterlog runbook record` que capture, con su propio - contrato JSON y modelo de amenazas, evidencia de que una celda `ci-safe` - corrió — sólo si el equipo decide que Session Outputs no bastan como - evidencia auxiliar. -- Migrar manuales existentes a runbooks ejecutables uno por uno, sólo cuando - exista infraestructura real que respalde cada procedimiento (no antes). -- Revisar si vale la pena que `runme ls --json` exponga etiquetas en una - versión futura de Runme, para poder validar la relación entre "celdas - etiquetadas ci-safe" y "celdas que `runme-ci` realmente ejecutó" de forma - automatizada en vez de manual. diff --git a/examples/manual.json b/examples/manual.json deleted file mode 100644 index 81732dd..0000000 --- a/examples/manual.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 deleted file mode 100644 index 3e6d5b6..0000000 --- a/examples/memory.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index fb68f26..0000000 --- a/examples/project.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 8cd97f7..0000000 --- a/examples/task.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 738ca52..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module example.org/bitacora-cluster - -go 1.23 diff --git a/internal/clusterlog/cli_integration_test.go b/internal/clusterlog/cli_integration_test.go deleted file mode 100644 index 0d896f7..0000000 --- a/internal/clusterlog/cli_integration_test.go +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 5f3af99..0000000 --- a/internal/clusterlog/commands.go +++ /dev/null @@ -1,432 +0,0 @@ -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:]) - } - if command == "bootstrap-instance" { - return a.runBootstrapInstance(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: - version - bootstrap-instance --output DIR [--product-name NOMBRE] [--base-url URL] - 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_bootstrap.go b/internal/clusterlog/commands_bootstrap.go deleted file mode 100644 index 2c497b3..0000000 --- a/internal/clusterlog/commands_bootstrap.go +++ /dev/null @@ -1,182 +0,0 @@ -package clusterlog - -import ( - "embed" - "os" - "path/filepath" - "sort" - "strings" -) - -//go:embed all:scaffold -var scaffoldFS embed.FS - -const scaffoldRoot = "scaffold" - -var scaffoldSections = []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"} - -// runBootstrapInstance genera el esqueleto de una instancia nueva de -// clusterlog (content/, data/, config/, zola.toml) en --output. No toca el -// proyecto actual: --output es un directorio nuevo, casi siempre fuera de -// este repositorio, así que este comando no pasa por ResolveRoot ni por el -// mecanismo de --commit. -func (a *App) runBootstrapInstance(args []string) error { - fs := newFlagSet("bootstrap-instance") - output := fs.String("output", "", "directorio donde crear la instancia nueva") - productName := fs.String("product-name", "Bitácora Operativa", "nombre del producto para zola.toml") - baseURL := fs.String("base-url", "https://clusterlog.example.org", "base_url de zola.toml; cámbielo antes de publicar") - if err := fs.Parse(args); err != nil { - return NewError(ExitUsage, "invalid_flags", err.Error(), nil) - } - if strings.TrimSpace(*output) == "" { - return NewError(ExitUsage, "missing_output", "indique --output con el directorio de la instancia nueva", nil) - } - root, err := filepath.Abs(*output) - if err != nil { - return NewError(ExitInternal, "internal", err.Error(), nil) - } - - if entries, statErr := os.ReadDir(root); statErr == nil { - if len(entries) > 0 { - return NewError(ExitConflict, "output_not_empty", "el directorio de salida ya existe y no está vacío", root) - } - } else if !os.IsNotExist(statErr) { - return NewError(ExitInternal, "internal", statErr.Error(), root) - } - - if a.Options.DryRun { - return a.printResult("bootstrap-instance", map[string]any{ - "output": root, - "product_name": *productName, - "base_url": *baseURL, - "dry_run": true, - }) - } - - created, err := writeScaffold(root, *productName, *baseURL) - if err != nil { - return err - } - - registry := AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{}} - if err := saveAdmins(root, registry); err != nil { - return NewError(ExitInternal, "internal", err.Error(), nil) - } - created = append(created, - filepath.Join(root, "data", "admins.json"), - filepath.Join(root, "config", "allowed_signers"), - ) - sort.Strings(created) - - return a.printResult("bootstrap-instance", map[string]any{ - "output": root, - "engine_version": Version, - "product_name": *productName, - "base_url": *baseURL, - "files_created": created, - "next_steps": []string{ - "clusterlog --root " + root + " --ssh-key admin add --id --name --email --key --roles owner,admin", - "clusterlog --root " + root + " sync", - "clusterlog --root " + root + " validate --skip-zola", - }, - }) -} - -func writeScaffold(root, productName, baseURL string) ([]string, error) { - created := []string{} - - title := "Bitácora Operativa" - if strings.TrimSpace(productName) != "" { - title = productName - } - - homeTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/content/_index.md.tmpl") - if err != nil { - return nil, NewError(ExitInternal, "internal", "plantilla de portada faltante", err.Error()) - } - homeContent := renderScaffoldTemplate(string(homeTmpl), map[string]string{"TITLE": title}) - homePath := filepath.Join(root, "content", "_index.md") - if err := writeFileAtomic(homePath, []byte(homeContent), 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), homePath) - } - created = append(created, homePath) - - for _, section := range scaffoldSections { - src := scaffoldRoot + "/content/" + section + "/_index.md" - data, err := scaffoldFS.ReadFile(src) - if err != nil { - return nil, NewError(ExitInternal, "internal", "plantilla de scaffold faltante: "+src, err.Error()) - } - dst := filepath.Join(root, "content", section, "_index.md") - if err := writeFileAtomic(dst, data, 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), dst) - } - created = append(created, dst) - } - - zolaTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/zola.toml.tmpl") - if err != nil { - return nil, NewError(ExitInternal, "internal", "plantilla zola.toml faltante", err.Error()) - } - zolaContent := renderScaffoldTemplate(string(zolaTmpl), map[string]string{ - "BASE_URL": baseURL, - "TITLE": title, - "PRODUCT_NAME": productName, - }) - zolaPath := filepath.Join(root, "zola.toml") - if err := writeFileAtomic(zolaPath, []byte(zolaContent), 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), zolaPath) - } - created = append(created, zolaPath) - - gitignoreTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/gitignore.tmpl") - if err != nil { - return nil, NewError(ExitInternal, "internal", "plantilla .gitignore faltante", err.Error()) - } - gitignorePath := filepath.Join(root, ".gitignore") - if err := writeFileAtomic(gitignorePath, gitignoreTmpl, 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), gitignorePath) - } - created = append(created, gitignorePath) - - readmeTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/README.md.tmpl") - if err != nil { - return nil, NewError(ExitInternal, "internal", "plantilla README faltante", err.Error()) - } - readmeContent := renderScaffoldTemplate(string(readmeTmpl), map[string]string{ - "TITLE": title, - "PRODUCT_NAME": productName, - }) - readmePath := filepath.Join(root, "README.md") - if err := writeFileAtomic(readmePath, []byte(readmeContent), 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), readmePath) - } - created = append(created, readmePath) - - versionPath := filepath.Join(root, ".clusterlog-version") - if err := writeFileAtomic(versionPath, []byte(Version+"\n"), 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), versionPath) - } - created = append(created, versionPath) - - for _, dir := range []string{filepath.Join(root, "data", "review-events"), filepath.Join(root, "data", "task-events")} { - keep := filepath.Join(dir, ".gitkeep") - if err := writeFileAtomic(keep, []byte{}, 0o644); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), keep) - } - created = append(created, keep) - } - - if err := os.MkdirAll(filepath.Join(root, "data", "generated"), 0o755); err != nil { - return nil, NewError(ExitInternal, "internal", err.Error(), nil) - } - - return created, nil -} - -func renderScaffoldTemplate(text string, values map[string]string) string { - for key, value := range values { - text = strings.ReplaceAll(text, "{{"+key+"}}", value) - } - return text -} diff --git a/internal/clusterlog/commands_bootstrap_test.go b/internal/clusterlog/commands_bootstrap_test.go deleted file mode 100644 index 0a0f589..0000000 --- a/internal/clusterlog/commands_bootstrap_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package clusterlog - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func newBootstrapApp(t *testing.T, dryRun bool) *App { - t.Helper() - return New(Options{DryRun: dryRun, JSON: true, Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, Executable: "clusterlog"}) -} - -func TestBootstrapInstanceCreatesExpectedFiles(t *testing.T) { - base := t.TempDir() - out := filepath.Join(base, "instancia-nueva") - app := newBootstrapApp(t, false) - - err := app.runBootstrapInstance([]string{"--output", out, "--product-name", "Bitácora de Prueba", "--base-url", "https://clusterlog.prueba.org"}) - if err != nil { - t.Fatalf("runBootstrapInstance falló: %v", err) - } - - for _, section := range scaffoldSections { - path := filepath.Join(out, "content", section, "_index.md") - if _, statErr := os.Stat(path); statErr != nil { - t.Fatalf("falta %s: %v", path, statErr) - } - } - - homeData, err := os.ReadFile(filepath.Join(out, "content", "_index.md")) - if err != nil { - t.Fatalf("falta la portada content/_index.md (necesaria para que zola build no falle): %v", err) - } - if !strings.Contains(string(homeData), `title = "Bitácora de Prueba"`) { - t.Fatalf("la portada no sustituyó TITLE: %s", homeData) - } - - zolaData, err := os.ReadFile(filepath.Join(out, "zola.toml")) - if err != nil { - t.Fatal(err) - } - zola := string(zolaData) - if !strings.Contains(zola, `base_url = "https://clusterlog.prueba.org"`) { - t.Fatalf("zola.toml no tiene el base_url esperado: %s", zola) - } - if !strings.Contains(zola, `title = "Bitácora de Prueba"`) { - t.Fatalf("zola.toml no tiene el title esperado: %s", zola) - } - if !strings.Contains(zola, `product_name = "Bitácora de Prueba"`) { - t.Fatalf("zola.toml no tiene product_name esperado: %s", zola) - } - - var registry AdminRegistry - adminsData, err := os.ReadFile(filepath.Join(out, "data", "admins.json")) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(adminsData, ®istry); err != nil { - t.Fatal(err) - } - if registry.Version != 1 || len(registry.Admins) != 0 || registry.ReviewPolicy.Default != "all-active" { - t.Fatalf("registro de admins inesperado: %+v", registry) - } - - if _, err := os.Stat(filepath.Join(out, "config", "allowed_signers")); err != nil { - t.Fatalf("falta config/allowed_signers: %v", err) - } - - versionData, err := os.ReadFile(filepath.Join(out, ".clusterlog-version")) - if err != nil { - t.Fatal(err) - } - if trimmed := strings.TrimSpace(string(versionData)); trimmed != Version { - t.Fatalf(".clusterlog-version = %q, se esperaba %q", trimmed, Version) - } - - for _, dir := range []string{"review-events", "task-events"} { - if _, err := os.Stat(filepath.Join(out, "data", dir, ".gitkeep")); err != nil { - t.Fatalf("falta data/%s/.gitkeep: %v", dir, err) - } - } - - if _, err := os.Stat(filepath.Join(out, "README.md")); err != nil { - t.Fatalf("falta README.md: %v", err) - } - if _, err := os.Stat(filepath.Join(out, ".gitignore")); err != nil { - t.Fatalf("falta .gitignore: %v", err) - } -} - -func TestBootstrapInstanceScaffoldValidatesClean(t *testing.T) { - base := t.TempDir() - out := filepath.Join(base, "instancia-nueva") - app := newBootstrapApp(t, false) - if err := app.runBootstrapInstance([]string{"--output", out}); err != nil { - t.Fatalf("runBootstrapInstance falló: %v", err) - } - - report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}} - validateApp := &App{} - registry, err := loadAdmins(out) - if err != nil { - t.Fatal(err) - } - validateApp.validateAdmins(out, registry, &report) - pages := validateApp.validatePages(out, registry, &report) - validateApp.validateEvents(out, registry, pages, &report) - if len(report.Errors) != 0 { - t.Fatalf("el scaffold recién creado no debería tener errores de validate: %+v", report.Errors) - } -} - -func TestBootstrapInstanceRefusesNonEmptyOutput(t *testing.T) { - base := t.TempDir() - out := filepath.Join(base, "instancia-existente") - if err := os.MkdirAll(out, 0o755); err != nil { - t.Fatal(err) - } - mustWriteTestFile(t, filepath.Join(out, "algo.txt"), "ya había algo aquí") - - app := newBootstrapApp(t, false) - err := app.runBootstrapInstance([]string{"--output", out}) - if err == nil { - t.Fatal("se esperaba un error por directorio no vacío") - } - cliErr, ok := err.(*CLIError) - if !ok || cliErr.Kind != "output_not_empty" { - t.Fatalf("se esperaba output_not_empty, se obtuvo: %v", err) - } -} - -func TestBootstrapInstanceDryRunWritesNothing(t *testing.T) { - base := t.TempDir() - out := filepath.Join(base, "instancia-dry-run") - app := newBootstrapApp(t, true) - if err := app.runBootstrapInstance([]string{"--output", out}); err != nil { - t.Fatalf("runBootstrapInstance --dry-run falló: %v", err) - } - if _, err := os.Stat(out); err == nil { - t.Fatal("--dry-run no debería haber creado el directorio de salida") - } -} - -func TestBootstrapInstanceRequiresOutput(t *testing.T) { - app := newBootstrapApp(t, false) - err := app.runBootstrapInstance([]string{}) - if err == nil { - t.Fatal("se esperaba un error por --output faltante") - } - cliErr, ok := err.(*CLIError) - if !ok || cliErr.Kind != "missing_output" { - t.Fatalf("se esperaba missing_output, se obtuvo: %v", err) - } -} diff --git a/internal/clusterlog/commands_content.go b/internal/clusterlog/commands_content.go deleted file mode 100644 index 5d9a80d..0000000 --- a/internal/clusterlog/commands_content.go +++ /dev/null @@ -1,227 +0,0 @@ -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 deleted file mode 100644 index 57f2e3e..0000000 --- a/internal/clusterlog/commands_task.go +++ /dev/null @@ -1,527 +0,0 @@ -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 deleted file mode 100644 index 617418a..0000000 --- a/internal/clusterlog/create.go +++ /dev/null @@ -1,434 +0,0 @@ -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 deleted file mode 100644 index 30ab305..0000000 --- a/internal/clusterlog/events.go +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index c142cfd..0000000 --- a/internal/clusterlog/git.go +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 6e441f1..0000000 --- a/internal/clusterlog/identity.go +++ /dev/null @@ -1,226 +0,0 @@ -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 deleted file mode 100644 index d9ea0f8..0000000 --- a/internal/clusterlog/markdown.go +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index 446dabb..0000000 --- a/internal/clusterlog/markdown_test.go +++ /dev/null @@ -1,57 +0,0 @@ -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/runbooks.go b/internal/clusterlog/runbooks.go deleted file mode 100644 index bda0673..0000000 --- a/internal/clusterlog/runbooks.go +++ /dev/null @@ -1,356 +0,0 @@ -package clusterlog - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "regexp" - "sort" - "strings" -) - -// Este archivo valida los runbooks ejecutables (Runme) que viven en -// RUNBOOK.md y runbooks/**/*.md. No ejecuta Runme ni ninguna celda: sólo -// analiza el Markdown como texto para hacer cumplir las reglas de la -// sección 12 del ADR 0004 antes de que alguien abra el documento. -// -// El análisis es un escáner de líneas con estado (no una única expresión -// regular como defensa completa): la regex sólo reconoce la FORMA de una -// línea de fence, y el contenido de atributos siempre se decodifica con -// encoding/json. - -var runmeFenceLine = regexp.MustCompile("^```([A-Za-z0-9_+-]*)\\s*(\\{.*\\})?\\s*$") - -type runmeCell struct { - Path string - Line int - Lang string - Name string - Tags []string - Attrs map[string]any - Body string -} - -func hasTag(tags []string, tag string) bool { - for _, t := range tags { - if t == tag { - return true - } - } - return false -} - -func hasTagPrefix(tags []string, prefix string) bool { - for _, t := range tags { - if strings.HasPrefix(t, prefix) { - return true - } - } - return false -} - -func attrBool(attrs map[string]any, key string) (bool, bool) { - value, ok := attrs[key] - if !ok { - return false, false - } - switch v := value.(type) { - case bool: - return v, true - case string: - return strings.EqualFold(v, "true"), true - default: - return false, true - } -} - -// scanRunmeCells recorre un documento Markdown y devuelve las celdas que -// declaran atributos Runme con al menos un "name". Los fences sin atributos -// (bloques de código normales) se ignoran para efectos de reglas de Runme, -// pero igual se respeta su apertura/cierre para no desalinear el escaneo. -func scanRunmeCells(path string, report *ValidationReport) []runmeCell { - data, err := os.ReadFile(path) - if err != nil { - report.add("error", "runbook_read_failed", path, err.Error(), nil) - return nil - } - cells := []runmeCell{} - lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") - - inFence := false - var current *runmeCell - var body strings.Builder - for i, raw := range lines { - lineNo := i + 1 - trimmed := strings.TrimRight(raw, " \t") - if !inFence { - m := runmeFenceLine.FindStringSubmatch(trimmed) - if m == nil { - continue - } - inFence = true - lang, attrsRaw := m[1], m[2] - if attrsRaw == "" { - current = nil - continue - } - var attrs map[string]any - if jsonErr := json.Unmarshal([]byte(attrsRaw), &attrs); jsonErr != nil { - report.add("error", "runme_cell_invalid_attrs", path, "los atributos de la celda no son JSON válido: "+jsonErr.Error(), map[string]any{"line": lineNo}) - current = nil - continue - } - name, _ := attrs["name"].(string) - if name == "" { - current = nil - continue - } - tagsRaw, _ := attrs["tag"].(string) - tags := []string{} - for _, t := range strings.Split(tagsRaw, ",") { - t = strings.TrimSpace(t) - if t != "" { - tags = append(tags, t) - } - } - current = &runmeCell{Path: path, Line: lineNo, Lang: lang, Name: name, Tags: tags, Attrs: attrs} - body.Reset() - continue - } - // Dentro de un fence: una línea de cierre es ``` sola en la línea. - if trimmed == "```" { - inFence = false - if current != nil { - current.Body = body.String() - cells = append(cells, *current) - current = nil - } - continue - } - if current != nil { - body.WriteString(raw) - body.WriteString("\n") - } - } - return cells -} - -func findRunbookFiles(root string) ([]string, error) { - paths := []string{} - rootRunbook := filepath.Join(root, "RUNBOOK.md") - if info, err := os.Stat(rootRunbook); err == nil && !info.IsDir() { - paths = append(paths, rootRunbook) - } - base := filepath.Join(root, "runbooks") - walkErr := 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" { - return nil - } - paths = append(paths, path) - return nil - }) - if os.IsNotExist(walkErr) { - walkErr = nil - } - sort.Strings(paths) - return paths, walkErr -} - -func makefileTargets(root string) map[string]bool { - targets := map[string]bool{} - data, err := os.ReadFile(filepath.Join(root, "Makefile")) - if err != nil { - return targets - } - scanner := bufio.NewScanner(strings.NewReader(string(data))) - targetLine := regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*:`) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "\t") { - continue - } - if strings.HasPrefix(strings.TrimSpace(line), ".PHONY:") { - for _, name := range strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), ".PHONY:")) { - targets[name] = true - } - continue - } - if m := targetLine.FindStringSubmatch(line); m != nil && !strings.HasPrefix(m[1], ".") { - targets[m[1]] = true - } - } - return targets -} - -var makeInvocation = regexp.MustCompile(`\bmake\s+([A-Za-z0-9_.-]+)`) -var scriptInvocation = regexp.MustCompile(`\.\/(scripts\/[A-Za-z0-9_.-]+\.sh)`) -var infraToolPattern = regexp.MustCompile(`\b(kubectl|terraform|helm)\b`) - -const ( - runmeUnknownAttrWarning = "unknown_runme_attr" -) - -var knownRunmeAttrs = map[string]bool{ - "name": true, "tag": true, "interactive": true, "excludeFromRunAll": true, - "id": true, "background": true, "promptEnv": true, "cwd": true, "category": true, -} - -// validateRunbooks aplica las reglas de la sección 12 del ADR 0004 sobre -// RUNBOOK.md y runbooks/**/*.md, y confirma que ninguna celda de Runme se -// haya colado en content/ (donde Zola sí publica). -func (a *App) validateRunbooks(root string, report *ValidationReport) { - files, err := findRunbookFiles(root) - if err != nil { - report.add("error", "runbook_walk_failed", filepath.Join(root, "runbooks"), err.Error(), nil) - } - - targets := makefileTargets(root) - names := map[string]string{} - var allCells []runmeCell - - for _, path := range files { - report.Checks["runbook_files"]++ - cells := scanRunmeCells(path, report) - allCells = append(allCells, cells...) - if findings := scanSecrets(mustReadFile(path)); len(findings) > 0 { - report.add("error", "possible_secret", path, "el runbook parece incluir secretos", findings) - } - } - - for _, cell := range allCells { - report.Checks["runme_cells"]++ - if prior, ok := names[cell.Name]; ok && prior != cell.Path { - report.add("error", "duplicate_runme_cell_name", cell.Path, "nombre de celda Runme duplicado en el repositorio", map[string]any{"name": cell.Name, "also_in": prior, "line": cell.Line}) - } else { - names[cell.Name] = cell.Path - } - - hasRole := hasTagPrefix(cell.Tags, "role-") - hasEnv := hasTagPrefix(cell.Tags, "env-") - hasRisk := hasTagPrefix(cell.Tags, "risk-") - if !hasRole || !hasEnv || !hasRisk { - missing := []string{} - if !hasRole { - missing = append(missing, "role-*") - } - if !hasEnv { - missing = append(missing, "env-*") - } - if !hasRisk { - missing = append(missing, "risk-*") - } - report.add("error", "runme_cell_missing_tag_category", cell.Path, "a la celda le faltan categorías de etiqueta obligatorias", map[string]any{"name": cell.Name, "missing": missing, "line": cell.Line}) - } - - mutating := hasTag(cell.Tags, "risk-mutating") || hasTag(cell.Tags, "risk-destructive") - ciSafe := hasTag(cell.Tags, "ci-safe") - manualOnly := hasTag(cell.Tags, "manual-only") - - if mutating { - interactive, hasInteractive := attrBool(cell.Attrs, "interactive") - exclude, hasExclude := attrBool(cell.Attrs, "excludeFromRunAll") - if !hasInteractive || !interactive || !hasExclude || !exclude { - report.add("error", "runme_risk_cell_not_isolated", cell.Path, "una celda risk-mutating/risk-destructive debe ser interactive:true y excludeFromRunAll:true", map[string]any{"name": cell.Name, "line": cell.Line}) - } - if !manualOnly { - report.add("error", "runme_risk_cell_not_manual_only", cell.Path, "una celda risk-mutating/risk-destructive debe llevar la etiqueta manual-only", map[string]any{"name": cell.Name, "line": cell.Line}) - } - } - if ciSafe && mutating { - report.add("error", "runme_ci_safe_risk_conflict", cell.Path, "una celda no puede ser ci-safe y risk-mutating/risk-destructive a la vez", map[string]any{"name": cell.Name, "line": cell.Line}) - } - if ciSafe && manualOnly { - report.add("error", "runme_contradictory_tags", cell.Path, "una celda no puede ser ci-safe y manual-only a la vez", map[string]any{"name": cell.Name, "line": cell.Line}) - } - - for key := range cell.Attrs { - if !knownRunmeAttrs[key] { - report.add("warning", runmeUnknownAttrWarning, cell.Path, "atributo de celda Runme no reconocido por esta validación", map[string]any{"name": cell.Name, "attr": key, "line": cell.Line}) - } - } - - for _, m := range makeInvocation.FindAllStringSubmatch(cell.Body, -1) { - if !targets[m[1]] { - report.add("error", "runme_cell_unknown_make_target", cell.Path, "la celda invoca un target de make que no existe", map[string]any{"name": cell.Name, "target": m[1]}) - } - } - for _, m := range scriptInvocation.FindAllStringSubmatch(cell.Body, -1) { - scriptPath := filepath.Join(root, filepath.FromSlash(m[1])) - if _, statErr := os.Stat(scriptPath); statErr != nil { - report.add("error", "runme_cell_unknown_script", cell.Path, "la celda invoca un script que no existe en el repositorio", map[string]any{"name": cell.Name, "script": m[1]}) - } - } - if infraToolPattern.MatchString(cell.Body) { - report.add("warning", "runme_cell_infra_not_present", cell.Path, "la celda referencia una herramienta de infraestructura (kubectl/terraform/helm) que no tiene configuración real en este repositorio", map[string]any{"name": cell.Name}) - } - } - - a.checkNoRunmeCellsInZolaContent(root, report) - a.checkRunmeSessionArtifacts(root, report) -} - -func mustReadFile(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - return string(data) -} - -// checkNoRunmeCellsInZolaContent hace cumplir la decisión de arquitectura: -// las celdas de Runme viven fuera de content/, nunca dentro. Si alguna -// página publicada por Zola trae atributos de celda Runme, es un error. -func (a *App) checkNoRunmeCellsInZolaContent(root string, report *ValidationReport) { - sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"} - for _, section := range sections { - files, err := listMarkdownFiles(root, section) - if err != nil { - continue - } - for _, path := range files { - data, readErr := os.ReadFile(path) - if readErr != nil { - continue - } - for i, line := range strings.Split(string(data), "\n") { - if m := runmeFenceLine.FindStringSubmatch(strings.TrimRight(line, " \t")); m != nil && m[2] != "" { - var attrs map[string]any - if json.Unmarshal([]byte(m[2]), &attrs) == nil { - if _, ok := attrs["name"]; ok { - report.add("error", "runme_cell_in_zola_content", path, "content/ es publicado por Zola; las celdas de Runme deben vivir en runbooks/", map[string]any{"line": i + 1}) - } - } - } - } - } - } -} - -// checkRunmeSessionArtifacts es una defensa best-effort: la documentación -// pública de Runme (docs.runme.dev/usage/auto-save) no fija un nombre de -// archivo exacto para los Session Outputs en v3.17.4, así que en vez de un -// glob amplio en .gitignore (que podría ocultar Markdown legítimo), se -// buscan señales concretas y se documenta la limitación en docs/RUNME.md. -func (a *App) checkRunmeSessionArtifacts(root string, report *ValidationReport) { - runmeStateDir := filepath.Join(root, ".runme") - if info, err := os.Stat(runmeStateDir); err == nil && info.IsDir() { - report.add("error", "runme_state_dir_tracked", runmeStateDir, "existe un directorio .runme en el árbol de trabajo; nunca debe versionarse (estado local de Runme)", nil) - } - _ = filepath.WalkDir(filepath.Join(root, "runbooks"), func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() { - return nil - } - base := strings.ToLower(filepath.Base(path)) - if strings.Contains(base, "output") && base != "_template.md" { - report.add("warning", "possible_runme_session_output", path, "el nombre sugiere un Session Output de Runme capturado; confirme que es contenido fuente y no una salida de ejecución antes de conservarlo", nil) - } - return nil - }) -} diff --git a/internal/clusterlog/runbooks_test.go b/internal/clusterlog/runbooks_test.go deleted file mode 100644 index cbde5d1..0000000 --- a/internal/clusterlog/runbooks_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package clusterlog - -import ( - "os" - "path/filepath" - "testing" -) - -func newRunbookTestRoot(t *testing.T) string { - t.Helper() - root := t.TempDir() - mustWriteTestFile(t, filepath.Join(root, "Makefile"), ".PHONY: build test\nbuild:\n\techo build\ntest:\n\techo test\n") - mustWriteTestFile(t, filepath.Join(root, "scripts", "smoke.sh"), "#!/usr/bin/env bash\necho ok\n") - return root -} - -func runRunbookValidation(t *testing.T, root string) *ValidationReport { - t.Helper() - report := &ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}} - app := &App{} - app.validateRunbooks(root, report) - return report -} - -func hasErrorCode(report *ValidationReport, code string) bool { - for _, issue := range report.Errors { - if issue.Code == code { - return true - } - } - return false -} - -func TestValidateRunbooksAcceptsWellFormedCell(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+ - "make build\n"+ - "```\n") - report := runRunbookValidation(t, root) - if len(report.Errors) != 0 { - t.Fatalf("no se esperaban errores, hay: %+v", report.Errors) - } - if report.Checks["runme_cells"] != 1 { - t.Fatalf("se esperaba contar 1 celda, hay %d", report.Checks["runme_cells"]) - } -} - -func TestValidateRunbooksDetectsDuplicateNames(t *testing.T) { - root := newRunbookTestRoot(t) - cell := "```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\nmake build\n```\n" - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+cell) - mustWriteTestFile(t, filepath.Join(root, "runbooks", "clusterlog-development.md"), "# Dev\n\n"+cell) - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "duplicate_runme_cell_name") { - t.Fatalf("se esperaba duplicate_runme_cell_name, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRequiresTagCategories(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-build\",\"tag\":\"ci-safe\",\"interactive\":\"false\"}\n"+ - "make build\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_cell_missing_tag_category") { - t.Fatalf("se esperaba runme_cell_missing_tag_category, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRequiresIsolationForMutatingCells(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-memory-create\",\"tag\":\"role-operator,env-production,risk-mutating,manual-only\"}\n"+ - "make build\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_risk_cell_not_isolated") { - t.Fatalf("se esperaba runme_risk_cell_not_isolated, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRejectsCiSafeMutatingConflict(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-memory-create\",\"tag\":\"role-operator,env-production,risk-mutating,ci-safe\",\"interactive\":\"true\",\"excludeFromRunAll\":true}\n"+ - "make build\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_ci_safe_risk_conflict") { - t.Fatalf("se esperaba runme_ci_safe_risk_conflict, errores: %+v", report.Errors) - } - if !hasErrorCode(report, "runme_risk_cell_not_manual_only") { - t.Fatalf("se esperaba runme_risk_cell_not_manual_only, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRejectsUnknownMakeTarget(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-deploy\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+ - "make deploy-to-mars\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_cell_unknown_make_target") { - t.Fatalf("se esperaba runme_cell_unknown_make_target, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRejectsUnknownScript(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-preflight\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+ - "./scripts/does-not-exist.sh\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_cell_unknown_script") { - t.Fatalf("se esperaba runme_cell_unknown_script, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksDetectsSecrets(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+ - "```sh {\"name\":\"clusterlog-leak\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+ - "export password: hunter2-super-secret\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "possible_secret") { - t.Fatalf("se esperaba possible_secret, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRejectsRunmeCellsInsideZolaContent(t *testing.T) { - root := newRunbookTestRoot(t) - if err := os.MkdirAll(filepath.Join(root, "content", "manuales"), 0o755); err != nil { - t.Fatal(err) - } - mustWriteTestFile(t, filepath.Join(root, "content", "manuales", "ejemplo.md"), "+++\ntitle = \"x\"\n+++\n\n"+ - "```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\"}\n"+ - "make build\n"+ - "```\n") - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_cell_in_zola_content") { - t.Fatalf("se esperaba runme_cell_in_zola_content, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksRejectsTrackedRunmeStateDir(t *testing.T) { - root := newRunbookTestRoot(t) - mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n") - if err := os.MkdirAll(filepath.Join(root, ".runme"), 0o755); err != nil { - t.Fatal(err) - } - report := runRunbookValidation(t, root) - if !hasErrorCode(report, "runme_state_dir_tracked") { - t.Fatalf("se esperaba runme_state_dir_tracked, errores: %+v", report.Errors) - } -} - -func TestValidateRunbooksNoRunbooksIsNoop(t *testing.T) { - root := t.TempDir() - report := runRunbookValidation(t, root) - if len(report.Errors) != 0 || len(report.Warnings) != 0 { - t.Fatalf("sin runbooks no debería haber hallazgos: %+v / %+v", report.Errors, report.Warnings) - } -} diff --git a/internal/clusterlog/scaffold/README.md.tmpl b/internal/clusterlog/scaffold/README.md.tmpl deleted file mode 100644 index 4f8c702..0000000 --- a/internal/clusterlog/scaffold/README.md.tmpl +++ /dev/null @@ -1,51 +0,0 @@ -# {{TITLE}} - -Este repositorio es una **instancia de clusterlog**: contiene el contenido, -los datos y la configuración de un deployment concreto, no el código del -motor. Se generó con: - -```bash -clusterlog bootstrap-instance --output . --product-name "{{PRODUCT_NAME}}" -``` - -## Qué hay aquí - -- `content/` — documentación, manuales, memorias, proyectos y tareas de esta - instancia. Empieza con las seis secciones vacías (`_index.md` únicamente). -- `data/admins.json`, `config/allowed_signers` — registro de administradores, - vacío hasta el primer `admin add`. -- `zola.toml` — configuración de este deployment (`base_url`, nombre del - producto). Cámbiela antes de publicar. -- `.clusterlog-version` — versión del motor `clusterlog` usada para generar - este esqueleto. Manténgala sincronizada con el binario que use su CI. - -## Primeros pasos - -1. Cambie `base_url` en `zola.toml` por el dominio real de este deployment. -2. Registre al primer administrador (recibe `owner` automáticamente): - - ```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 - ``` - -3. Inicialice Git y firme el primer commit (vea la sección "Registrar al - primer administrador" en la documentación del motor para el detalle - completo de `git config`/`commit -S`). -4. Genere la proyección y valide: - - ```bash - clusterlog --root . sync - clusterlog --root . validate --skip-zola - ``` - -## Pendiente (no incluido por este comando) - -- Plantillas Tera y el tema `zola.386`: todavía se obtienen desde un checkout - del repositorio del motor (`make theme` / `scripts/bootstrap-theme.sh`). - Empaquetarlos en un release descargable del motor es trabajo de una fase - posterior. -- Un workflow de CI para esta instancia (que descargue el binario del motor - por versión fijada) no se generó todavía: el motor aún no publica releases - binarios. Se agrega en cuanto exista ese release. diff --git a/internal/clusterlog/scaffold/content/_index.md.tmpl b/internal/clusterlog/scaffold/content/_index.md.tmpl deleted file mode 100644 index e252dc8..0000000 --- a/internal/clusterlog/scaffold/content/_index.md.tmpl +++ /dev/null @@ -1,14 +0,0 @@ -+++ -title = "{{TITLE}}" -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/internal/clusterlog/scaffold/content/documentacion/_index.md b/internal/clusterlog/scaffold/content/documentacion/_index.md deleted file mode 100644 index a5fe80d..0000000 --- a/internal/clusterlog/scaffold/content/documentacion/_index.md +++ /dev/null @@ -1,9 +0,0 @@ -+++ -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/internal/clusterlog/scaffold/content/manuales/_index.md b/internal/clusterlog/scaffold/content/manuales/_index.md deleted file mode 100644 index db8f5a7..0000000 --- a/internal/clusterlog/scaffold/content/manuales/_index.md +++ /dev/null @@ -1,9 +0,0 @@ -+++ -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/internal/clusterlog/scaffold/content/memorias/_index.md b/internal/clusterlog/scaffold/content/memorias/_index.md deleted file mode 100644 index 0a74653..0000000 --- a/internal/clusterlog/scaffold/content/memorias/_index.md +++ /dev/null @@ -1,10 +0,0 @@ -+++ -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/internal/clusterlog/scaffold/content/proyectos/_index.md b/internal/clusterlog/scaffold/content/proyectos/_index.md deleted file mode 100644 index 0fc4767..0000000 --- a/internal/clusterlog/scaffold/content/proyectos/_index.md +++ /dev/null @@ -1,9 +0,0 @@ -+++ -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/internal/clusterlog/scaffold/content/revision/_index.md b/internal/clusterlog/scaffold/content/revision/_index.md deleted file mode 100644 index 188be64..0000000 --- a/internal/clusterlog/scaffold/content/revision/_index.md +++ /dev/null @@ -1,8 +0,0 @@ -+++ -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/internal/clusterlog/scaffold/content/tareas/_index.md b/internal/clusterlog/scaffold/content/tareas/_index.md deleted file mode 100644 index 7713a02..0000000 --- a/internal/clusterlog/scaffold/content/tareas/_index.md +++ /dev/null @@ -1,9 +0,0 @@ -+++ -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/internal/clusterlog/scaffold/gitignore.tmpl b/internal/clusterlog/scaffold/gitignore.tmpl deleted file mode 100644 index 34c6268..0000000 --- a/internal/clusterlog/scaffold/gitignore.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -/public/ -/bin/ -/themes/zola.386/ -.DS_Store -*.tmp diff --git a/internal/clusterlog/scaffold/zola.toml.tmpl b/internal/clusterlog/scaffold/zola.toml.tmpl deleted file mode 100644 index 70ade15..0000000 --- a/internal/clusterlog/scaffold/zola.toml.tmpl +++ /dev/null @@ -1,53 +0,0 @@ -base_url = "{{BASE_URL}}" -title = "{{TITLE}}" -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 = "vitesse-black" - -[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 = "{{PRODUCT_NAME}}" -product_short_name = "clusterlog" -repository_label = "Repositorio interno" -review_meeting_label = "Revisión semanal" -footer_text = "Fuente auditable: Markdown + eventos JSON + Git" diff --git a/internal/clusterlog/schema.go b/internal/clusterlog/schema.go deleted file mode 100644 index a569e4a..0000000 --- a/internal/clusterlog/schema.go +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index 676179f..0000000 --- a/internal/clusterlog/state.go +++ /dev/null @@ -1,539 +0,0 @@ -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 deleted file mode 100644 index 0146074..0000000 --- a/internal/clusterlog/state_test.go +++ /dev/null @@ -1,193 +0,0 @@ -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 deleted file mode 100644 index 00e93b5..0000000 --- a/internal/clusterlog/store.go +++ /dev/null @@ -1,361 +0,0 @@ -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 deleted file mode 100644 index 6eda170..0000000 --- a/internal/clusterlog/store_test.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index ff22caf..0000000 --- a/internal/clusterlog/test_helpers_test.go +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index a95511d..0000000 --- a/internal/clusterlog/types.go +++ /dev/null @@ -1,382 +0,0 @@ -package clusterlog - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "time" -) - -// Version se puede sobreescribir en tiempo de build con: -// -// -ldflags "-X example.org/bitacora-cluster/internal/clusterlog.Version=v0.2.0" -// -// Ver la variable CLUSTERLOG_VERSION en el Makefile. -var 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 deleted file mode 100644 index a702c8c..0000000 --- a/internal/clusterlog/validate.go +++ /dev/null @@ -1,496 +0,0 @@ -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) - a.validateRunbooks(root, &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/runbooks/README.md b/runbooks/README.md index 00e27b3..63cf0c7 100644 --- a/runbooks/README.md +++ b/runbooks/README.md @@ -22,9 +22,10 @@ arriesgar el front matter TOML ni el resaltado de sintaxis que sí usa ## Convención de nombres `kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo -dentro de un archivo): `clusterlog-build`, `clusterlog-validate-local`, -`clusterlog-review-mine`. `clusterlog validate` (ver -`internal/clusterlog/runbooks.go`) falla si dos celdas repiten un nombre. +dentro de un archivo): `clusterlog-sync-check`, `clusterlog-validate-local`, +`weekly-review-mine`. `clusterlog validate` (código en +[`internal/clusterlog/runbooks.go`](https://github.com/fenandosr/clusterlog/blob/main/internal/clusterlog/runbooks.go) +del motor) falla si dos celdas repiten un nombre. ## Taxonomía de etiquetas @@ -44,7 +45,16 @@ cumplir esto — no es sólo una convención de estilo. ## Qué no va en una celda -Ver la sección "Runme no contiene lógica de negocio" del ADR -[`docs/adr/0004-runme-sidecar.md`](../docs/adr/0004-runme-sidecar.md). En -resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente; no -reimplementa validación, autorización, rollback ni gestión de secretos. +Ver ADR-0004 y ADR-0005 en el +[repositorio del motor](https://github.com/fenandosr/clusterlog/tree/main/docs/adr). +En resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente; +no reimplementa validación, autorización, rollback ni gestión de secretos. + +## Esta es una instancia, no el motor + +Esta carpeta y `RUNBOOK.md` en la raíz operan sobre **datos reales de este +deployment** (`content/`, `data/`, `config/`). El desarrollo del propio +`clusterlog` (compilar, probar, `bootstrap-instance`) vive en +[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); su +`.clusterlog-version` en la raíz de este repositorio fija qué versión del +motor usa esta instancia. diff --git a/runbooks/clusterlog-development.md b/runbooks/clusterlog-development.md deleted file mode 100644 index fde85f5..0000000 --- a/runbooks/clusterlog-development.md +++ /dev/null @@ -1,89 +0,0 @@ -# Ciclo de desarrollo de clusterlog - -Propósito: correr el mismo ciclo que `AGENTS.md` pide "antes de entregar", -como notebook en vez de copiar/pegar bloques sueltos. Todas las celdas de -este documento envuelven targets existentes de `Makefile`; ninguna reimplementa -lo que ya hace `go test`, `go vet` o `clusterlog`. - -## Prerrequisitos - -- Go 1.23+, Git con firmas SSH. -- Para las celdas con Zola: binario `zola` en `PATH` (opcional; las celdas de - `validate`/`sync` no lo requieren). - -## Formato - -```sh {"name":"clusterlog-gofmt-check","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -test -z "$(gofmt -l cmd internal)" -``` - -## Pruebas y análisis estático - -```sh {"name":"clusterlog-vet","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -go vet ./... -``` - -```sh {"name":"clusterlog-test-race","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -go test -race ./... -``` - -## Compilar y proyectar - -```sh {"name":"clusterlog-dev-build","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -make build -``` - -```sh {"name":"clusterlog-dev-sync","tag":"role-developer,env-local,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true} -# Escribe data/generated/site_state.json. Revise el diff antes de confirmar -# cualquier commit que incluya ese archivo regenerado. -make sync -git diff --stat -- data/generated/site_state.json -``` - -### Prerrequisitos de `clusterlog-dev-sync` - -- Haber corrido `clusterlog-dev-build` en esta sesión (o tener `bin/clusterlog` - ya compilado). - -### Validación posterior - -```sh {"name":"clusterlog-dev-sync-check","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -make sync-check -``` - -### Rollback - -`data/generated/site_state.json` es 100% regenerable desde el contenido -fuente (`content/`, `data/admins.json`, `data/*-events/`). Si `make sync` -produjo un diff no deseado, descarte el archivo con -`git checkout -- data/generated/site_state.json` y no lo incluya en el commit. - -## Validar - -```sh {"name":"clusterlog-dev-validate-local","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -make validate-local -``` - -## Flujo completo local (equivalente a CI, sin Zola) - -```sh {"name":"clusterlog-dev-smoke","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"} -make build -./bin/clusterlog --root . sync --check -./bin/clusterlog --root . validate --skip-zola -./scripts/smoke.sh -``` - -## Registro en Clusterlog - -Ninguna celda de este runbook crea memorias, tareas ni commits. Si el -trabajo de la sesión amerita una memoria, hágalo con el flujo normal descrito -en `AGENTS.md` (`clusterlog memory create --from-json`, con `--dry-run` antes -de `--commit`) — fuera de Runme, con su propia llave. - -## Limitaciones - -- `clusterlog-dev-sync` está marcado `manual-only` a propósito: regenera un - archivo versionado y su diff debe revisarse antes de confirmarlo. -- Este runbook no incluye `make site`/`zola build`: ver - [`RUNBOOK.md`](../RUNBOOK.md#comprobaciones-rápidas) para por qué esa celda - vive ahí y no se duplica aquí. diff --git a/scripts/e2e-local.sh b/scripts/e2e-local.sh deleted file mode 100755 index 5064703..0000000 --- a/scripts/e2e-local.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CLI="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}" - -for command in jq mktemp cp find; do - if ! command -v "$command" >/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 manuales documentacion; 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/fetch-engine.sh b/scripts/fetch-engine.sh new file mode 100755 index 0000000..d9ac180 --- /dev/null +++ b/scripts/fetch-engine.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Descarga un artefacto del release fijado del motor (fenandosr/clusterlog) +# y verifica su checksum contra checksums.txt del mismo release. Nunca usa +# `latest`: la versión viene de .clusterlog-version. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENGINE_REPO="${CLUSTERLOG_ENGINE_REPO:-fenandosr/clusterlog}" +VERSION="$(cat "$ROOT/.clusterlog-version")" +KIND="${1:?uso: fetch-engine.sh }" +DEST="${2:?uso: fetch-engine.sh }" + +BASE_URL="https://github.com/${ENGINE_REPO}/releases/download/${VERSION}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +case "$KIND" in + bin) + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac + asset="clusterlog_${os}_${arch}.tar.gz" + ;; + templates) + asset="clusterlog-templates_${VERSION}.tar.gz" + ;; + *) + echo "Tipo de artefacto desconocido: $KIND (use bin o templates)" >&2 + exit 1 + ;; +esac + +echo "Descargando $asset (clusterlog $VERSION, $ENGINE_REPO)..." +curl -fsSL -o "$TMP/checksums.txt" "$BASE_URL/checksums.txt" +curl -fsSL -o "$TMP/$asset" "$BASE_URL/$asset" + +expected="$(grep " $asset\$" "$TMP/checksums.txt" | awk '{print $1}')" +if [ -z "$expected" ]; then + echo "ERROR: $asset no aparece en checksums.txt del release $VERSION" >&2 + exit 1 +fi +computed="$(sha256sum "$TMP/$asset" | awk '{print $1}')" +if [ "$computed" != "$expected" ]; then + echo "ERROR: checksum no coincide para $asset" >&2 + echo " esperado: $expected" >&2 + echo " obtenido: $computed" >&2 + exit 1 +fi +echo "Checksum verificado." + +case "$KIND" in + bin) + mkdir -p "$(dirname "$DEST")" + tar xzf "$TMP/$asset" -C "$TMP" clusterlog + install -m 0755 "$TMP/clusterlog" "$DEST" + echo "Binario en $DEST ($("$DEST" version 2>/dev/null || true))" + ;; + templates) + rm -rf "$DEST" + mkdir -p "$DEST" + tar xzf "$TMP/$asset" -C "$DEST" + echo "Plantillas en $DEST" + ;; +esac diff --git a/templates/404.html b/templates/404.html deleted file mode 100644 index 75322bf..0000000 --- a/templates/404.html +++ /dev/null @@ -1,3 +0,0 @@ -{% 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 deleted file mode 100644 index de39bf7..0000000 --- a/templates/base.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - {% 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 deleted file mode 100644 index e3bdb72..0000000 --- a/templates/index.html +++ /dev/null @@ -1,44 +0,0 @@ -{% 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 deleted file mode 100644 index 1db8d61..0000000 --- a/templates/memories.html +++ /dev/null @@ -1,19 +0,0 @@ -{% 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 deleted file mode 100644 index 6d86771..0000000 --- a/templates/memory.html +++ /dev/null @@ -1,24 +0,0 @@ -{% 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 deleted file mode 100644 index 5752974..0000000 --- a/templates/page.html +++ /dev/null @@ -1,15 +0,0 @@ -{% 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 deleted file mode 100644 index 172fdd8..0000000 --- a/templates/project.html +++ /dev/null @@ -1,10 +0,0 @@ -{% 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 deleted file mode 100644 index 3de13db..0000000 --- a/templates/projects.html +++ /dev/null @@ -1,16 +0,0 @@ -{% 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 deleted file mode 100644 index dd952bf..0000000 --- a/templates/review.html +++ /dev/null @@ -1,65 +0,0 @@ -{% 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 deleted file mode 100644 index 7a83868..0000000 --- a/templates/section.html +++ /dev/null @@ -1,22 +0,0 @@ -{% 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 deleted file mode 100644 index acf87ce..0000000 --- a/templates/task.html +++ /dev/null @@ -1,14 +0,0 @@ -{% 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 deleted file mode 100644 index a92a8cf..0000000 --- a/templates/tasks.html +++ /dev/null @@ -1,18 +0,0 @@ -{% 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 deleted file mode 100644 index 0551c69..0000000 --- a/templates/taxonomy_list.html +++ /dev/null @@ -1,8 +0,0 @@ -{% 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 deleted file mode 100644 index 87d8322..0000000 --- a/templates/taxonomy_single.html +++ /dev/null @@ -1,6 +0,0 @@ -{% 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 %}