Compare commits

..
13 Commits
Author SHA1 Message Date
fernandoandClaude Sonnet 5 fb5bed22c2 chore: subir a clusterlog v0.2.0 (plantillas embebidas en el binario)
CI / validate-and-build (push) Successful in 9s
CI / runme (push) Successful in 11s
make templates ya no descarga un tarball aparte: extrae las plantillas
del binario ya descargado con "clusterlog templates extract" (ver
ADR-0006 del motor). fetch-engine.sh se simplifica a un solo modo
(binario); ya no hay asset de plantillas que descargar ni checksum
aparte que verificar para eso.

Probado de extremo a extremo contra el release v0.2.0 real: fetch del
binario, extracción de plantillas, sync, validate --require-zola y
zola build sobre el contenido real de MCPS, todo limpio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:42:30 -06:00
fernandoandClaude Sonnet 5 38c04ca62f fix(ci): runme-ci.sh vuelve a fallar con nombres exactos; usar --dry-run
CI / validate-and-build (push) Successful in 7s
CI / runme (push) Successful in 9s
El intento anterior (correr cada celda por nombre, sin --all) también
aborta en el runner real ("Parsing files..." → "could not execute
command: aborted") — el problema no era --all. --dry-run sí funciona en
ese runner (nunca ejecuta nada él mismo), pero escribe el script
resuelto a stderr, no a stdout: el primer intento de "| bash" no
ejecutaba nada porque el pipe sólo captura stdout, y lo que se veía en
pantalla era stderr yendo directo a la terminal. Corregido con "2>&1
>/dev/null | bash". Probado localmente de extremo a extremo: las 4
celdas ejecutan de verdad (incluye verify-head devolviendo valid:true
real, no sólo el texto del comando).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 01:50:57 -06:00
fernandoandClaude Sonnet 5 064b5d5bb8 fix(ci): correr celdas ci-safe por nombre en vez de runme run --all
CI / validate-and-build (push) Successful in 7s
CI / runme (push) Failing after 7s
El log real del runner confirmó que el crash ("could not execute
command: aborted") ocurre justo al entrar a la UI interactiva
multi-tarea de "runme run --all" (mensajes "Initializing..."/"Parsing
files..."), no en una celda en particular — --log/--log-file ni
siquiera alcanzan a escribir nada. No se pudo reproducir en local pese
a varios intentos (stdin/stdout no interactivos, CI=true, TERM=dumb).

scripts/runme-ci.sh corre cada celda ci-safe por su nombre exacto, sin
--all: --dry-run ya había confirmado que esa forma no dispara la UI
multi-tarea. Makefile y RUNBOOK.md actualizados para reflejarlo.
Probado localmente de extremo a extremo, las 4 celdas en verde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 01:45:23 -06:00
fernandoandClaude Sonnet 5 f178ecdcc7 fix(ci): forzar entorno no interactivo y agregar diagnóstico a runme-ci
CI / validate-and-build (push) Successful in 7s
CI / runme (push) Failing after 7s
El job runme fallaba en el runner real con "could not execute command:
aborted" justo al iniciar "runme run", algo que no se pudo reproducir
localmente (incluso forzando stdin/stdout no interactivos) — probable
diferencia de cómo act_runner en modo nativo/host expone el proceso
frente a una sesión interactiva. Se agrega CI=true y TERM=dumb al job
(convención estándar que muchas TUIs respetan) y --log/--log-file a
runme-ci para que, si vuelve a fallar, el paso de diagnóstico imprima
el log real en vez de repetir el ciclo de adivinar a ciegas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 01:40:21 -06:00
fernandoandClaude Sonnet 5 63970a89e1 fix(ci): correr sync antes de validate en el job runme
CI / validate-and-build (push) Successful in 8s
CI / runme (push) Failing after 6s
source_sha256 en data/generated/site_state.json incluye la ruta
absoluta del checkout (sourceDigest hashea path+contenido), así que
difiere entre el checkout donde se commiteó y el checkout de un job de
CI en otra ruta, aunque el contenido sea idéntico. El job
validate-and-build ya corría sync antes de validate en el mismo
checkout; el job runme no, y por eso runme-check fallaba con
generated_state_stale. Reproducido localmente clonando a otra ruta
antes de aplicar el fix, y confirmado limpio después.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 01:30:51 -06:00
fernandoandClaude Sonnet 5 622578b454 fix(content): escapar {{...}} literal en dos memorias (rompía zola check)
CI / validate-and-build (push) Successful in 7s
CI / runme (push) Failing after 5s
Los comandos de ejemplo "podman inspect ... --format '{{range
.Config.Env}}{{println .}}{{end}}'" son sintaxis de Go templates, no de
Tera, pero Zola igual intenta interpretarlos como expresiones Tera al
renderizar la página — zola check fallaba con "Found `}}` but expected
identifier". Encontrado por la propia CI al validar --require-zola tras
la conversión a instancia (antes nunca se había corrido zola check
sobre este contenido en CI con la ruta real). Envueltos en {% raw %}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 01:20:53 -06:00
fernandoandClaude Sonnet 5 eac5e22ed1 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>
2026-08-27 00:40:26 -06:00
fernandoandClaude Sonnet 5 5240cfd8c2 fix(bootstrap-instance): generar content/_index.md (portada)
Sin la portada, zola build fallaba en toda instancia recién creada:
get_url no podía resolver @/_index.md desde section.html. Encontrado
al probar bootstrap-instance de extremo a extremo (zola build real,
no sólo validate --skip-zola) para sembrar el repo motor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:20:19 -06:00
fernandoandClaude Sonnet 5 4afb85bb9e feat(motor): agregar version fijable en build y clusterlog bootstrap-instance
Primer paso hacia separar el motor (herramienta genérica) de cada
instancia (contenido de un deployment): Version deja de ser const para
poder inyectarse con -ldflags en `make build` (git describe, sin tags
aún), y el comando nuevo bootstrap-instance genera el esqueleto mínimo
de una instancia (zola.toml, content/<sección>/_index.md, admins.json
vacío, .clusterlog-version) reusando saveAdmins() en vez de duplicar su
formato. Documentado en CLI.md; el empaquetado de templates/tema y el
CI de instancia quedan para cuando el motor publique releases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:06:49 -06:00
fernandoandClaude Sonnet 5 c06037fa3b feat(runme): integrar Runme como sidecar de documentación ejecutable
Agrega RUNBOOK.md y runbooks/ (fuera de content/, para no interferir con
la publicación de Zola), con reglas de nombres/etiquetas/aislamiento de
celdas mutantes verificadas por clusterlog validate. Instalación pinneada
a v3.17.4 con checksum verificado (scripts/runme-install.sh), un job de
CI separado (.gitea/workflows/ci.yml) que sólo corre celdas ci-safe, y la
decisión documentada en docs/adr/0004-runme-sidecar.md y docs/RUNME.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:06:01 -06:00
fernando fcee75fe83 memory(MEM-20260825-EED6F1): Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea) 2026-08-24 19:01:15 -06:00
fernando e6c9733725 memory(MEM-20260824-254312): Configurar correo saliente para recuperación de contraseña en Zammad y Grafana 2026-08-24 14:49:04 -06:00
fernando 3bf89b9888 memory(MEM-20260824-C5E50A): Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph 2026-08-24 14:49:00 -06:00
76 changed files with 1196 additions and 6725 deletions
+1
View File
@@ -0,0 +1 @@
v0.2.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
+17
View File
@@ -0,0 +1,17 @@
# Copie a .env (ignorado por git) si su flujo local o Runme lo necesita.
# Nunca ponga valores reales de credenciales en este archivo ni en .env.
# Raíz del proyecto clusterlog, si no se ejecuta desde ahí.
CLUSTERLOG_ROOT=.
# Ruta a SU llave pública (o base) para identidad/firma. Nunca la ruta de la
# llave privada de otra persona, y nunca el contenido de una llave privada.
CLUSTERLOG_SSH_KEY=
# Entorno lógico para runbooks que necesiten distinguir local/staging/prod.
# Valores esperados: local, ci, staging, production.
CLUSTERLOG_ENV=local
# Ruta a un archivo JSON de memoria ya redactado (sin secretos), usado por
# el flujo de ejemplo de "memory create --from-json" en AGENTS.md.
CLUSTERLOG_MEMORY_JSON=
+39 -23
View File
@@ -8,18 +8,13 @@ permissions:
contents: read
jobs:
test-and-build:
validate-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Instalar Zola fijado
run: |
mkdir -p "$HOME/.local/bin"
@@ -31,23 +26,10 @@ jobs:
- name: Preparar tema fijado
run: ./scripts/bootstrap-theme.sh
- name: Formato Go
run: test -z "$(gofmt -l cmd internal)"
- name: Pruebas Go
run: go test ./...
- name: Detector de carreras Go
run: go test -race ./...
- name: Análisis estático Go
run: go vet ./...
- name: Compilar CLI
run: make build
- name: Flujo integral de la CLI
run: ./scripts/e2e-local.sh
- name: Descargar plantillas y binario del motor (fenandosr/clusterlog, versión fijada en .clusterlog-version)
run: |
make templates
make build
- name: Verificar firmas SSH de commits del pull request
if: github.event_name == 'pull_request'
@@ -76,3 +58,37 @@ jobs:
- name: Fin del pipeline
run: echo "listo"
runme:
runs-on: ubuntu-latest
permissions:
contents: read
env:
DO_NOT_TRACK: "true"
SCARF_NO_ANALYTICS: "true"
CI: "true"
TERM: "dumb"
steps:
- uses: actions/checkout@v4
- name: Descargar binario del motor (versión fijada en .clusterlog-version)
run: make build
# source_sha256 de data/generated/site_state.json incluye la ruta
# absoluta del checkout, así que difiere entre máquinas aunque el
# contenido sea idéntico; hay que regenerarlo en este mismo checkout
# antes de validar, igual que ya hace el job validate-and-build.
- name: Regenerar la proyección en este checkout
run: ./bin/clusterlog --root . sync
- name: Instalar Runme fijado (v3.17.4, checksum verificado)
run: make runme-install
- name: Exponer runme en PATH
run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Reglas de clusterlog validate sobre los runbooks
run: make runme-check
- name: Ejecutar celdas ci-safe de RUNBOOK.md
run: make runme-ci
+13
View File
@@ -1,6 +1,19 @@
/memorias/
/public/
/bin/
/templates/
/themes/zola.386/
.DS_Store
*.tmp
# Runme: variables de entorno locales, nunca valores reales versionados.
.env
.env.local
.env.*.local
.direnv/
# No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver
# docs/RUNME.md en el repositorio del motor): un glob amplio aquí podría
# ocultar Markdown legítimo por accidente. clusterlog validate detecta
# señales concretas en su lugar (directorio .runme/ versionado, archivos
# "*output*" dentro de runbooks/).
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["stateful.runme"]
}
+25 -6
View File
@@ -72,15 +72,33 @@ No convierta salidas extensas de terminal en el cuerpo. Resuma y enlace evidenci
- `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse.
- `task_has_running_timers`: identifique a los actores; no cierre o borre eventos.
## Cambios al código del proyecto
## Runbooks ejecutables (Runme)
Antes de entregar:
`RUNBOOK.md` y `runbooks/**/*.md` son documentación ejecutable, no una
segunda CLI. Reglas para un agente:
- Nunca active una celda `risk-mutating` o `risk-destructive` sin que el
usuario haya autorizado explícitamente esa operación puntual — la
etiqueta `manual-only`/`interactive`/`excludeFromRunAll` es una defensa
técnica de Runme, no una autorización.
- Trate cada celda como el comando que envuelve: si el comando requiere
`--dry-run` antes de `--commit`, la celda también.
- No agregue lógica nueva dentro de una celda; si hace falta lógica
reutilizable, colóquela en `clusterlog`, un script en `scripts/` o un
target de `Makefile`, y haga que la celda sólo lo invoque.
- No genere ni edite manualmente un identificador Lifecycle Identity de
Runme.
## Cambios a esta instancia
Este repositorio no tiene código de la CLI (vive en
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog)). Un cambio
aquí es Markdown/JSON de contenido o configuración de deployment. Antes de
entregar:
```bash
gofmt -w cmd internal
go test ./...
go vet ./...
make build
make templates
./bin/clusterlog --root . sync
./bin/clusterlog --root . sync --check
./bin/clusterlog --root . validate --skip-zola
@@ -94,4 +112,5 @@ Cuando Zola esté disponible:
zola build
```
Mantenga compatibilidad del JSON. Cualquier cambio de contrato debe actualizar `schema.go`, `CLI.md`, ejemplos y pruebas.
Si el cambio necesario es a la CLI, sus esquemas o su comportamiento, hágalo
en el repositorio del motor, no aquí.
-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`.
+6
View File
@@ -23,6 +23,12 @@ Sigue primero `AGENTS.md`. Estas reglas son específicas para sesiones de admini
- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada.
- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado.
## Runbooks ejecutables (Runme)
- No actives una celda `risk-mutating`/`risk-destructive` de `RUNBOOK.md` o `runbooks/` sin autorización explícita del usuario para esa operación puntual.
- Una celda `ci-safe` sigue siendo el mismo comando que documenta; aplícale las mismas reglas de `--dry-run`/`--commit` que usarías si lo tipearas tú mismo.
- No agregues lógica nueva dentro de una celda ni edites un identificador Lifecycle Identity generado por Runme.
## Formato de respuesta al usuario
```text
-368
View File
@@ -1,368 +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.
-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.
+27 -17
View File
@@ -1,28 +1,26 @@
SHELL := /usr/bin/env bash
GO ?= go
ZOLA ?= zola
BINARY ?= bin/clusterlog
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean
.PHONY: all build theme templates sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci
all: build
# "build" ya no compila (esta instancia no tiene código Go): descarga el
# binario fijado en .clusterlog-version desde fenandosr/clusterlog,
# verificando su checksum. Se mantiene el nombre "build" para no romper
# RUNBOOK.md ni el hábito de make build/make sync-check de siempre.
build:
mkdir -p $(dir $(BINARY))
$(GO) build -trimpath -o $(BINARY) ./cmd/clusterlog
test:
$(GO) test ./...
vet:
$(GO) vet ./...
e2e: build
./scripts/e2e-local.sh
./scripts/fetch-engine.sh $(BINARY)
theme:
./scripts/bootstrap-theme.sh
# Las plantillas viajan embebidas en el binario del motor desde v0.2.0 (ver
# ADR-0006 del motor): no hay red aparte para esto, sólo se extraen.
templates: build
./$(BINARY) --root . templates extract
sync: build
./$(BINARY) --root . sync
@@ -35,17 +33,29 @@ validate: build
validate-local: build
./$(BINARY) --root . validate --skip-zola
site: theme sync validate
site: theme templates sync validate
$(ZOLA) build
serve: theme sync
serve: theme templates sync
$(ZOLA) serve --interface 127.0.0.1
smoke: build
./scripts/smoke.sh
ci: theme test vet e2e build sync sync-check validate
ci: theme templates build sync sync-check validate
$(ZOLA) build
clean:
rm -rf bin public themes/zola.386
rm -rf bin public themes/zola.386 templates
runme-install:
./scripts/runme-install.sh
runme-list:
runme ls --filename RUNBOOK.md
runme-check: build
./scripts/runme-check.sh
runme-ci: build
./scripts/runme-ci.sh
+52 -57
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:
@@ -13,7 +23,7 @@ administrador o agente IA
| ---- escribe Markdown/eventos JSON
| ---- opcionalmente crea commit SSH firmado
v
repositorio Git ---- fuente de verdad y auditoría
repositorio Git ---- fuente de verdad y auditoría (esta instancia)
|
v
clusterlog sync ---- genera data/generated/site_state.json
@@ -22,7 +32,7 @@ administrador o agente IA
Zola ---- lectura, navegación, taxonomías y búsqueda
```
## Qué incluye el MVP
## Qué incluye
- Secciones de **Documentación**, **Manuales**, **Memorias**, **Proyectos**, **Tareas** y **Revisión**.
- Memorias operativas con autor, huella SSH, riesgo, sistemas, etiquetas, ticket, duración, validación y reversión.
@@ -30,69 +40,45 @@ administrador o agente IA
- Invalidación automática de revisiones cuando cambia el contenido de una memoria.
- Tareas con estimación, fecha objetivo, responsables, estado y tiempo real mediante eventos `start`, `stop` y `adjust`.
- Proyectos con objetivo, responsables, alcance, hitos, criterio de éxito y estado.
- CLI no interactiva, salida JSON, entrada JSON por archivo o `stdin`, modo `--dry-run` y códigos de salida estables.
- Identidad por huella de llave pública SSH y commits Git firmados por SSH con `--commit`.
- Detección heurística de secretos antes de escribir contenido.
- Estado derivado y reproducible para que Zola siga siendo un sitio estático.
## Requisitos
- Go 1.23 o posterior.
- Git con soporte de firmas SSH.
- OpenSSH/`ssh-keygen` para firmar y verificar commits.
- Zola 0.23.3 para construir el sitio.
- Acceso al repositorio del tema durante `make theme`, o una copia local indicada con `ZOLA386_SOURCE`.
- **No requiere Go ni un checkout del motor**: `make build`/`make templates` descargan el binario y las plantillas fijadas en [`.clusterlog-version`](.clusterlog-version) desde `fenandosr/clusterlog`, verificando su checksum.
## Inicio rápido
```bash
make theme
make build
make theme # tema zola.386 (repositorio externo, pinneado)
make templates # plantillas del motor, versión fijada
make build # binario clusterlog, versión fijada
./bin/clusterlog --root . sync
./bin/clusterlog --root . validate --skip-zola
zola serve
```
El sitio queda disponible en la dirección que muestre Zola. Antes de publicarlo, cambie `base_url` en `zola.toml` y manténgalo detrás de VPN, red privada o autenticación corporativa.
El sitio queda disponible en la dirección que muestre Zola. Antes de publicar un cambio a `base_url`/`zola.toml`, manténgalo detrás de VPN, red privada o autenticación corporativa.
## Registrar al primer administrador
## Registrar un administrador nuevo
La llave pública identifica el registro del administrador. Para una auditoría fuerte, las operaciones posteriores deben usar `--commit` y la rama protegida debe rechazar commits sin firma válida.
Sólo un `owner` activo puede hacerlo (ver `content/manuales/primer-administrador.md` para el procedimiento completo, incluido el alta del segundo administrador).
```bash
./bin/clusterlog \
--root . \
--ssh-key ~/.ssh/id_ed25519.pub \
--commit \
admin add \
--id ana \
--name "Ana Torres" \
--email ana@example.org \
--key ~/.ssh/id_ed25519.pub \
--roles owner,admin
--roles admin
```
Configure Git para firmar el primer commit completo:
```bash
git init
git config user.name "Ana Torres"
git config user.email ana@example.org
git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519
git config commit.gpgsign true
git add .
git commit -S -m "bootstrap: iniciar bitácora operativa"
```
A partir de ahí:
```bash
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit identity
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit memory create --from-json examples/memory.json
```
`identity` no modifica el repositorio; `--commit` sólo tiene efecto en comandos que escriben archivos.
## Flujo para Claude Code u otro agente
1. El agente recopila y redacta los hechos de la sesión, sin incluir credenciales ni valores sensibles.
@@ -126,6 +112,8 @@ Las instrucciones completas para agentes están en [`AGENTS.md`](AGENTS.md) y [`
## Flujo de revisión semanal
Versión ejecutable (Runme) en [`runbooks/weekly-review.md`](runbooks/weekly-review.md); agenda y criterio en [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md):
```bash
# Ver sólo las memorias que le faltan al administrador actual
./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
@@ -143,42 +131,49 @@ La página `/revision/` contiene la cola pendiente y, como apoyo a la reunión,
## Comandos de calidad
```bash
make test # pruebas unitarias e integración de la CLI
make vet # análisis estático de Go
make e2e # flujo completo en una copia temporal
make sync-check # comprueba que la proyección está actualizada
make validate-local # valida sin requerir Zola
make site # tema + plantillas + estado + validación completa + sitio
make smoke # contratos, sincronización y validación local
make validate-local # sin requerir Zola
make site # tema + estado + validación completa + sitio
make ci # equivalente local de la canalización
```
## Documentación ejecutable (Runme)
Los procedimientos frecuentes también existen como notebooks ejecutables,
sin reemplazar ningún comando canónico: [`RUNBOOK.md`](RUNBOOK.md) es el
punto de entrada. Requiere Runme `v3.17.4` (`make runme-install`) sólo si se
quiere abrir como notebook o correr por CLI; cada celda es, además, un
bloque de shell normal que sigue funcionando copiado a mano. Modelo de
identidad, limitaciones y la decisión completa viven en el motor:
[`docs/RUNME.md`](https://github.com/fenandosr/clusterlog/blob/main/docs/RUNME.md)
y
[ADR-0004](https://github.com/fenandosr/clusterlog/blob/main/docs/adr/0004-runme-sidecar.md).
## Estructura
```text
cmd/clusterlog/ punto de entrada de la CLI
internal/clusterlog/ modelo, comandos, validación y proyección
content/ Markdown fuente por sección
content/ Markdown fuente por sección (memorias, manuales, ...)
data/admins.json registro de administradores y política
data/review-events/ un archivo por revisión
data/task-events/ un archivo por evento de tiempo/estado
data/generated/ proyección regenerable para Zola
templates/ plantillas locales compatibles con Zola actual
static/ ajustes visuales y búsqueda
scripts/ preparación del tema y smoke tests
docs/ decisiones arquitectónicas y operación
config/allowed_signers firmantes SSH autorizados (generado junto a admins.json)
templates/, themes/ descargados por make templates/make theme — no versionados
static/ ajustes visuales y búsqueda propios de esta instancia
scripts/ fetch del motor, tema, smoke test, verificación de firmas
runbooks/, RUNBOOK.md documentación ejecutable de esta instancia
docs/ guion de la reunión semanal
.clusterlog-version versión fijada del motor que usa esta instancia
```
## Documentación clave
- [`HOJA_DE_PROYECTO.md`](HOJA_DE_PROYECTO.md): alcance, requisitos, fases y criterios de aceptación.
- [`CLI.md`](CLI.md): contrato de comandos, JSON y códigos de salida.
- [`SECURITY.md`](SECURITY.md): modelo de confianza y despliegue recomendado.
- [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`.
- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal.
- [`docs/ADR.md`](docs/ADR.md): decisiones de diseño de **clusterlog** (la herramienta), no del clúster que administra; formato, campos y ejemplo. Registros en [`docs/adr/`](docs/adr/), sin equivalente en la UI del sitio ni en la CLI.
- [`VALIDATION_REPORT.md`](VALIDATION_REPORT.md): pruebas ejecutadas, límites y controles pendientes.
- [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0.
- [`content/manuales/`](content/manuales/): procedimientos operativos de esta instancia (acceso SSH, correo, Caddy, alta de administradores...).
- Documentación del motor (CLI, esquemas, ADRs, `bootstrap-instance`, Runme): [fenandosr/clusterlog](https://github.com/fenandosr/clusterlog).
## Estado del prototipo
## Estado
La CLI compila con Go 1.23 y sus pruebas cubren creación por JSON, resolución de identidad, revisión por hash, proyección de tareas y eventos independientes. La construcción de Zola está configurada en la CI incluida. Cuando el registro ya contiene administradores activos, la CI también verifica cada commit del pull request contra `config/allowed_signers`; el scaffold vacío omite esa comprobación durante el bootstrap inicial.
Instancia activa del clúster MCPS. La CI (`.gitea/workflows/ci.yml`) descarga el binario y las plantillas del motor por versión fijada, sincroniza y valida el contenido, construye con Zola y publica en `clusterlog.mcps-epcm.org` al hacer push a `main`. También verifica cada commit del pull request contra `config/allowed_signers`.
-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`.
+87
View File
@@ -0,0 +1,87 @@
# RUNBOOK
Punto de entrada de la documentación ejecutable de esta **instancia** de
`clusterlog` (MCPS). Cada celda envuelve un comando canónico existente
(`make`, `./bin/clusterlog`, un script en `scripts/`) — ninguna celda
contiene lógica propia. El desarrollo del motor mismo (compilar, probar,
`bootstrap-instance`) vive en
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); ver
[`runbooks/README.md`](runbooks/README.md) para la convención completa de
nombres y etiquetas.
Este archivo funciona igual con o sin Runme instalado: cada bloque es un
comando de shell normal que también puede copiarse y ejecutarse a mano.
## Runbooks disponibles
- [`runbooks/weekly-review.md`](runbooks/weekly-review.md) — versión ejecutable de la reunión semanal ([`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md) conserva la agenda).
- [`runbooks/_template.md`](runbooks/_template.md) — plantilla para runbooks nuevos.
## Comprobaciones rápidas
```sh {"name":"clusterlog-runme-preflight","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
set -euo pipefail
test -f zola.toml && test -f .clusterlog-version || { echo "ejecute esto desde la raíz de la instancia" >&2; exit 1; }
echo "clusterlog-version: $(cat .clusterlog-version)"
if command -v zola >/dev/null 2>&1; then echo "zola: $(zola --version)"; else echo "zola: no instalado (validate --skip-zola sigue funcionando)"; fi
if command -v runme >/dev/null 2>&1; then echo "runme: $(runme --version)"; else echo "runme: no instalado (make runme-install)"; fi
```
```sh {"name":"clusterlog-sync-check","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
make sync-check
```
```sh {"name":"clusterlog-validate-local","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
make validate-local
```
```sh {"name":"clusterlog-site-build","tag":"role-operator,env-local,risk-read-only","interactive":"false"}
make site
```
`clusterlog-site-build` no lleva `ci-safe`: descarga el tema y las plantillas
del motor por red (`make theme templates`). La CI (`.gitea/workflows/ci.yml`)
ya construye el sitio en su propio paso; repetirlo aquí sería una segunda
copia de la misma lógica, no una celda adicional útil.
```sh {"name":"clusterlog-review-mine","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY con la ruta a su llave pública}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" review list --mine
```
`clusterlog-review-mine` tampoco lleva `ci-safe`: `--mine` resuelve identidad
a partir de una llave SSH personal, y CI no tiene ni debe tener acceso a
ninguna llave privada real.
```sh {"name":"clusterlog-verify-head","tag":"role-reviewer,env-local,risk-read-only,ci-safe","interactive":"false"}
./bin/clusterlog --root . --json verify commit HEAD
```
## Ejecutar
Localmente, fuera de este runner, `runme run --filename RUNBOOK.md --tag
ci-safe --all --skip-prompts` funciona normalmente. En este repositorio,
`make runme-ci` en cambio corre `scripts/runme-ci.sh`, que para cada celda
`ci-safe` extrae su script con `runme run --dry-run <celda>` (nunca ejecuta
nada por sí solo) y lo corre con `bash`, deteniéndose en el primer fallo:
```text
runme run --filename RUNBOOK.md --dry-run clusterlog-runme-preflight | bash
runme run --filename RUNBOOK.md --dry-run clusterlog-sync-check | bash
runme run --filename RUNBOOK.md --dry-run clusterlog-validate-local | bash
runme run --filename RUNBOOK.md --dry-run clusterlog-verify-head | bash
```
Tanto `runme run --all` como invocar cada celda por nombre (sin `--all`)
abortan de inmediato en el runner real de esta instancia
("could not execute command: aborted", justo al entrar a "Parsing
files..."), algo que no se pudo reproducir en ninguna terminal local. Sólo
`--dry-run` evita esa ruta de código en ambos lados — ver `scripts/runme-ci.sh`
para el detalle completo, incluida la comprobación real
(`clusterlog-verify-head` corriendo y devolviendo `valid: true` de verdad).
Nunca ejecute `runme run --all --skip-prompts` sobre este archivo ni sobre
`runbooks/weekly-review.md`: sin filtrar por nombre o etiqueta, `--all`
también incluiría celdas que mutan estado real de esta instancia. Ésas
deben correrse una por una, a mano, después de revisar sus
prerrequisitos.
+38
View File
@@ -121,6 +121,44 @@ Ante contenido sensible o firma cuestionable:
6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro;
7. actualice manuales y controles preventivos.
## Documentación ejecutable (Runme)
`RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como
notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004 en el
[repositorio del motor](https://github.com/fenandosr/clusterlog)). Esto no
cambia el modelo de confianza: se agregan las siguientes reglas.
- Ejecutar una celda de Runme equivale exactamente a ejecutar el comando
local con los permisos del usuario que la corre. No hay una elevación ni
un sandbox adicional.
- El usuario debe leer una celda antes de ejecutarla, igual que revisaría
cualquier comando pegado desde un chat o un documento.
- Un agente de IA no puede activar una celda `risk-mutating`/`risk-destructive`
sin autorización explícita del usuario para esa operación puntual — igual
que con cualquier otro comando mutante.
- Una etiqueta (`ci-safe`, `role-*`, `risk-*`) es metadato de clasificación,
**no** un control de acceso. `interactive` y `excludeFromRunAll` son
defensas adicionales que Runme sí respeta técnicamente, pero tampoco son
autorización: son mecanismos para reducir el riesgo de un "Run All"
accidental, no una barrera de permisos.
- Runme no debe leer, exportar ni copiar ninguna llave privada. Ninguna
celda de este repositorio lo hace ni debe hacerlo.
- No se guardan outputs de celdas con secretos. Los Session Outputs de
Runme están desactivados por defecto en esta integración; si se habilitan
localmente, no deben versionarse ni compartirse.
- Runme Cloud, Gists y cualquier servicio remoto de Runme quedan fuera de
alcance. `runme open` (servidor web local) no debe exponerse en `0.0.0.0`.
- La ejecución de celdas desde el sitio Zola publicado está fuera de
alcance: Zola no construye `runbooks/`, y no existe ningún mecanismo para
correr Runme desde el navegador.
- Las operaciones productivas reales (RBAC, `sudo`, gestores de secretos,
kubeconfig, proveedor cloud, controles corporativos) siguen siendo
responsabilidad de esas herramientas. Runme nunca las reemplaza ni las
reimplementa.
- Un agente debe detenerse ante errores de identidad, firma o validación
también dentro de un runbook, igual que en cualquier otro flujo de
`clusterlog`.
## Reporte de vulnerabilidades
No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales.
-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))
}
}
@@ -0,0 +1,70 @@
+++
title = "Configurar correo saliente para recuperación de contraseña en Zammad y Grafana"
description = "Zammad tenía la credencial SMTP de salida desincronizada del buzón real (los resets de contraseña fallaban silenciosamente); Grafana no tenía SMTP configurado en absoluto. Se creó una cuenta dedicada de solo-salida y se configuró en ambos servicios, sin tocar el buzón soporte@ que Zammad usa para recibir tickets."
date = 2026-08-24T20:49:04Z
authors = ["gen"]
slug = "mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana"
template = "memory.html"
[taxonomies]
categories = ["memorias"]
tags = ["correo", "docker-mailserver", "grafana", "smtp", "zammad"]
systems = ["grafana", "mail-server", "mcps-hpc-01", "mcps-vpn-server", "zammad"]
[extra]
id = "MEM-20260824-254312"
kind = "memory"
author_id = "gen"
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
risk = "medium"
ticket = ""
review_required = true
review_policy = "all-active"
reviewer_snapshot = true
reviewers = ["gen"]
started_at = ""
finished_at = ""
duration_minutes = 0
agent = "claude-code"
+++
## Contexto
El usuario pidió configurar correo saliente en mail.mcps-epcm.org (10.8.0.1, mcps-vpn-server) para que soporte.mcps-epcm.org (Zammad) y grafana.mcps-epcm.org (Grafana, ambos en mcps-hpc-01) puedan mandar el correo de 'olvidé mi contraseña'. Diagnóstico: el contenedor zammad-railsserver tenía SMTP_USER_NAME=soporte@mcps-epcm.org y un SMTP_PASSWORD que ya NO coincidía con la contraseña real del buzón (confirmado con un intento swaks que produjo 'Password mismatch' / 'SASL LOGIN authentication failed' en el log de postfix). Ese mismo buzón soporte@ es el canal de ENTRADA real de Zammad (Channel id=1, área Email::Notification para el saliente; hay además un Channel id=5 de tipo IMAP que zammad-fetch-mail.timer sondea cada 60s para crear tickets desde correos entrantes — ese canal sí tiene la contraseña correcta, guardada en la base de datos de Zammad, no accesible desde fuera). Rotar la contraseña de soporte@ habría arreglado el saliente pero roto el sondeo IMAP de entrada (Channel 5), que sigue funcionando con normalidad. Por eso se optó por crear una cuenta nueva dedicada solo a notificaciones automáticas salientes (notificaciones@mcps-epcm.org), dejando soporte@ intacto. Grafana no tenía ninguna sección [smtp] activa (todo comentado/default) ni SSO configurado (auth local activa por default), así que hoy era literalmente imposible que mandara cualquier correo. Se creó la cuenta en docker-mailserver, se verificó por SMTP con swaks (auth+entrega limpia confirmada en el log de postfix/amavis). Se actualizó el Channel id=1 de Zammad (área Email::Notification) por rails runner dentro del contenedor zammad-railsserver, apuntando host/user/password/domain a la cuenta nueva, dejando notification_sender ('Soporte MCPS <soporte@mcps-epcm.org>') sin cambios para que el remitente visible siga siendo reconocible aunque la autenticación SMTP real sea con notificaciones@. Se probó un envío real de extremo a extremo replicando el pipeline de entrega de Zammad (Mail.new + delivery_method :smtp con las mismas opciones ya guardadas en el Channel) y llegó limpio. Para Grafana, no había compose file ni bind-mount de config: el contenedor se recrea vía un unit de systemd propio (/etc/systemd/system/container-grafana.service, patrón 'podman run' directo, igual que el de Caddy documentado en el manual RUN-20260819-A3B63B). Se agregó GF_SMTP_* al ExecStart, con GF_SMTP_PASSWORD inyectado en forma 'bare' desde un EnvironmentFile dedicado (/etc/grafana-smtp.env, 600 root:root) — mismo patrón de manejo de secretos que ya usa Caddy con las credenciales de Route53. Se respaldó la unit original antes de editar (container-grafana.service.bak-2026-08-24). Tras systemctl daemon-reload + restart, el contenedor recreado ya expone las variables GF_SMTP_* correctas. No se pudo verificar el flujo completo de 'olvidé mi contraseña' de Grafana desde este entorno (no hay acceso a navegador ni sesión admin de Grafana para disparar su endpoint de test email) — queda pendiente un clic de prueba real por parte de un administrador.
## Cambios realizados
- Creada la cuenta notificaciones@mcps-epcm.org en docker-mailserver (mcps-vpn-server), verificada por SMTP con swaks
- Actualizado Channel id=1 (Email::Notification, saliente) de Zammad para autenticar con notificaciones@mcps-epcm.org en vez de la contraseña vieja de soporte@; notification_sender se dejó igual
- Probado un envío real desde Zammad usando el canal actualizado, confirmado entregado limpio en el log de postfix/amavis
- Agregada configuración SMTP a Grafana: nuevo /etc/grafana-smtp.env (600, root:root) con la contraseña, y variables GF_SMTP_ENABLED/HOST/USER/PASSWORD/FROM_ADDRESS/FROM_NAME/STARTTLS_POLICY agregadas al ExecStart de /etc/systemd/system/container-grafana.service (unit original respaldada como .bak-2026-08-24)
- systemctl daemon-reload + restart de container-grafana.service; confirmado que el contenedor recreado expone las variables GF_SMTP_* nuevas
## Comandos relevantes (secretos redactados)
{% raw %}
```console
podman exec mailserver setup email list
podman exec mailserver setup email add notificaciones@mcps-epcm.org <password redactado>
swaks --to prueba@mcps-epcm.org --from notificaciones@mcps-epcm.org --server mail.mcps-epcm.org:587 --auth LOGIN --auth-user notificaciones@mcps-epcm.org --auth-password <redactado> --tls --hide-all
podman exec zammad-railsserver bash -lc "rails r 'Channel.where(area: %q(Email::Notification))...'"
podman exec zammad-railsserver bash -lc "rails r 'c = Channel.find(1); ... c.options[:outbound][:options][:password] = <redactado>; c.save!'"
podman exec zammad-railsserver bash -lc "rails r 'Mail.new(...).delivery_method(:smtp, ...).deliver'" # prueba end-to-end
systemctl cat container-grafana.service
cp /etc/systemd/system/container-grafana.service /etc/systemd/system/container-grafana.service.bak-2026-08-24
tee /etc/grafana-smtp.env # GF_SMTP_PASSWORD=<redactado>, chmod 600
systemctl daemon-reload && systemctl restart container-grafana.service
podman inspect grafana --format '{{range .Config.Env}}{{println .}}{{end}}'
```
{% endraw %}
## Validación
- swaks contra la cuenta notificaciones@ terminó con exit 0 y el log de postfix mostró sasl_username=notificaciones@mcps-epcm.org + amavis 'Passed CLEAN'
- Tras actualizar el Channel de Zammad, un envío real replicando su pipeline de entrega (Mail + delivery_method :smtp con las opciones guardadas) terminó en 'OK' y el log de postfix/amavis confirmó entrega limpia desde notificaciones@ hacia prueba@
- podman inspect grafana tras el restart mostró las 7 variables GF_SMTP_* con los valores esperados (incluida la contraseña resuelta desde el EnvironmentFile)
- systemctl is-active container-grafana.service devolvió 'active' tras el restart, sin reinicios en bucle
## Riesgo y reversión
Restaurar /etc/systemd/system/container-grafana.service.bak-2026-08-24 sobre container-grafana.service, borrar /etc/grafana-smtp.env, systemctl daemon-reload && systemctl restart container-grafana.service para volver Grafana a su estado sin SMTP. En Zammad, el Channel id=1 puede revertirse manualmente (rails runner) a host/user/password de soporte@ si se decide no usar la cuenta dedicada — no hay comando de un solo paso, hay que reconstruir el hash de options a mano. La cuenta notificaciones@mcps-epcm.org puede borrarse con 'setup email del' si se abandona este enfoque; no tiene aliases ni depende de nada más.
@@ -0,0 +1,78 @@
+++
title = "Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph"
description = "El mon del login node (mcps-hpc-01) llevaba ~16h en crash-loop por corrupción de su store RocksDB local; se sacó del monmap y cephadm lo redesplegó con store fresco. En paralelo aparecieron 2 PGs nuevas inconsistentes (HEALTH_ERR), diagnosticadas y reparadas sin ambigüedad."
date = 2026-08-24T20:49:00Z
authors = ["gen"]
slug = "mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph"
template = "memory.html"
[taxonomies]
categories = ["memorias"]
tags = ["almacenamiento", "ceph", "mon", "salud-cluster"]
systems = ["ceph", "mcps-hpc-01"]
[extra]
id = "MEM-20260824-C5E50A"
kind = "memory"
author_id = "gen"
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
risk = "high"
ticket = ""
review_required = true
review_policy = "all-active"
reviewer_snapshot = true
reviewers = ["gen"]
started_at = ""
finished_at = ""
duration_minutes = 0
agent = "claude-code"
+++
## Contexto
A pedido del usuario ('ayúdame a resolver los problemas de ceph en el login node') se revisó ceph -s en mcps-hpc-01 y salió HEALTH_WARN con CEPHADM_FAILED_DAEMON + MON_DOWN (mon.mcps-hpc-01 fuera de quorum) + RECENT_CRASH (miles de crashes acumulados) + el backlog de deep-scrub ya conocido. journalctl mostró que ceph-mon abortaba en cada arranque con ceph_assert(r >= 0) en MonitorDBStore::clear() durante Monitor::preinit() — corrupción del store local, no disco lleno (367G libres de 723G). Los otros 4 mons (MCPS-HPC02/03/04/05) estaban sanos y en quorum, así que no había riesgo real de perder el cluster: la remediación estándar de Ceph para este caso (mon con store corrupto, quorum sano en el resto) es sacarlo del monmap y dejar que cephadm lo redespliegue desde cero, sincronizando vía Paxos desde los mons sanos. Se ejecutó así, con autorización explícita del usuario para cada comando (el clasificador de auto mode de Claude Code bloqueó cada systemctl/ceph mutante uno por uno; algunos los corrió el propio usuario). Aparte de eso, ceph -s mostró HEALTH_ERR por 2 PGs nuevas inconsistentes (3.29, 3.65) — mismo patrón que memorias anteriores (MEM-20260819-6EA868, MEM-20260820-4D10C2): rados list-inconsistent-obj confirmó en ambas una sola copia dañada (osd.13 con read_error en 3.29, osd.1 con data_digest_mismatch en 3.65) contra una copia primaria limpia, sin ambigüedad. osd.13 (mcps-hpc-01, /dev/sda) tuvo su TERCER read_error en pocos días — SMART sigue en PASSED sin sectores reasignados/pendientes, pero la recurrencia ya amerita programar reemplazo preventivo, no solo observar. No se pudo revisar el disco de osd.1 (host MCPS-HPC02): no hay acceso SSH configurado desde este entorno ni desde mcps-hpc-01 hacia ese host; no se intentó adivinar credenciales, mismo criterio que con MCPS-HPC05 en la memoria anterior. El repair de ambas PGs tardó ~1h40 en completar (deep-scrub de PGs grandes de este pool), confirmado por ceph -s volviendo a HEALTH_WARN limpio (solo el backlog de deep-scrub). Se archivaron con ceph crash archive-all los ~9660 reportes de crash acumulados durante el incidente del mon, todos con timestamp del 2026-08-21 (no eran crashes nuevos). El backlog de deep-scrub (313 pgs) se dejó igual, mismo trade-off pendiente de decisión de equipo documentado en memorias anteriores.
## Cambios realizados
- Detenido el daemon mon.mcps-hpc-01 en crash-loop (systemctl stop)
- Sacado mon.mcps-hpc-01 del monmap (ceph mon remove mcps-hpc-01)
- Eliminado el daemon corrupto vía cephadm (ceph orch daemon rm mon.mcps-hpc-01 --force); cephadm lo redesplegó automáticamente con store fresco y volvió a quorum (5/5)
- Diagnosticadas y reparadas pg 3.29 (osd.10 primario limpio / osd.13 read_error) y pg 3.65 (osd.7 primario limpio / osd.1 data_digest_mismatch) con ceph pg repair
- Archivados ~9660 crash reports viejos (todos del 2026-08-21, del mismo incidente del mon) con ceph crash archive-all
## Comandos relevantes (secretos redactados)
```console
ceph -s
ceph health detail
ceph crash ls / ceph crash info <id>
ceph orch ps --daemon_type mon
systemctl status ceph-<fsid>@mon.mcps-hpc-01.service
journalctl -u ceph-<fsid>@mon.mcps-hpc-01.service
du -sh /var/lib/ceph/<fsid>/mon.mcps-hpc-01
systemctl stop ceph-<fsid>@mon.mcps-hpc-01.service
ceph mon remove mcps-hpc-01
ceph mon stat / ceph quorum_status
ceph orch daemon rm mon.mcps-hpc-01 --force
rados list-inconsistent-obj 3.29 --format=json-pretty
rados list-inconsistent-obj 3.65 --format=json-pretty
ceph osd metadata 13 / 1 / 10 / 7
smartctl -H /dev/sda; smartctl -A /dev/sda
ceph pg repair 3.29
ceph pg repair 3.65
ceph crash archive-all
```
## Validación
- journalctl del daemon mostró backtrace consistente en cada crash: ceph_assert(r >= 0) en MonitorDBStore::clear(), confirmando corrupción del store y no un problema transitorio
- ceph quorum_status antes de tocar nada confirmó 4/5 mons sanos en quorum, sin riesgo de perder el cluster al remover el mon roto
- tras el redeploy, ceph -s mostró mon: 5 daemons, quorum ...,mcps-hpc-01 y desaparecieron CEPHADM_FAILED_DAEMON y MON_DOWN
- rados list-inconsistent-obj confirmó, para ambas PGs, que solo existía una copia dañada antes de reparar (sin ambigüedad de cuál copia era la buena)
- smartctl -H/-A en el disco de osd.13 confirmó PASSED sin sectores reasignados/pendientes antes de reparar
- ceph -s tras ~1h40 mostró HEALTH_WARN limpio (solo PG_NOT_DEEP_SCRUBBED), sin OSD_SCRUB_ERRORS ni PG_DAMAGED
- ceph health detail tras archivar confirmó 0 crashes nuevos (RECENT_CRASH ya no aparece)
## Riesgo y reversión
El redeploy del mon y el pg repair no tienen reversión directa; el estado post-cambio es el estado sano verificado (5/5 mons, HEALTH_WARN limpio). Si mon.mcps-hpc-01 vuelve a fallar en preinit tras este redeploy, ya no es un problema de store corrupto (se recreó desde cero) — investigar disco/hardware del host. Si el patrón de read_error en osd.13 se repite una CUARTA vez, ya no tratarlo como watch-item pasivo: programar reemplazo preventivo del disco aunque SMART siga en PASSED.
@@ -0,0 +1,68 @@
+++
title = "Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea)"
description = "Continuación de MEM-20260824-254312: se agregó la misma cuenta dedicada notificaciones@mcps-epcm.org como remitente SMTP de Gitea, que no tenía sección [mailer] configurada en absoluto (recuperación de contraseña también era imposible ahí)."
date = 2026-08-25T01:01:15Z
authors = ["gen"]
slug = "mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea"
template = "memory.html"
[taxonomies]
categories = ["memorias"]
tags = ["correo", "docker-mailserver", "gitea", "smtp"]
systems = ["gitea", "mail-server", "mcps-hpc-01", "mcps-vpn-server"]
[extra]
id = "MEM-20260825-EED6F1"
kind = "memory"
author_id = "gen"
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
risk = "medium"
ticket = ""
review_required = true
review_policy = "all-active"
reviewer_snapshot = true
reviewers = ["gen"]
started_at = ""
finished_at = ""
duration_minutes = 0
agent = "claude-code"
+++
## Contexto
El usuario pidió extender el correo saliente ya configurado para Zammad y Grafana (ver MEM-20260824-254312) también a git.mcps-epcm.org (Gitea 1.27.1, contenedor 'gitea' en mcps-hpc-01). Revisando /opt/gitea/data/gitea/conf/app.ini no había sección [mailer] en absoluto — mismo escenario que Grafana: Gitea no podía mandar ningún correo, incluyendo el de recuperación de contraseña. El contenedor ya se gestiona con un unit de systemd propio (/etc/systemd/system/container-gitea.service) que arma la config vía variables GITEA__<seccion>__<clave> (convención propia de Gitea, ya usada para server/database/service/security en ese mismo unit) — se siguió el mismo patrón agregando GITEA__mailer__*, en vez de editar app.ini a mano, porque el entrypoint de Gitea vuelve a sincronizar esas variables a app.ini en cada arranque del contenedor (editar el archivo directamente se habría perdido o desincronizado en el próximo restart). La contraseña de notificaciones@ se inyectó igual que en Grafana: forma 'bare' de podman vía un EnvironmentFile dedicado (/etc/gitea-smtp.env, 600 root:root), no hardcodeada en el unit. Se respaldó el unit original antes de editar. Tras systemctl daemon-reload + restart, se confirmó que app.ini quedó con [mailer] ENABLED=true y los valores correctos, el contenedor arrancó limpio (sin errores de mail en el log) y el sitio respondió HTTP 200. No se disparó un envío real de prueba (a diferencia de Zammad): Gitea no expone un comando de CLI para probar el mailer, y el botón de 'enviar correo de prueba' vive en su panel de administración web, al que no tengo acceso desde este entorno. La credencial notificaciones@mcps-epcm.org ya se había verificado funcional por SMTP en la memoria anterior (swaks + entrega real confirmada desde Zammad), así que la confianza en que funcione aquí es alta pero no está confirmada específicamente para Gitea.
## Cambios realizados
- Creado /etc/gitea-smtp.env (600, root:root) con GITEA__mailer__PASSWD
- Respaldado el unit original como container-gitea.service.bak-2026-08-24
- Agregadas variables GITEA__mailer__ENABLED/PROTOCOL/SMTP_ADDR/SMTP_PORT/FROM/USER/PASSWD al ExecStart de /etc/systemd/system/container-gitea.service, con EnvironmentFile=/etc/gitea-smtp.env
- systemctl daemon-reload + restart de container-gitea.service
## Comandos relevantes (secretos redactados)
{% raw %}
```console
podman inspect gitea --format '{{range .Config.Env}}{{println .}}{{end}}'
cat /opt/gitea/data/gitea/conf/app.ini
podman exec gitea gitea --version
systemctl cat container-gitea.service
cp /etc/systemd/system/container-gitea.service /etc/systemd/system/container-gitea.service.bak-2026-08-24
tee /etc/gitea-smtp.env # GITEA__mailer__PASSWD=<redactado>, chmod 600
systemctl daemon-reload && systemctl restart container-gitea.service
cat /opt/gitea/data/gitea/conf/app.ini # confirmar seccion [mailer]
podman logs gitea | grep -iE 'mail|error|fatal|panic'
curl -s -o /dev/null -w '%{http_code}' http://10.89.0.60:3000/
```
{% endraw %}
## Validación
- app.ini tras el restart mostró [mailer] con ENABLED=true, PROTOCOL=smtp+starttls, SMTP_ADDR=mail.mcps-epcm.org, SMTP_PORT=587, USER/FROM=notificaciones@mcps-epcm.org y la contraseña correcta
- podman logs gitea sin líneas de error/fatal/panic ni menciones de mail tras el arranque
- curl al puerto interno del contenedor devolvió HTTP 200
- systemctl is-active container-gitea.service devolvió 'active', sin reinicios en bucle
## Riesgo y reversión
Restaurar /etc/systemd/system/container-gitea.service.bak-2026-08-24 sobre container-gitea.service, borrar /etc/gitea-smtp.env, systemctl daemon-reload && systemctl restart container-gitea.service para volver Gitea a su estado sin [mailer]. Pendiente: un administrador debe probar el flujo real de 'olvidé mi contraseña' en git.mcps-epcm.org (o el botón de correo de prueba en Admin > Configuración) para confirmar la entrega end-to-end, igual que sigue pendiente para Grafana.
+180 -3
View File
@@ -1,14 +1,98 @@
{
"version": 2,
"generated_at": "2026-08-24T18:25:00Z",
"source_sha256": "73d66ea39e4def01d7ccf1993b29bbfd832e893c7926a96dc842bc02d70f6044",
"generated_at": "2026-08-27T07:20:28Z",
"source_sha256": "3b845d6aef09eadc3c336ab6c4bb170e461c213ddb59920be6f39ae46b37b0f9",
"stats": {
"active_admins": 1,
"pending_reviews": 4,
"pending_reviews": 7,
"open_tasks": 1,
"active_projects": 1
},
"review_queue": [
{
"entry_id": "MEM-20260825-EED6F1",
"title": "Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea)",
"description": "Continuación de MEM-20260824-254312: se agregó la misma cuenta dedicada notificaciones@mcps-epcm.org como remitente SMTP de Gitea, que no tenía sección [mailer] configurada en absoluto (recuperación de contraseña también era imposible ahí).",
"date": "2026-08-25T01:01:15Z",
"author_id": "gen",
"risk": "medium",
"tags": [
"correo",
"docker-mailserver",
"gitea",
"smtp"
],
"systems": [
"gitea",
"mail-server",
"mcps-hpc-01",
"mcps-vpn-server"
],
"permalink": "/memorias/mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea/",
"content_sha256": "69f1610c2d9004fd7a0948af970a12c98178d701231461e2d18c2219c9cb13aa",
"reviewed_by": [],
"missing_reviewers": [
"gen"
],
"required_count": 1,
"reviewed_count": 0
},
{
"entry_id": "MEM-20260824-254312",
"title": "Configurar correo saliente para recuperación de contraseña en Zammad y Grafana",
"description": "Zammad tenía la credencial SMTP de salida desincronizada del buzón real (los resets de contraseña fallaban silenciosamente); Grafana no tenía SMTP configurado en absoluto. Se creó una cuenta dedicada de solo-salida y se configuró en ambos servicios, sin tocar el buzón soporte@ que Zammad usa para recibir tickets.",
"date": "2026-08-24T20:49:04Z",
"author_id": "gen",
"risk": "medium",
"tags": [
"correo",
"docker-mailserver",
"grafana",
"smtp",
"zammad"
],
"systems": [
"grafana",
"mail-server",
"mcps-hpc-01",
"mcps-vpn-server",
"zammad"
],
"permalink": "/memorias/mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana/",
"content_sha256": "afdbe017e912bc7c5926ad5a66b9e79e055099c1eb564f72d6d6258698d5fcc9",
"reviewed_by": [],
"missing_reviewers": [
"gen"
],
"required_count": 1,
"reviewed_count": 0
},
{
"entry_id": "MEM-20260824-C5E50A",
"title": "Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph",
"description": "El mon del login node (mcps-hpc-01) llevaba ~16h en crash-loop por corrupción de su store RocksDB local; se sacó del monmap y cephadm lo redesplegó con store fresco. En paralelo aparecieron 2 PGs nuevas inconsistentes (HEALTH_ERR), diagnosticadas y reparadas sin ambigüedad.",
"date": "2026-08-24T20:49:00Z",
"author_id": "gen",
"risk": "high",
"tags": [
"almacenamiento",
"ceph",
"mon",
"salud-cluster"
],
"systems": [
"ceph",
"mcps-hpc-01"
],
"permalink": "/memorias/mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph/",
"content_sha256": "e3ca2349c7ebbfc769db94409776e82677c818d10de0124881a2b1d1f4f96233",
"reviewed_by": [],
"missing_reviewers": [
"gen"
],
"required_count": 1,
"reviewed_count": 0
},
{
"entry_id": "MEM-20260820-4D10C2",
"title": "Reparar 2 PGs inconsistentes nuevas en Ceph (HEALTH_ERR, mcps-hpc-01)",
@@ -112,6 +196,99 @@
}
],
"recent_memories": [
{
"entry_id": "MEM-20260825-EED6F1",
"title": "Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea)",
"description": "Continuación de MEM-20260824-254312: se agregó la misma cuenta dedicada notificaciones@mcps-epcm.org como remitente SMTP de Gitea, que no tenía sección [mailer] configurada en absoluto (recuperación de contraseña también era imposible ahí).",
"date": "2026-08-25T01:01:15Z",
"author_id": "gen",
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
"risk": "medium",
"review_required": true,
"review_policy": "all-active",
"reviewer_snapshot": true,
"reviewers": [
"gen"
],
"tags": [
"correo",
"docker-mailserver",
"gitea",
"smtp"
],
"systems": [
"gitea",
"mail-server",
"mcps-hpc-01",
"mcps-vpn-server"
],
"slug": "mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea",
"permalink": "/memorias/mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea/",
"source_path": "content/memorias/2026-08-25-mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea.md",
"content_sha256": "69f1610c2d9004fd7a0948af970a12c98178d701231461e2d18c2219c9cb13aa"
},
{
"entry_id": "MEM-20260824-254312",
"title": "Configurar correo saliente para recuperación de contraseña en Zammad y Grafana",
"description": "Zammad tenía la credencial SMTP de salida desincronizada del buzón real (los resets de contraseña fallaban silenciosamente); Grafana no tenía SMTP configurado en absoluto. Se creó una cuenta dedicada de solo-salida y se configuró en ambos servicios, sin tocar el buzón soporte@ que Zammad usa para recibir tickets.",
"date": "2026-08-24T20:49:04Z",
"author_id": "gen",
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
"risk": "medium",
"review_required": true,
"review_policy": "all-active",
"reviewer_snapshot": true,
"reviewers": [
"gen"
],
"tags": [
"correo",
"docker-mailserver",
"grafana",
"smtp",
"zammad"
],
"systems": [
"grafana",
"mail-server",
"mcps-hpc-01",
"mcps-vpn-server",
"zammad"
],
"slug": "mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana",
"permalink": "/memorias/mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana/",
"source_path": "content/memorias/2026-08-24-mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana.md",
"content_sha256": "afdbe017e912bc7c5926ad5a66b9e79e055099c1eb564f72d6d6258698d5fcc9"
},
{
"entry_id": "MEM-20260824-C5E50A",
"title": "Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph",
"description": "El mon del login node (mcps-hpc-01) llevaba ~16h en crash-loop por corrupción de su store RocksDB local; se sacó del monmap y cephadm lo redesplegó con store fresco. En paralelo aparecieron 2 PGs nuevas inconsistentes (HEALTH_ERR), diagnosticadas y reparadas sin ambigüedad.",
"date": "2026-08-24T20:49:00Z",
"author_id": "gen",
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
"risk": "high",
"review_required": true,
"review_policy": "all-active",
"reviewer_snapshot": true,
"reviewers": [
"gen"
],
"tags": [
"almacenamiento",
"ceph",
"mon",
"salud-cluster"
],
"systems": [
"ceph",
"mcps-hpc-01"
],
"slug": "mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph",
"permalink": "/memorias/mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph/",
"source_path": "content/memorias/2026-08-24-mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph.md",
"content_sha256": "e3ca2349c7ebbfc769db94409776e82677c818d10de0124881a2b1d1f4f96233"
},
{
"entry_id": "MEM-20260820-4D10C2",
"title": "Reparar 2 PGs inconsistentes nuevas en Ceph (HEALTH_ERR, mcps-hpc-01)",
-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.
+5
View File
@@ -1,5 +1,10 @@
# Reunión semanal de revisión operativa
> Versión ejecutable de los comandos de esta página:
> [`runbooks/weekly-review.md`](../runbooks/weekly-review.md) (abrible como
> notebook con Runme, o copiable a mano). Este documento conserva la agenda
> y el criterio; los bloques de comando canónicos viven sólo ahí.
## Objetivo
Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad.
-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.
-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)
}
}
-427
View File
@@ -1,427 +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:])
}
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:
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])
}
}
-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")
}
}
-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"}},
}
}
-377
View File
@@ -1,377 +0,0 @@
package clusterlog
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
const Version = "0.1.0"
const (
ExitOK = 0
ExitUsage = 2
ExitIdentity = 3
ExitValidation = 4
ExitConflict = 5
ExitGit = 6
ExitInternal = 10
)
type CLIError struct {
Code int
Kind string
Message string
Details any
}
func (e *CLIError) Error() string { return e.Message }
func NewError(code int, kind, message string, details any) *CLIError {
return &CLIError{Code: code, Kind: kind, Message: message, Details: details}
}
type Options struct {
Root string
JSON bool
SSHKey string
Commit bool
DryRun bool
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
Now func() time.Time
Executable string
}
type App struct {
Options Options
}
func New(options Options) *App {
if options.Stdout == nil {
options.Stdout = os.Stdout
}
if options.Stderr == nil {
options.Stderr = os.Stderr
}
if options.Stdin == nil {
options.Stdin = os.Stdin
}
if options.Now == nil {
options.Now = time.Now
}
if options.Executable == "" {
options.Executable = "clusterlog"
}
return &App{Options: options}
}
type ReviewPolicy struct {
Default string `json:"default"`
IncludeAuthor bool `json:"include_author"`
}
type SSHKey struct {
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
AddedAt string `json:"added_at,omitempty"`
}
type Admin struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Active bool `json:"active"`
Roles []string `json:"roles,omitempty"`
SSHKeys []SSHKey `json:"ssh_keys"`
}
type AdminRegistry struct {
Version int `json:"version"`
ReviewPolicy ReviewPolicy `json:"review_policy"`
Admins []Admin `json:"admins"`
}
type Identity struct {
Admin Admin `json:"admin"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
PublicPath string `json:"public_key_path"`
SigningKey string `json:"signing_key_path"`
}
type ReviewEvent struct {
ID string `json:"id"`
EntryID string `json:"entry_id"`
AdminID string `json:"admin_id"`
Fingerprint string `json:"fingerprint"`
ReviewedAt string `json:"reviewed_at"`
ContentSHA256 string `json:"content_sha256"`
Note string `json:"note,omitempty"`
}
type ReviewLog struct {
Version int `json:"version"`
Events []ReviewEvent `json:"events"`
}
type TaskEvent struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Type string `json:"type"`
AdminID string `json:"admin_id"`
Fingerprint string `json:"fingerprint"`
At string `json:"at"`
Status string `json:"status,omitempty"`
Minutes int `json:"minutes,omitempty"`
Note string `json:"note,omitempty"`
}
type TaskLog struct {
Version int `json:"version"`
Events []TaskEvent `json:"events"`
}
type PageMeta struct {
Path string
Body string
Root map[string]any
Taxonomies map[string][]string
Extra map[string]any
RawFront string
ContentHash string
}
func (p PageMeta) String(key string) string {
if v, ok := p.Root[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func (p PageMeta) ExtraString(key string) string {
if v, ok := p.Extra[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func (p PageMeta) ExtraBool(key string) bool {
v, ok := p.Extra[key]
if !ok {
return false
}
b, _ := v.(bool)
return b
}
func (p PageMeta) ExtraInt(key string) int {
v, ok := p.Extra[key]
if !ok {
return 0
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func (p PageMeta) ExtraStrings(key string) []string {
v, ok := p.Extra[key]
if !ok {
return nil
}
switch values := v.(type) {
case []string:
return values
case []any:
out := make([]string, 0, len(values))
for _, item := range values {
out = append(out, fmt.Sprint(item))
}
return out
default:
return nil
}
}
type MemoryRecord struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Description string `json:"description"`
Date string `json:"date"`
AuthorID string `json:"author_id"`
AuthorFingerprint string `json:"author_fingerprint"`
Risk string `json:"risk"`
Ticket string `json:"ticket,omitempty"`
ReviewRequired bool `json:"review_required"`
ReviewPolicy string `json:"review_policy"`
ReviewerSnapshot bool `json:"reviewer_snapshot"`
Reviewers []string `json:"reviewers"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
ContentSHA256 string `json:"content_sha256"`
}
type ReviewQueueItem struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Description string `json:"description"`
Date string `json:"date"`
AuthorID string `json:"author_id"`
Risk string `json:"risk"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Permalink string `json:"permalink"`
ContentSHA256 string `json:"content_sha256"`
ReviewedBy []string `json:"reviewed_by"`
MissingReviewers []string `json:"missing_reviewers"`
RequiredCount int `json:"required_count"`
ReviewedCount int `json:"reviewed_count"`
ConfigurationError string `json:"configuration_error,omitempty"`
}
type TaskState struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
ProjectID string `json:"project_id,omitempty"`
Assignees []string `json:"assignees"`
EstimateMinutes int `json:"estimate_minutes"`
ActualMinutes int `json:"actual_minutes"`
Due string `json:"due,omitempty"`
RunningBy []string `json:"running_by"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
}
type ProjectState struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Owners []string `json:"owners"`
TargetDate string `json:"target_date,omitempty"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
}
type SiteStats struct {
ActiveAdmins int `json:"active_admins"`
PendingReviews int `json:"pending_reviews"`
OpenTasks int `json:"open_tasks"`
ActiveProjects int `json:"active_projects"`
}
type SiteState struct {
Version int `json:"version"`
GeneratedAt string `json:"generated_at"`
SourceSHA256 string `json:"source_sha256"`
Stats SiteStats `json:"stats"`
ReviewQueue []ReviewQueueItem `json:"review_queue"`
RecentMemories []MemoryRecord `json:"recent_memories"`
Tasks []TaskState `json:"tasks"`
Projects []ProjectState `json:"projects"`
}
type ResultEnvelope struct {
OK bool `json:"ok"`
Command string `json:"command,omitempty"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
func (a *App) printResult(command string, data any, warnings ...string) error {
if a.Options.JSON {
enc := json.NewEncoder(a.Options.Stdout)
enc.SetIndent("", " ")
return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings})
}
switch v := data.(type) {
case string:
fmt.Fprintln(a.Options.Stdout, v)
default:
bytes, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Fprintln(a.Options.Stdout, string(bytes))
}
for _, warning := range warnings {
fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning)
}
return nil
}
func PrintError(w io.Writer, jsonMode bool, err error) int {
var cliErr *CLIError
if !errors.As(err, &cliErr) {
cliErr = NewError(ExitInternal, "internal", err.Error(), nil)
}
if jsonMode {
payload := ResultEnvelope{OK: false, Error: map[string]any{
"kind": cliErr.Kind,
"message": cliErr.Message,
"details": cliErr.Details,
"exit_code": cliErr.Code,
}}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
} else {
fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message)
if cliErr.Details != nil {
bytes, _ := json.MarshalIndent(cliErr.Details, "", " ")
fmt.Fprintln(w, string(bytes))
}
}
return cliErr.Code
}
func (a *App) ResolveRoot() (string, error) {
if a.Options.Root != "" {
root, err := filepath.Abs(a.Options.Root)
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil {
return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root)
}
return root, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
current := cwd
for {
if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd)
}
-495
View File
@@ -1,495 +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)
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
})
}
+60
View File
@@ -0,0 +1,60 @@
# Runbooks ejecutables
Esta carpeta vive **fuera** de `content/`, así que Zola nunca la construye ni
la publica (confirmado en `zola.toml`: no hay `content_dir` alterno). Es la
razón por la que aquí sí se pueden usar atributos de celda de Runme sin
arriesgar el front matter TOML ni el resaltado de sintaxis que sí usa
`content/manuales/`.
## Cómo abrir un runbook
- **Como notebook (Runme, VS Code):** instale la extensión recomendada en
[`.vscode/extensions.json`](../.vscode/extensions.json) (`stateful.runme`) y
abra el archivo `.md` normalmente; VS Code lo mostrará como notebook.
- **Como Markdown normal:** clic derecho sobre la pestaña → "Reopen Editor
With..." → "Text Editor". Ningún runbook depende de estar en modo notebook
para ser legible o copiable.
- **Por CLI:** `runme run --filename runbooks/<archivo>.md <nombre-de-celda>`.
- **Sin Runme instalado:** copie el bloque de comando y ejecútelo en su
terminal; ninguna celda usa sintaxis que no sea shell/`make`/`clusterlog`
normal.
## Convención de nombres
`kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo
dentro de un archivo): `clusterlog-sync-check`, `clusterlog-validate-local`,
`weekly-review-mine`. `clusterlog validate` (código en
[`internal/clusterlog/runbooks.go`](https://github.com/fenandosr/clusterlog/blob/main/internal/clusterlog/runbooks.go)
del motor) falla si dos celdas repiten un nombre.
## Taxonomía de etiquetas
Toda celda debe declarar al menos una etiqueta de cada categoría:
| 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 (pero mutuamente excluyente con `risk-mutating`/`risk-destructive`) |
Una celda `risk-mutating` o `risk-destructive` **debe** llevar
`manual-only`, `"interactive":"true"` y `"excludeFromRunAll":true`, y
**no puede** llevar `ci-safe` al mismo tiempo. `clusterlog validate` hace
cumplir esto — no es sólo una convención de estilo.
## Qué no va en una celda
Ver ADR-0004 y ADR-0005 en el
[repositorio del motor](https://github.com/fenandosr/clusterlog/tree/main/docs/adr).
En resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente;
no reimplementa validación, autorización, rollback ni gestión de secretos.
## Esta es una instancia, no el motor
Esta carpeta y `RUNBOOK.md` en la raíz operan sobre **datos reales de este
deployment** (`content/`, `data/`, `config/`). El desarrollo del propio
`clusterlog` (compilar, probar, `bootstrap-instance`) vive en
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); su
`.clusterlog-version` en la raíz de este repositorio fija qué versión del
motor usa esta instancia.
+99
View File
@@ -0,0 +1,99 @@
# <Título del runbook>
> Copie este archivo, renómbrelo en `kebab-case` y borre los comentarios
> `> así`. No borre las secciones: si alguna no aplica, escriba "No aplica" y
> explique por qué en una línea.
## Título
`<slug-del-runbook>`
## Propósito
> Qué logra este runbook en una o dos frases. Si no puede explicarlo en dos
> frases, probablemente son dos runbooks.
## Propietario
> Rol o persona responsable de mantenerlo actualizado.
## Audiencia
> Uno o más de: `role-developer`, `role-operator`, `role-reviewer`, `role-owner`.
## Entornos permitidos
> Uno o más de: `env-local`, `env-ci`, `env-staging`, `env-production`.
## Clasificación de riesgo
> `risk-read-only`, `risk-mutating` o `risk-destructive`, por celda — no
> necesariamente una sola clasificación para todo el documento.
## Prerrequisitos
> Herramientas, variables de entorno, accesos y estado previo necesarios.
> Distinga:
> - **Observación**: qué debe ser cierto antes de empezar.
> - **Hipótesis**: qué se espera que pase, si aplica (para runbooks de
> diagnóstico).
## Variables requeridas
> Nombre, propósito y si es secreta (nunca ponga el valor aquí; use
> `.env.example` como referencia de nombres).
## Preflight
> Celda de sólo lectura que confirma que el entorno está listo, sin instalar
> ni mutar nada.
```sh {"name":"template-preflight-example","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
echo "reemplace esto por una comprobación real de prerrequisitos"
```
## Procedimiento
> Distinga explícitamente:
> - **Acción**: qué comando se ejecuta.
> - **Resultado esperado**: qué debería observarse si funcionó.
Ejemplo de celda de sólo lectura (segura para CI):
```sh {"name":"template-safe-example","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
echo "una celda de sólo lectura va aquí"
```
Ejemplo conceptual de celda que muta estado (nunca copie literalmente sin
adaptar prerrequisitos y validación):
```sh {"name":"template-mutating-example","tag":"role-operator,env-production,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
echo "una celda que muta estado real va aquí; nunca risk-mutating + ci-safe"
```
## Validación
> Cómo se confirma que el resultado observado es el esperado. Comandos
> concretos, no "revisar que todo esté bien".
## Rollback
> Cómo revertir, si aplica. Si no hay reversión directa, dígalo explícitamente
> y describa el procedimiento de corrección manual.
## Registro en Clusterlog
> Qué memoria, tarea o commit debe generarse fuera de Runme como evidencia
> canónica de que esto se ejecutó.
## Evidencia
> Qué guardar como prueba (salida JSON, SHA de commit, ID de memoria).
## Referencias
> Enlaces a `CLI.md`, `SECURITY.md`, ADRs u otros runbooks relacionados.
## Limitaciones
> Qué este runbook explícitamente no cubre, y por qué.
+120
View File
@@ -0,0 +1,120 @@
# Reunión semanal de revisión — versión ejecutable
Este runbook es el complemento ejecutable de
[`docs/WEEKLY_REVIEW.md`](../docs/WEEKLY_REVIEW.md), que conserva la agenda y
el criterio de la reunión. Los comandos de preparación y cierre viven aquí
una sola vez; `docs/WEEKLY_REVIEW.md` enlaza a este archivo en vez de repetir
los bloques de shell.
## Propósito
Preparar y cerrar la reunión semanal sin copiar comandos a mano.
## Propietario
Cada administrador ejecuta su propia preparación con su propia llave.
## Audiencia
`role-reviewer`, `role-owner`.
## Entornos permitidos
`env-local` únicamente. Ningún comando de este runbook debe correr en CI:
todos requieren identidad SSH personal o mutan la cola de revisión.
## Clasificación de riesgo
Preparación: `risk-read-only`. Marcar una revisión: `risk-mutating`.
## Prerrequisitos
- `CLUSTERLOG_SSH_KEY` apuntando a su llave pública registrada.
- Haber leído las memorias pendientes antes de marcarlas.
## Variables requeridas
- `CLUSTERLOG_SSH_KEY`: ruta a su llave pública.
- `MEM_ID`: el `entry_id` exacto de la memoria que va a marcar (por ejemplo
`MEM-20260825-ABC123`), sólo para la celda de cierre.
## Preflight
```sh {"name":"weekly-review-identity","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" identity
```
## Procedimiento
### 1. Preparación individual
```sh {"name":"weekly-review-pending","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" review list --mine
```
```sh {"name":"weekly-review-my-tasks","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" task list --mine
```
### 2. Marcar una memoria como revisada (durante o después de la reunión)
```sh {"name":"weekly-review-mark","tag":"role-reviewer,env-production,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
: "${MEM_ID:?defina MEM_ID con el entry_id exacto de la memoria, por ejemplo MEM-20260825-ABC123}"
./bin/clusterlog --root . --json --commit --ssh-key "$CLUSTERLOG_SSH_KEY" \
review mark "$MEM_ID" --note "Revisado en reunión semanal"
```
Prerrequisito adicional de esta celda: haber leído y entendido la memoria
completa, no sólo su título. `env-production` porque un `--commit` exitoso
queda en el historial de Git compartido — no es una operación de laboratorio.
### 3. Cierre
```sh {"name":"weekly-review-close-check","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
make sync-check
```
```sh {"name":"weekly-review-close-validate","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
make validate-local
```
```sh {"name":"weekly-review-close-verify","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
./bin/clusterlog --root . verify commit HEAD
```
## Validación
`review list --mine` debe quedar vacío (o con causa y fecha de seguimiento
documentada aparte, según `docs/WEEKLY_REVIEW.md`).
## Rollback
`review mark` no tiene reversión directa: agrega un evento inmutable en
`data/review-events/`. Si se marcó por error, no se edita el evento; se
documenta el error en la siguiente memoria y se decide en equipo si se
requiere una excepción de política (ver "Preguntas de gobierno" en
`HOJA_DE_PROYECTO.md`).
## Registro en Clusterlog
`review mark --commit` ya deja su propio commit firmado; no hace falta una
memoria adicional sólo por marcar una revisión.
## Evidencia
Salida JSON de `review mark` (contiene el hash de contenido revisado) y el
SHA del commit que produce.
## Referencias
- [`docs/WEEKLY_REVIEW.md`](../docs/WEEKLY_REVIEW.md)
- [`CLI.md`](../CLI.md), sección 7 (Revisión).
## Limitaciones
Ninguna celda de este runbook es `ci-safe`: todas requieren una llave SSH
personal o mutan estado compartido. Esto es intencional, no una omisión.
-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"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Descarga el binario 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.
#
# Las plantillas ya no se descargan aparte (desde v0.2.0 del motor viajan
# embebidas en el propio binario): use "clusterlog templates extract"
# después de este script, ver el target "templates" del Makefile.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENGINE_REPO="${CLUSTERLOG_ENGINE_REPO:-fenandosr/clusterlog}"
VERSION="$(cat "$ROOT/.clusterlog-version")"
DEST="${1:?uso: fetch-engine.sh <destino-del-binario>}"
BASE_URL="https://github.com/${ENGINE_REPO}/releases/download/${VERSION}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
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"
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."
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))"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Comprobación de compatibilidad de los runbooks. No escribe nada en el
# repositorio. Las reglas duras (nombres únicos, etiquetas, aislamiento de
# celdas mutantes, secretos, referencias a scripts/targets inexistentes)
# viven en `clusterlog validate` (internal/clusterlog/runbooks.go) y corren
# siempre, con o sin Runme instalado. Este script agrega, por encima, un
# chequeo blando de que Runme puede parsear cada runbook de verdad.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
echo "== Reglas de clusterlog validate (obligatorio) =="
"$BIN" --root "$ROOT" --json validate --skip-zola
if ! command -v runme >/dev/null 2>&1; then
echo "== runme no está instalado: se omite el chequeo de compatibilidad de Runme (make runme-install) =="
exit 0
fi
echo "== Compatibilidad con la versión fijada de Runme (blando) =="
status=0
files=("$ROOT/RUNBOOK.md")
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$ROOT/runbooks" -name '*.md' -print0 2>/dev/null)
for f in "${files[@]}"; do
rel="${f#"$ROOT"/}"
if runme ls --filename "$f" >/dev/null 2>&1; then
echo " ok: $rel"
else
echo " ADVERTENCIA: runme no pudo parsear $rel" >&2
status=1
fi
done
if [ "$status" -ne 0 ]; then
echo "runme-check: advertencias de compatibilidad de Runme (no bloqueante)." >&2
fi
exit 0
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Corre cada celda ci-safe de RUNBOOK.md, una por una, extrayendo su script
# real con "runme run --dry-run <celda>" y ejecutándolo con bash — sin usar
# el motor de ejecución de Runme.
#
# Se intentó "runme run --tag ci-safe --all --skip-prompts" y también
# invocar cada celda por nombre sin --all: ambas formas abortan de
# inmediato en el runner real de esta instancia ("could not execute
# command: aborted", justo al entrar a "Parsing files..."), algo que no se
# pudo reproducir en ninguna terminal local (interactiva, con stdin/stdout
# redirigidos, con CI=true/TERM=dumb). "--dry-run" sí funciona en ambos
# lados: no dispara esa ruta de código porque nunca ejecuta nada él mismo,
# sólo imprime el script resuelto de la celda.
#
# Esto sacrifica que sea Runme quien de verdad ejecute el comando en CI,
# pero preserva lo esencial: el Markdown con atributos de celda sigue
# siendo la única fuente de verdad de qué corre, y localmente (fuera de
# este runner) "runme run <celda>" sigue funcionando de forma normal.
#
# La lista debe coincidir con las celdas "ci-safe" de RUNBOOK.md.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CI_SAFE_CELLS=(
clusterlog-runme-preflight
clusterlog-sync-check
clusterlog-validate-local
clusterlog-verify-head
)
for cell in "${CI_SAFE_CELLS[@]}"; do
echo "== $cell =="
# --dry-run escribe el script resuelto a stderr, no a stdout.
runme run --filename RUNBOOK.md --dry-run "$cell" 2>&1 >/dev/null | bash
done
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Instala una versión fijada de Runme (nunca "latest") verificando su
# checksum SHA-256 contra los valores publicados en el release oficial.
# No sustituye ni toca bin/clusterlog. No usa curl|sh.
set -euo pipefail
RUNME_VERSION="3.17.4"
INSTALL_DIR="${RUNME_INSTALL_DIR:-$HOME/.local/bin}"
BASE_URL="https://github.com/runmedev/runme/releases/download/v${RUNME_VERSION}"
# SHA-256 tomados del checksums.txt publicado en el release v3.17.4 y
# verificados de forma independiente (sha256sum) contra el tarball real
# durante la implementación de esta integración.
CHECKSUM_LINUX_X86_64="78af7ed33a84ff91a617a018886730038f10af8f73027451d322c21c417fed2c"
CHECKSUM_LINUX_ARM64="fd016b92115d7f77806c70619ee113dc951c49398863e54bc83eadb9d48665e4"
CHECKSUM_DARWIN_X86_64="7c69077ad7331725fa5224fab95562089a42c95baad42b130e1b0b3774ebc480"
CHECKSUM_DARWIN_ARM64="cba556395ca24ccee641ee2faa20cc5a4150826205371de354aafc4a4d830381"
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux)
case "$arch" in
x86_64) asset="runme_linux_x86_64.tar.gz"; checksum="$CHECKSUM_LINUX_X86_64" ;;
aarch64|arm64) asset="runme_linux_arm64.tar.gz"; checksum="$CHECKSUM_LINUX_ARM64" ;;
*) echo "Arquitectura Linux no soportada por este script: $arch" >&2; exit 1 ;;
esac
;;
Darwin)
case "$arch" in
x86_64) asset="runme_darwin_x86_64.tar.gz"; checksum="$CHECKSUM_DARWIN_X86_64" ;;
arm64) asset="runme_darwin_arm64.tar.gz"; checksum="$CHECKSUM_DARWIN_ARM64" ;;
*) echo "Arquitectura macOS no soportada por este script: $arch" >&2; exit 1 ;;
esac
;;
*)
echo "Sistema operativo no soportado por este script: $os" >&2
echo "En Windows, descargue manualmente runme_windows_{x86_64,arm64}.zip desde" >&2
echo " https://github.com/runmedev/runme/releases/tag/v${RUNME_VERSION}" >&2
echo "y verifique su checksum contra checksums.txt del mismo release." >&2
exit 1
;;
esac
if command -v runme >/dev/null 2>&1; then
current="$(runme --version 2>/dev/null | awk '{print $3}')"
if [ "$current" = "$RUNME_VERSION" ]; then
echo "runme $RUNME_VERSION ya está instalado en $(command -v runme); nada que hacer."
exit 0
fi
fi
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
echo "Descargando $asset (runme v${RUNME_VERSION})..."
curl -fsSL -o "$tmp_dir/$asset" "$BASE_URL/$asset"
echo "Verificando checksum SHA-256..."
computed="$(sha256sum "$tmp_dir/$asset" | awk '{print $1}')"
if [ "$computed" != "$checksum" ]; then
echo "ERROR: checksum no coincide para $asset" >&2
echo " esperado: $checksum" >&2
echo " obtenido: $computed" >&2
exit 1
fi
echo "Checksum verificado."
mkdir -p "$INSTALL_DIR"
tar xzf "$tmp_dir/$asset" -C "$tmp_dir" runme
install -m 0755 "$tmp_dir/runme" "$INSTALL_DIR/runme"
if [ -e "$INSTALL_DIR/clusterlog" ] || [ -e "$INSTALL_DIR/bin/clusterlog" ]; then
echo "ERROR: se detectó un binario 'clusterlog' en $INSTALL_DIR; abortando para no sobrescribirlo." >&2
exit 1
fi
echo "runme instalado en $INSTALL_DIR/runme"
"$INSTALL_DIR/runme" --version
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "Agregue $INSTALL_DIR a su PATH para usar 'runme' directamente." ;;
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 %}