refactor: convertir este repositorio en instancia de clusterlog
CI / validate-and-build (push) Failing after 7s
CI / runme (push) Failing after 5s

Retira el código del motor (cmd/, internal/, go.mod, templates/,
examples/, scripts de desarrollo Go) — vive ahora en
https://github.com/fenandosr/clusterlog (release v0.1.0). Este
repositorio conserva únicamente content/, data/, config/ y zola.toml:
los datos reales de esta instancia (MCPS).

Se agrega .clusterlog-version (fija v0.1.0) y scripts/fetch-engine.sh,
que descarga el binario y las plantillas del motor por versión fijada
verificando su checksum contra el release. Makefile y
.gitea/workflows/ci.yml se adaptan para descargar en vez de compilar.
RUNBOOK.md y runbooks/ se recortan a las celdas que sí operan sobre
datos reales de esta instancia (sync-check, validate-local, site-build,
review-mine, verify-head); el ciclo de desarrollo del motor mismo
(build/test/vet) vive en su propio repositorio.

Se preserva toda la historia existente: éste es un commit nuevo, no una
reescritura. Ver docs/adr/0005-motor-instancia.md en el repositorio del
motor para el contexto completo de la separación.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:40:26 -06:00
co-authored by Claude Sonnet 5
parent 5240cfd8c2
commit eac5e22ed1
81 changed files with 186 additions and 8411 deletions
+1
View File
@@ -0,0 +1 @@
v0.1.0
-18
View File
@@ -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
+6 -29
View File
@@ -8,18 +8,13 @@ permissions:
contents: read contents: read
jobs: jobs:
test-and-build: validate-and-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Instalar Zola fijado - name: Instalar Zola fijado
run: | run: |
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
@@ -31,23 +26,10 @@ jobs:
- name: Preparar tema fijado - name: Preparar tema fijado
run: ./scripts/bootstrap-theme.sh run: ./scripts/bootstrap-theme.sh
- name: Formato Go - name: Descargar plantillas y binario del motor (fenandosr/clusterlog, versión fijada en .clusterlog-version)
run: test -z "$(gofmt -l cmd internal)" run: |
make templates
- name: Pruebas Go make build
run: go test ./...
- name: Detector de carreras Go
run: go test -race ./...
- name: Análisis estático Go
run: go vet ./...
- name: Compilar CLI
run: make build
- name: Flujo integral de la CLI
run: ./scripts/e2e-local.sh
- name: Verificar firmas SSH de commits del pull request - name: Verificar firmas SSH de commits del pull request
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@@ -87,12 +69,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - name: Descargar binario del motor (versión fijada en .clusterlog-version)
with:
go-version-file: go.mod
cache: true
- name: Compilar CLI
run: make build run: make build
- name: Instalar Runme fijado (v3.17.4, checksum verificado) - name: Instalar Runme fijado (v3.17.4, checksum verificado)
+5 -3
View File
@@ -1,6 +1,7 @@
/memorias/ /memorias/
/public/ /public/
/bin/ /bin/
/templates/
/themes/zola.386/ /themes/zola.386/
.DS_Store .DS_Store
*.tmp *.tmp
@@ -12,6 +13,7 @@
.direnv/ .direnv/
# No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver # 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 # docs/RUNME.md en el repositorio del motor): un glob amplio aquí podría
# accidente. clusterlog validate detecta señales concretas en su lugar # ocultar Markdown legítimo por accidente. clusterlog validate detecta
# (directorio .runme/ versionado, archivos "*output*" dentro de runbooks/). # señales concretas en su lugar (directorio .runme/ versionado, archivos
# "*output*" dentro de runbooks/).
+8 -6
View File
@@ -89,15 +89,16 @@ segunda CLI. Reglas para un agente:
- No genere ni edite manualmente un identificador Lifecycle Identity de - No genere ni edite manualmente un identificador Lifecycle Identity de
Runme. 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 ```bash
gofmt -w cmd internal
go test ./...
go vet ./...
make build make build
make templates
./bin/clusterlog --root . sync ./bin/clusterlog --root . sync
./bin/clusterlog --root . sync --check ./bin/clusterlog --root . sync --check
./bin/clusterlog --root . validate --skip-zola ./bin/clusterlog --root . validate --skip-zola
@@ -111,4 +112,5 @@ Cuando Zola esté disponible:
zola build 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í.
-12
View File
@@ -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`.
-407
View File
@@ -1,407 +0,0 @@
# Referencia de `clusterlog`
## 1. Forma general
```text
clusterlog [opciones globales] <comando> [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/<sección>/_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.
-484
View File
@@ -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/<evento>.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 <sha>` 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.
-21
View File
@@ -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.
+13 -19
View File
@@ -1,30 +1,24 @@
SHELL := /usr/bin/env bash SHELL := /usr/bin/env bash
GO ?= go
ZOLA ?= zola ZOLA ?= zola
BINARY ?= bin/clusterlog 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 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: build:
mkdir -p $(dir $(BINARY)) ./scripts/fetch-engine.sh bin $(BINARY)
$(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/clusterlog
test:
$(GO) test ./...
vet:
$(GO) vet ./...
e2e: build
./scripts/e2e-local.sh
theme: theme:
./scripts/bootstrap-theme.sh ./scripts/bootstrap-theme.sh
templates:
./scripts/fetch-engine.sh templates templates
sync: build sync: build
./$(BINARY) --root . sync ./$(BINARY) --root . sync
@@ -37,20 +31,20 @@ validate: build
validate-local: build validate-local: build
./$(BINARY) --root . validate --skip-zola ./$(BINARY) --root . validate --skip-zola
site: theme sync validate site: theme templates sync validate
$(ZOLA) build $(ZOLA) build
serve: theme sync serve: theme templates sync
$(ZOLA) serve --interface 127.0.0.1 $(ZOLA) serve --interface 127.0.0.1
smoke: build smoke: build
./scripts/smoke.sh ./scripts/smoke.sh
ci: theme test vet e2e build sync sync-check validate ci: theme templates build sync sync-check validate
$(ZOLA) build $(ZOLA) build
clean: clean:
rm -rf bin public themes/zola.386 rm -rf bin public themes/zola.386 templates
runme-install: runme-install:
./scripts/runme-install.sh ./scripts/runme-install.sh
+45 -72
View File
@@ -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: La solución separa responsabilidades:
@@ -13,7 +23,7 @@ administrador o agente IA
| ---- escribe Markdown/eventos JSON | ---- escribe Markdown/eventos JSON
| ---- opcionalmente crea commit SSH firmado | ---- opcionalmente crea commit SSH firmado
v v
repositorio Git ---- fuente de verdad y auditoría repositorio Git ---- fuente de verdad y auditoría (esta instancia)
| |
v v
clusterlog sync ---- genera data/generated/site_state.json 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 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**. - 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. - 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. - 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`. - 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. - 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`. - 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 ## Requisitos
- Go 1.23 o posterior.
- Git con soporte de firmas SSH. - Git con soporte de firmas SSH.
- OpenSSH/`ssh-keygen` para firmar y verificar commits. - OpenSSH/`ssh-keygen` para firmar y verificar commits.
- Zola 0.23.3 para construir el sitio. - 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 ## Inicio rápido
```bash ```bash
make theme make theme # tema zola.386 (repositorio externo, pinneado)
make build make templates # plantillas del motor, versión fijada
make build # binario clusterlog, versión fijada
./bin/clusterlog --root . sync ./bin/clusterlog --root . sync
./bin/clusterlog --root . validate --skip-zola ./bin/clusterlog --root . validate --skip-zola
zola serve 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: Sólo un `owner` activo puede hacerlo (ver `content/manuales/primer-administrador.md` para el procedimiento completo, incluido el alta del segundo administrador).
```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.
```bash ```bash
./bin/clusterlog \ ./bin/clusterlog \
--root . \ --root . \
--ssh-key ~/.ssh/id_ed25519.pub \ --ssh-key ~/.ssh/id_ed25519.pub \
--commit \
admin add \ admin add \
--id ana \ --id ana \
--name "Ana Torres" \ --name "Ana Torres" \
--email ana@example.org \ --email ana@example.org \
--key ~/.ssh/id_ed25519.pub \ --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 ## 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. 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 ## 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 ```bash
# Ver sólo las memorias que le faltan al administrador actual # Ver sólo las memorias que le faltan al administrador actual
./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine ./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 ## Comandos de calidad
```bash ```bash
make test # pruebas unitarias e integración de la CLI make sync-check # comprueba que la proyección está actualizada
make vet # análisis estático de Go make validate-local # valida sin requerir Zola
make e2e # flujo completo en una copia temporal make site # tema + plantillas + estado + validación completa + sitio
make smoke # contratos, sincronización y validación local 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 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 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 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 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, bloque de shell normal que sigue funcionando copiado a mano. Modelo de
modelo de identidad y limitaciones conocidas en identidad, limitaciones y la decisión completa viven en el motor:
[`docs/RUNME.md`](docs/RUNME.md) y la decisión en [`docs/RUNME.md`](https://github.com/fenandosr/clusterlog/blob/main/docs/RUNME.md)
[`docs/adr/0004-runme-sidecar.md`](docs/adr/0004-runme-sidecar.md). y
[ADR-0004](https://github.com/fenandosr/clusterlog/blob/main/docs/adr/0004-runme-sidecar.md).
## Estructura ## Estructura
```text ```text
cmd/clusterlog/ punto de entrada de la CLI content/ Markdown fuente por sección (memorias, manuales, ...)
internal/clusterlog/ modelo, comandos, validación y proyección
content/ Markdown fuente por sección
data/admins.json registro de administradores y política data/admins.json registro de administradores y política
data/review-events/ un archivo por revisión data/review-events/ un archivo por revisión
data/task-events/ un archivo por evento de tiempo/estado data/task-events/ un archivo por evento de tiempo/estado
data/generated/ proyección regenerable para Zola data/generated/ proyección regenerable para Zola
templates/ plantillas locales compatibles con Zola actual config/allowed_signers firmantes SSH autorizados (generado junto a admins.json)
static/ ajustes visuales y búsqueda templates/, themes/ descargados por make templates/make theme — no versionados
scripts/ preparación del tema y smoke tests static/ ajustes visuales y búsqueda propios de esta instancia
docs/ decisiones arquitectónicas y operación 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 ## 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. - [`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/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. - [`content/manuales/`](content/manuales/): procedimientos operativos de esta instancia (acceso SSH, correo, Caddy, alta de administradores...).
- [`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). - Documentación del motor (CLI, esquemas, ADRs, `bootstrap-instance`, Runme): [fenandosr/clusterlog](https://github.com/fenandosr/clusterlog).
- [`VALIDATION_REPORT.md`](VALIDATION_REPORT.md): pruebas ejecutadas, límites y controles pendientes.
- [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0.
## Estado del prototipo ## 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`.
-49
View File
@@ -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`.
+22 -45
View File
@@ -1,9 +1,11 @@
# RUNBOOK # RUNBOOK
Punto de entrada de la documentación ejecutable de `clusterlog`. Cada celda de Punto de entrada de la documentación ejecutable de esta **instancia** de
abajo envuelve un comando canónico existente (`make`, `./bin/clusterlog`, un `clusterlog` (MCPS). Cada celda envuelve un comando canónico existente
script en `scripts/`) — ninguna celda contiene lógica propia. Ver (`make`, `./bin/clusterlog`, un script en `scripts/`) — ninguna celda
[`docs/RUNME.md`](docs/RUNME.md) para el porqué y el modelo de identidad, y 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 [`runbooks/README.md`](runbooks/README.md) para la convención completa de
nombres y etiquetas. nombres y etiquetas.
@@ -12,45 +14,35 @@ comando de shell normal que también puede copiarse y ejecutarse a mano.
## Runbooks disponibles ## 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/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. - [`runbooks/_template.md`](runbooks/_template.md) — plantilla para runbooks nuevos.
## Comprobaciones rápidas ## 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 set -euo pipefail
test -f go.mod && test -f Makefile || { echo "ejecute esto desde la raíz de clusterlog" >&2; exit 1; } test -f zola.toml && test -f .clusterlog-version || { echo "ejecute esto desde la raíz de la instancia" >&2; exit 1; }
echo "go: $(go version)" echo "clusterlog-version: $(cat .clusterlog-version)"
echo "git: $(git --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 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 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"} ```sh {"name":"clusterlog-sync-check","tag":"role-operator,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"}
make sync-check 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 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 make site
``` ```
`clusterlog-site-build` no lleva `ci-safe`: `make site` incluye `make theme`, `clusterlog-site-build` no lleva `ci-safe`: descarga el tema y las plantillas
que clona el repositorio del tema por red. La CI principal del motor por red (`make theme templates`). La CI (`.gitea/workflows/ci.yml`)
(`.gitea/workflows/ci.yml`) ya construye el sitio en su propio paso; repetirlo ya construye el sitio en su propio paso; repetirlo aquí sería una segunda
aquí sería una segunda copia de la misma lógica, no una celda adicional útil. 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"} ```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}" : "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY con la ruta a su llave pública}"
@@ -67,29 +59,14 @@ ninguna llave privada real.
## Ejecutar ## 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 ```text
runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts
``` ```
`runme run` sí acepta `--tag`/`--all` (verificado con el binario real de es lo que corre `make runme-ci`: exactamente las celdas marcadas `ci-safe`,
`v3.17.4`: un tag inexistente devuelve `No tasks to execute with the tag en orden, deteniéndose en el primer fallo (verificado con Runme real; ver el
provided`, y `--tag ci-safe --all` corre exactamente las celdas marcadas ADR-0004 del motor). Nunca ejecute `runme run --all --skip-prompts` **sin**
`ci-safe`, en orden, deteniéndose en el primer fallo). Como es un filtro `--tag ci-safe` sobre este archivo ni sobre `runbooks/weekly-review.md`: sin
explícito por etiqueta de seguridad — no "correr todo" — este uso puntual sí ese filtro, `--all` también incluiría celdas que mutan estado real de esta
es seguro. instancia. Ésas deben correrse una por una, a mano, después de revisar sus
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
prerrequisitos. prerrequisitos.
+3 -2
View File
@@ -124,8 +124,9 @@ Ante contenido sensible o firma cuestionable:
## Documentación ejecutable (Runme) ## Documentación ejecutable (Runme)
`RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como `RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como
notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004). Esto no cambia el notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004 en el
modelo de confianza: se agregan las siguientes reglas. [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 - 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 local con los permisos del usuario que la corre. No hay una elevación ni
-1
View File
@@ -1 +0,0 @@
76b832471eead59133fbfffe63dd5e5ef1ec708a2e4c4b9b864d665350616ad5 bin/clusterlog
-80
View File
@@ -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=<commit> ./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.
-104
View File
@@ -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
```
-1
View File
@@ -1 +0,0 @@
0.1.0
-34
View File
@@ -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] <comando> [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))
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"version": 2, "version": 2,
"generated_at": "2026-08-25T01:01:15Z", "generated_at": "2026-08-27T06:37:41Z",
"source_sha256": "24c5629b3f812fee92bb28270c4c245f3c4735eef59c8cdadb91d05aeda0813d", "source_sha256": "594001fb9ffeca21d3c081be211f5249968d3f43e857530f45c8d69e25381aec",
"stats": { "stats": {
"active_admins": 1, "active_admins": 1,
"pending_reviews": 7, "pending_reviews": 7,
-113
View File
@@ -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.
-212
View File
@@ -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 <nombre>` 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).
-41
View File
@@ -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.
-35
View File
@@ -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.
-31
View File
@@ -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.
-216
View File
@@ -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.
-9
View File
@@ -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"
}
-26
View File
@@ -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"
}
-27
View File
@@ -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."
]
}
-18
View File
@@ -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."
}
-3
View File
@@ -1,3 +0,0 @@
module example.org/bitacora-cluster
go 1.23
@@ -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)
}
}
-432
View File
@@ -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] <comando>
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])
}
}
-182
View File
@@ -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 <su-llave.pub> admin add --id <id> --name <nombre> --email <correo> --key <su-llave.pub> --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
}
@@ -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, &registry); 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)
}
}
-227
View File
@@ -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})
}
-527
View File
@@ -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})
}
-434
View File
@@ -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
}
-114
View File
@@ -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)
})
}
-88
View File
@@ -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
}
-226
View File
@@ -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"), &registry); 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
}
-209
View File
@@ -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
}
-57
View File
@@ -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")
}
}
-356
View File
@@ -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
})
}
-168
View File
@@ -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)
}
}
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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**.
@@ -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.
@@ -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.
@@ -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.
@@ -1,5 +0,0 @@
/public/
/bin/
/themes/zola.386/
.DS_Store
*.tmp
@@ -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"
-164
View File
@@ -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)
}
-539
View File
@@ -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
}
-193
View File
@@ -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)
}
}
-361
View File
@@ -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
}
-58
View File
@@ -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)
}
}
-78
View File
@@ -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"}},
}
}
-382
View File
@@ -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)
}
-496
View File
@@ -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
})
}
+17 -7
View File
@@ -22,9 +22,10 @@ arriesgar el front matter TOML ni el resaltado de sintaxis que sí usa
## Convención de nombres ## Convención de nombres
`kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo `kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo
dentro de un archivo): `clusterlog-build`, `clusterlog-validate-local`, dentro de un archivo): `clusterlog-sync-check`, `clusterlog-validate-local`,
`clusterlog-review-mine`. `clusterlog validate` (ver `weekly-review-mine`. `clusterlog validate` (código en
`internal/clusterlog/runbooks.go`) falla si dos celdas repiten un nombre. [`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 ## 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 ## Qué no va en una celda
Ver la sección "Runme no contiene lógica de negocio" del ADR Ver ADR-0004 y ADR-0005 en el
[`docs/adr/0004-runme-sidecar.md`](../docs/adr/0004-runme-sidecar.md). En [repositorio del motor](https://github.com/fenandosr/clusterlog/tree/main/docs/adr).
resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente; no En resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente;
reimplementa validación, autorización, rollback ni gestión de secretos. 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.
-89
View File
@@ -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í.
-166
View File
@@ -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" <<JSON
{
"title": "Migrar alertas de nodos",
"description": "Mover reglas al repositorio estándar",
"project_id": "$project_id",
"priority": "high",
"assignees": ["bob"],
"estimate_minutes": 60,
"due": "2099-11-01",
"tags": ["alertas"],
"systems": ["prometheus"],
"objective": "Centralizar reglas.",
"acceptance_criteria": ["Reglas validadas"],
"notes": "Sin cambios productivos."
}
JSON
task=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
task create --from-json "$TMP/task.json")
task_id=$(jq -er '.data.task_id' <<<"$task")
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
task start "$task_id" --note 'Inicio' >/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"
+64
View File
@@ -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 <bin|templates> <destino>}"
DEST="${2:?uso: fetch-engine.sh <bin|templates> <destino>}"
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
-3
View File
@@ -1,3 +0,0 @@
{% extends "base.html" %}
{% block title %}404 · {{ config.title }}{% endblock title %}
{% block content %}<section class="ops-hero"><p class="ops-eyebrow">ERROR 404</p><h1>Ruta no encontrada</h1><p>La referencia no existe en la proyección actual de la bitácora.</p><p><a href="{{ get_url(path='@/_index.md') }}">Volver al inicio</a></p></section>{% endblock content %}
-49
View File
@@ -1,49 +0,0 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% block description %}{{ config.description }}{% endblock description %}">
<meta name="robots" content="noindex,nofollow,noarchive">
<title>{% block title %}{{ config.title }}{% endblock title %}</title>
<link rel="stylesheet" href="{{ get_url(path='site.css') }}">
<link rel="stylesheet" href="{{ get_url(path='css/ops.css') }}">
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ get_url(path='atom.xml') }}">
</head>
<body>
{% set ops = load_data(path="data/generated/site_state.json", format="json") %}
<header class="ops-header">
<div class="container ops-header__inner">
<a class="ops-brand" href="{{ get_url(path='@/_index.md') }}">{{ config.extra.product_name }}</a>
<span class="ops-prompt" aria-hidden="true">root@cluster:~#</span>
<nav class="ops-nav" aria-label="Navegación principal">
<a href="{{ get_url(path='@/documentacion/_index.md') }}">Documentación</a>
<a href="{{ get_url(path='@/manuales/_index.md') }}">Manuales</a>
<a href="{{ get_url(path='@/memorias/_index.md') }}">Memorias</a>
<a href="{{ get_url(path='@/proyectos/_index.md') }}">Proyectos</a>
<a href="{{ get_url(path='@/tareas/_index.md') }}">Tareas</a>
<a class="ops-nav__review" href="{{ get_url(path='@/revision/_index.md') }}">Revisión <span>{{ ops.stats.pending_reviews }}</span></a>
</nav>
</div>
</header>
<div class="container ops-search" role="search">
<label for="ops-search-input">Buscar en la bitácora</label>
<div class="ops-search__row">
<input id="ops-search-input" type="search" autocomplete="off" data-index-url="{{ get_url(path='search_index.es.json') }}" placeholder="título, sistema, comando, incidente…">
<kbd>/</kbd>
</div>
<div id="ops-search-results" class="ops-search-results" hidden></div>
</div>
<main class="container ops-main">
{% block content %}{% endblock content %}
</main>
<footer class="container ops-footer">
<span>{{ config.extra.footer_text }}</span>
<span>CLI: <code>{{ config.extra.product_short_name }}</code></span>
</footer>
<script defer src="{{ get_url(path='js/ops-search.js') }}"></script>
</body>
</html>
-44
View File
@@ -1,44 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<section class="ops-hero">
<p class="ops-eyebrow">OPERACIONES / CONOCIMIENTO / AUDITORÍA</p>
<h1>{{ section.title }}</h1>
<div class="ops-lead">{{ section.content | safe }}</div>
</section>
<section class="ops-stats" aria-label="Estado actual">
<a class="ops-stat" href="{{ get_url(path='@/revision/_index.md') }}"><strong>{{ ops.stats.pending_reviews }}</strong><span>revisiones pendientes</span></a>
<a class="ops-stat" href="{{ get_url(path='@/tareas/_index.md') }}"><strong>{{ ops.stats.open_tasks }}</strong><span>tareas abiertas</span></a>
<a class="ops-stat" href="{{ get_url(path='@/proyectos/_index.md') }}"><strong>{{ ops.stats.active_projects }}</strong><span>proyectos activos</span></a>
<div class="ops-stat"><strong>{{ ops.stats.active_admins }}</strong><span>administradores activos</span></div>
</section>
<section class="ops-grid ops-grid--sections">
<a class="ops-panel" href="{{ get_url(path='@/documentacion/_index.md') }}"><span class="ops-panel__code">DOC</span><h2>Documentación</h2><p>Arquitectura y decisiones duraderas.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/manuales/_index.md') }}"><span class="ops-panel__code">RUN</span><h2>Manuales</h2><p>Procedimientos repetibles y reversibles.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/memorias/_index.md') }}"><span class="ops-panel__code">MEM</span><h2>Memorias</h2><p>Historia de operaciones y cambios.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/proyectos/_index.md') }}"><span class="ops-panel__code">PRJ</span><h2>Proyectos</h2><p>Objetivos, alcance e hitos.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/tareas/_index.md') }}"><span class="ops-panel__code">TSK</span><h2>Tareas</h2><p>Trabajo visible y tiempo registrado.</p></a>
<a class="ops-panel ops-panel--attention" href="{{ get_url(path='@/revision/_index.md') }}"><span class="ops-panel__code">REV</span><h2>Revisión semanal</h2><p>La agenda común del equipo.</p></a>
</section>
<section class="ops-section-heading">
<div><p class="ops-eyebrow">ACTIVIDAD</p><h2>Memorias recientes</h2></div>
<a href="{{ get_url(path='@/memorias/_index.md') }}">Ver todas →</a>
</section>
<div class="ops-list">
{% for item in ops.recent_memories %}
{% if loop.index <= 6 %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ item.entry_id }}</code><span class="badge badge--{{ item.risk }}">{{ item.risk }}</span><span>{{ item.date }}</span></div>
<h3><a href="{{ item.permalink }}">{{ item.title }}</a></h3>
<p>{{ item.description }}</p>
<div class="ops-tags">{% for tag in item.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
</article>
{% endif %}
{% endfor %}
{% if ops.recent_memories | length == 0 %}<p class="ops-empty">Todavía no hay memorias operativas.</p>{% endif %}
</div>
{% endblock content %}
-19
View File
@@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header">
<p class="ops-eyebrow">HISTORIA TÉCNICA</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p>
<div class="ops-prose">{{ section.content | safe }}</div>
</header>
<div class="ops-list">
{% for page in section.pages %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span class="badge badge--{{ page.extra.risk }}">{{ page.extra.risk }}</span><span>{{ page.date }}</span><span>por {{ page.extra.author_id }}</span></div>
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
<p>{{ page.description }}</p>
<div class="ops-tags">{% for system in page.taxonomies.systems %}<span>@{{ system }}</span>{% endfor %}{% for tag in page.taxonomies.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
</article>
{% endfor %}
</div>
{% endblock content %}
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header">
<p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / MEMORIA OPERATIVA</p>
<h1>{{ page.title }}</h1><p>{{ page.description }}</p>
<div class="ops-meta">
<span>Autor: <strong>{{ page.extra.author_id }}</strong></span>
<span>Fecha: {{ page.date }}</span>
<span>Riesgo: <strong class="badge badge--{{ page.extra.risk }}">{{ page.extra.risk }}</strong></span>
{% if page.extra.duration_minutes > 0 %}<span>Duración: {{ page.extra.duration_minutes }} min</span>{% endif %}
{% if page.extra.ticket %}<span>Referencia: {{ page.extra.ticket }}</span>{% endif %}
</div>
<div class="ops-tags">{% for system in page.taxonomies.systems %}<span>@{{ system }}</span>{% endfor %}{% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', name=tag) }}">#{{ tag }}</a>{% endfor %}</div>
</header>
{% if page.extra.review_required %}
<aside class="ops-callout"><strong>Revisión requerida.</strong> El estado se calcula contra el hash actual. Consulte <a href="{{ get_url(path='@/revision/_index.md') }}">Revisión semanal</a>.</aside>
{% endif %}
<div class="ops-prose">{{ page.content | safe }}</div>
<footer class="ops-source"><span>Huella SSH del autor:</span><code>{{ page.extra.author_fingerprint }}</code></footer>
</article>
{% endblock content %}
-15
View File
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header">
<p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / {{ page.extra.kind }}</p>
<h1>{{ page.title }}</h1>
<p>{{ page.description }}</p>
<div class="ops-meta"><span>Autor: {{ page.extra.author_id }}</span><span>Fecha: {{ page.date }}</span></div>
<div class="ops-tags">{% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', name=tag) }}">#{{ tag }}</a>{% endfor %}</div>
</header>
<div class="ops-prose">{{ page.content | safe }}</div>
</article>
{% endblock content %}
-10
View File
@@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header"><p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / PROYECTO</p><h1>{{ page.title }}</h1><p>{{ page.description }}</p>
<div class="ops-meta"><span>Estado: {{ page.extra.status }}</span>{% if page.extra.target_date %}<span>Fecha objetivo: {{ page.extra.target_date }}</span>{% endif %}<span>Responsables: {% for owner in page.extra.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if page.extra.owners | length == 0 %}Sin asignar{% endif %}</span></div></header>
<div class="ops-prose">{{ page.content | safe }}</div>
</article>
{% endblock content %}
-16
View File
@@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">LÍNEAS DE TRABAJO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p><div class="ops-prose">{{ section.content | safe }}</div></header>
<div class="ops-board">
{% for project in ops.projects %}
<article class="ops-card">
<div class="ops-card__top"><code>{{ project.project_id }}</code><span class="badge badge--status">{{ project.status }}</span></div>
<h2><a href="{{ project.permalink }}">{{ project.title }}</a></h2><p>{{ project.description }}</p>
<dl>{% if project.target_date %}<dt>Objetivo</dt><dd>{{ project.target_date }}</dd>{% endif %}<dt>Responsables</dt><dd>{% for owner in project.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if project.owners | length == 0 %}Sin asignar{% endif %}</dd></dl>
</article>
{% endfor %}
{% if ops.projects | length == 0 %}<p class="ops-empty">No hay proyectos.</p>{% endif %}
</div>
{% endblock content %}
-65
View File
@@ -1,65 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header">
<p class="ops-eyebrow">AGENDA DEL EQUIPO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p>
<div class="ops-prose">{{ section.content | safe }}</div>
</header>
<div class="ops-callout">
<strong>Flujo de la reunión:</strong> abrir la memoria, discutirla y registrar cada confirmación con
<code>clusterlog --commit review mark &lt;ID&gt;</code>. Una edición posterior cambia el hash y vuelve a abrir la revisión.
</div>
<section aria-labelledby="review-queue-title">
<h2 id="review-queue-title">Memorias pendientes</h2>
<div class="ops-list">
{% for item in ops.review_queue %}
<article class="ops-list-item ops-list-item--review">
<div class="ops-list-item__meta"><code>{{ item.entry_id }}</code><span class="badge badge--{{ item.risk }}">{{ item.risk }}</span><span>{{ item.reviewed_count }}/{{ item.required_count }} revisiones</span></div>
<h3><a href="{{ item.permalink }}">{{ item.title }}</a></h3>
<p>{{ item.description }}</p>
{% if item.configuration_error %}<p class="ops-error">{{ item.configuration_error }}</p>{% endif %}
<div class="ops-review-grid">
<div><strong>Revisaron</strong>{% for admin in item.reviewed_by %}<span class="reviewer reviewer--done">✓ {{ admin }}</span>{% endfor %}{% if item.reviewed_by | length == 0 %}<span class="reviewer">Nadie aún</span>{% endif %}</div>
<div><strong>Faltan</strong>{% for admin in item.missing_reviewers %}<span class="reviewer reviewer--missing">○ {{ admin }}</span>{% endfor %}{% if item.missing_reviewers | length == 0 %}<span class="reviewer reviewer--done">Completa</span>{% endif %}</div>
</div>
<pre><code>clusterlog --json --commit review mark {{ item.entry_id }}</code></pre>
</article>
{% endfor %}
{% if ops.review_queue | length == 0 %}<div class="ops-empty ops-empty--success"><strong>Cola limpia.</strong><br>No hay memorias pendientes para la versión actual de su contenido.</div>{% endif %}
</div>
</section>
<section class="ops-meeting-section" aria-labelledby="meeting-tasks-title">
<h2 id="meeting-tasks-title">Tareas abiertas para revisar</h2>
<div class="ops-list">
{% for task in ops.tasks %}
{% if task.status != "done" and task.status != "cancelled" %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ task.task_id }}</code><span class="badge">{{ task.status }}</span><span>{{ task.actual_minutes }}/{{ task.estimate_minutes }} min</span></div>
<h3><a href="{{ task.permalink }}">{{ task.title }}</a></h3>
<p>{{ task.description }}</p>
{% if task.running_by | length > 0 %}<p><strong>En ejecución:</strong> {{ task.running_by | join(sep=", ") }}</p>{% endif %}
</article>
{% endif %}
{% endfor %}
</div>
</section>
<section class="ops-meeting-section" aria-labelledby="meeting-projects-title">
<h2 id="meeting-projects-title">Proyectos activos o bloqueados</h2>
<div class="ops-list">
{% for project in ops.projects %}
{% if project.status == "active" or project.status == "blocked" or project.status == "planned" %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ project.project_id }}</code><span class="badge">{{ project.status }}</span>{% if project.target_date %}<span>Meta {{ project.target_date }}</span>{% endif %}</div>
<h3><a href="{{ project.permalink }}">{{ project.title }}</a></h3>
<p>{{ project.description }}</p>
</article>
{% endif %}
{% endfor %}
</div>
</section>
{% endblock content %}
-22
View File
@@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header">
<p class="ops-eyebrow">BIBLIOTECA TÉCNICA</p>
<h1>{{ section.title }}</h1>
<p>{{ section.description }}</p>
<div class="ops-prose">{{ section.content | safe }}</div>
</header>
<div class="ops-list">
{% for page in section.pages %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span>{{ page.date }}</span></div>
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
<p>{{ page.description }}</p>
<div class="ops-tags">{% for tag in page.taxonomies.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
</article>
{% endfor %}
{% if section.pages | length == 0 %}<p class="ops-empty">No hay páginas en esta sección.</p>{% endif %}
</div>
{% endblock content %}
-14
View File
@@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header"><p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / TAREA</p><h1>{{ page.title }}</h1><p>{{ page.description }}</p>
<div class="ops-meta"><span>Estado base: {{ page.extra.status }}</span><span>Prioridad: {{ page.extra.priority }}</span><span>Estimación: {{ page.extra.estimate_minutes }} min</span>{% if page.extra.due %}<span>Fecha: {{ page.extra.due }}</span>{% endif %}{% if page.extra.project_id %}<span>Proyecto: {{ page.extra.project_id }}</span>{% endif %}</div></header>
<aside class="ops-callout">El estado efectivo y el tiempo acumulado se muestran en el <a href="{{ get_url(path='@/tareas/_index.md') }}">tablero de tareas</a>; se calculan desde el registro de eventos.</aside>
<div class="ops-prose">{{ page.content | safe }}</div>
<pre><code>clusterlog --commit task start {{ page.extra.id }}
clusterlog --commit task stop {{ page.extra.id }}
clusterlog --commit task done {{ page.extra.id }}</code></pre>
</article>
{% endblock content %}
-18
View File
@@ -1,18 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">TRABAJO COMPARTIDO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p><div class="ops-prose">{{ section.content | safe }}</div></header>
<div class="ops-board">
{% for task in ops.tasks %}
<article class="ops-card">
<div class="ops-card__top"><code>{{ task.task_id }}</code><span class="badge badge--status">{{ task.status }}</span></div>
<h2><a href="{{ task.permalink }}">{{ task.title }}</a></h2><p>{{ task.description }}</p>
<dl><dt>Prioridad</dt><dd>{{ task.priority }}</dd><dt>Estimado</dt><dd>{{ task.estimate_minutes }} min</dd><dt>Real</dt><dd>{{ task.actual_minutes }} min</dd>{% if task.due %}<dt>Fecha</dt><dd>{{ task.due }}</dd>{% endif %}{% if task.project_id %}<dt>Proyecto</dt><dd>{{ task.project_id }}</dd>{% endif %}</dl>
{% if task.running_by | length > 0 %}<p class="ops-running">▶ En curso por {% for admin in task.running_by %}{{ admin }}{% if not loop.last %}, {% endif %}{% endfor %}</p>{% endif %}
<div class="ops-tags">{% for admin in task.assignees %}<span>+{{ admin }}</span>{% endfor %}</div>
</article>
{% endfor %}
{% if ops.tasks | length == 0 %}<p class="ops-empty">No hay tareas.</p>{% endif %}
</div>
{% endblock content %}
-8
View File
@@ -1,8 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ taxonomy.name }} · {{ config.title }}{% endblock title %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">ÍNDICE</p><h1>{{ taxonomy.name }}</h1></header>
<div class="ops-grid">
{% for term in terms %}<a class="ops-panel" href="{{ term.permalink }}"><h2>{{ term.name }}</h2><p>{{ term.page_count }} páginas</p></a>{% endfor %}
</div>
{% endblock content %}
-6
View File
@@ -1,6 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ term.name }} · {{ config.title }}{% endblock title %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">{{ taxonomy.name }}</p><h1>{{ term.name }}</h1><p>{{ term.page_count }} páginas</p></header>
<div class="ops-list">{% for page in term.pages %}<article class="ops-list-item"><div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span>{{ page.date }}</span></div><h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2><p>{{ page.description }}</p></article>{% endfor %}</div>
{% endblock content %}