refactor: convertir este repositorio en instancia de clusterlog
Retira el código del motor (cmd/, internal/, go.mod, templates/, examples/, scripts de desarrollo Go) — vive ahora en https://github.com/fenandosr/clusterlog (release v0.1.0). Este repositorio conserva únicamente content/, data/, config/ y zola.toml: los datos reales de esta instancia (MCPS). Se agrega .clusterlog-version (fija v0.1.0) y scripts/fetch-engine.sh, que descarga el binario y las plantillas del motor por versión fijada verificando su checksum contra el release. Makefile y .gitea/workflows/ci.yml se adaptan para descargar en vez de compilar. RUNBOOK.md y runbooks/ se recortan a las celdas que sí operan sobre datos reales de esta instancia (sync-check, validate-local, site-build, review-mine, verify-head); el ciclo de desarrollo del motor mismo (build/test/vet) vive en su propio repositorio. Se preserva toda la historia existente: éste es un commit nuevo, no una reescritura. Ver docs/adr/0005-motor-instancia.md en el repositorio del motor para el contexto completo de la separación. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
v0.1.0
|
||||
@@ -1,18 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
+6
-29
@@ -8,18 +8,13 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
validate-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Instalar Zola fijado
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
@@ -31,23 +26,10 @@ jobs:
|
||||
- name: Preparar tema fijado
|
||||
run: ./scripts/bootstrap-theme.sh
|
||||
|
||||
- name: Formato Go
|
||||
run: test -z "$(gofmt -l cmd internal)"
|
||||
|
||||
- name: Pruebas Go
|
||||
run: go test ./...
|
||||
|
||||
- name: Detector de carreras Go
|
||||
run: go test -race ./...
|
||||
|
||||
- name: Análisis estático Go
|
||||
run: go vet ./...
|
||||
|
||||
- name: Compilar CLI
|
||||
run: make build
|
||||
|
||||
- name: Flujo integral de la CLI
|
||||
run: ./scripts/e2e-local.sh
|
||||
- name: Descargar plantillas y binario del motor (fenandosr/clusterlog, versión fijada en .clusterlog-version)
|
||||
run: |
|
||||
make templates
|
||||
make build
|
||||
|
||||
- name: Verificar firmas SSH de commits del pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -87,12 +69,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Compilar CLI
|
||||
- name: Descargar binario del motor (versión fijada en .clusterlog-version)
|
||||
run: make build
|
||||
|
||||
- name: Instalar Runme fijado (v3.17.4, checksum verificado)
|
||||
|
||||
+5
-3
@@ -1,6 +1,7 @@
|
||||
/memorias/
|
||||
/public/
|
||||
/bin/
|
||||
/templates/
|
||||
/themes/zola.386/
|
||||
.DS_Store
|
||||
*.tmp
|
||||
@@ -12,6 +13,7 @@
|
||||
.direnv/
|
||||
|
||||
# No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver
|
||||
# docs/RUNME.md): un glob amplio aquí podría ocultar Markdown legítimo por
|
||||
# accidente. clusterlog validate detecta señales concretas en su lugar
|
||||
# (directorio .runme/ versionado, archivos "*output*" dentro de runbooks/).
|
||||
# docs/RUNME.md en el repositorio del motor): un glob amplio aquí podría
|
||||
# ocultar Markdown legítimo por accidente. clusterlog validate detecta
|
||||
# señales concretas en su lugar (directorio .runme/ versionado, archivos
|
||||
# "*output*" dentro de runbooks/).
|
||||
|
||||
@@ -89,15 +89,16 @@ segunda CLI. Reglas para un agente:
|
||||
- No genere ni edite manualmente un identificador Lifecycle Identity de
|
||||
Runme.
|
||||
|
||||
## Cambios al código del proyecto
|
||||
## Cambios a esta instancia
|
||||
|
||||
Antes de entregar:
|
||||
Este repositorio no tiene código de la CLI (vive en
|
||||
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog)). Un cambio
|
||||
aquí es Markdown/JSON de contenido o configuración de deployment. Antes de
|
||||
entregar:
|
||||
|
||||
```bash
|
||||
gofmt -w cmd internal
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make build
|
||||
make templates
|
||||
./bin/clusterlog --root . sync
|
||||
./bin/clusterlog --root . sync --check
|
||||
./bin/clusterlog --root . validate --skip-zola
|
||||
@@ -111,4 +112,5 @@ Cuando Zola esté disponible:
|
||||
zola build
|
||||
```
|
||||
|
||||
Mantenga compatibilidad del JSON. Cualquier cambio de contrato debe actualizar `schema.go`, `CLI.md`, ejemplos y pruebas.
|
||||
Si el cambio necesario es a la CLI, sus esquemas o su comportamiento, hágalo
|
||||
en el repositorio del motor, no aquí.
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,407 +0,0 @@
|
||||
# Referencia de `clusterlog`
|
||||
|
||||
## 1. Forma general
|
||||
|
||||
```text
|
||||
clusterlog [opciones globales] <comando> [opciones del comando]
|
||||
```
|
||||
|
||||
Las opciones globales deben colocarse **antes** del comando.
|
||||
|
||||
| Opción | Efecto |
|
||||
|---|---|
|
||||
| `--root DIR` | raíz del repositorio; si se omite, la CLI busca el proyecto desde el directorio actual |
|
||||
| `--json` | salida estructurada y estable para agentes |
|
||||
| `--ssh-key PATH` | llave pública o ruta base usada para resolver identidad y firma |
|
||||
| `--commit` | crea un commit Git SSH firmado con sólo los archivos de la operación |
|
||||
| `--dry-run` | valida y calcula el resultado sin escribir |
|
||||
|
||||
También puede definirse `CLUSTERLOG_SSH_KEY`.
|
||||
|
||||
## 2. Contrato JSON
|
||||
|
||||
Éxito:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"command": "memory.create",
|
||||
"data": {},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Error:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"kind": "identity_not_found",
|
||||
"message": "ninguna llave SSH local coincide con un administrador activo",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Un agente debe usar el código del proceso como decisión primaria y `error.kind` para tratamiento específico. No debe inferir éxito por texto parcial.
|
||||
|
||||
## 3. Códigos de salida
|
||||
|
||||
| Código | Significado |
|
||||
|---:|---|
|
||||
| `0` | éxito |
|
||||
| `2` | uso, flags o entrada inválida |
|
||||
| `3` | identidad no encontrada o no autorizada |
|
||||
| `4` | validación, firma o contenido inválido |
|
||||
| `5` | conflicto de estado |
|
||||
| `6` | operación Git fallida |
|
||||
| `10` | error interno no clasificado |
|
||||
|
||||
## 4. Identidad
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub identity
|
||||
```
|
||||
|
||||
La respuesta incluye el administrador, la huella, la ruta pública y la ruta que Git intentará usar para firmar. No prueba posesión hasta que exista una firma válida.
|
||||
|
||||
## 5. Administradores
|
||||
|
||||
### Agregar
|
||||
|
||||
```bash
|
||||
clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub admin add \
|
||||
--id ana \
|
||||
--name "Ana Torres" \
|
||||
--email ana@example.org \
|
||||
--key ~/.ssh/id_ed25519.pub \
|
||||
--roles owner,admin
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- El primer administrador obtiene `owner` aunque no se incluya explícitamente.
|
||||
- Después del primero, sólo un `owner` puede agregar administradores.
|
||||
- No se aceptan IDs, correos o huellas duplicados.
|
||||
- `data/admins.json` y `config/allowed_signers` se actualizan juntos.
|
||||
|
||||
### Listar
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json admin list
|
||||
clusterlog --root . --json admin list --active
|
||||
```
|
||||
|
||||
## 6. Memorias
|
||||
|
||||
### Consultar esquema
|
||||
|
||||
```bash
|
||||
clusterlog --json schema memory
|
||||
```
|
||||
|
||||
### Crear por JSON
|
||||
|
||||
```bash
|
||||
cat examples/memory.json | clusterlog \
|
||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
memory create --from-json -
|
||||
```
|
||||
|
||||
### Crear por flags
|
||||
|
||||
```bash
|
||||
clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub memory create \
|
||||
--title "Rotar certificado del API" \
|
||||
--description "Renovación programada y comprobación de clientes" \
|
||||
--tags tls,certificados \
|
||||
--systems kube-apiserver \
|
||||
--risk medium \
|
||||
--ticket CHG-1042 \
|
||||
--duration 45m \
|
||||
--agent claude-code \
|
||||
--body-file memoria.md
|
||||
```
|
||||
|
||||
Opciones específicas:
|
||||
|
||||
- `--title`
|
||||
- `--description`
|
||||
- `--tags`
|
||||
- `--systems`
|
||||
- `--risk low|medium|high|critical`
|
||||
- `--ticket`
|
||||
- `--review` (por defecto `true`)
|
||||
- `--review-policy all-active|all-active-except-author`
|
||||
- `--duration` en minutos, `90m` o `2h`
|
||||
- `--agent`
|
||||
- `--body-file ARCHIVO|-`
|
||||
- `--from-json ARCHIVO|-`
|
||||
|
||||
Cuando se combinan JSON y flags, los flags que se proporcionen explícitamente tienen precedencia.
|
||||
|
||||
## 7. Revisión
|
||||
|
||||
### Listar pendientes
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json review list
|
||||
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
|
||||
```
|
||||
|
||||
`--mine` requiere identidad y devuelve sólo memorias cuyo conjunto pendiente contiene al administrador actual.
|
||||
|
||||
### Marcar la versión actual
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
review mark MEM-20260812-... --note "Revisado; sin objeciones"
|
||||
```
|
||||
|
||||
El evento guarda el SHA-256 del contenido actual. Repetir el comando sobre el mismo hash es idempotente; editar la memoria hace que el evento anterior deje de contar.
|
||||
|
||||
## 8. Tareas
|
||||
|
||||
### Crear
|
||||
|
||||
```bash
|
||||
cat examples/task.json | clusterlog \
|
||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task create --from-json -
|
||||
```
|
||||
|
||||
Flags disponibles:
|
||||
|
||||
- `--title`, `--description`
|
||||
- `--project PRJ-...`
|
||||
- `--priority low|medium|high|urgent`
|
||||
- `--assignees ana,luis`
|
||||
- `--estimate 120m`
|
||||
- `--due YYYY-MM-DD`
|
||||
- `--tags`, `--systems`
|
||||
- `--objective`
|
||||
- `--acceptance` separado por comas
|
||||
- `--notes`
|
||||
- `--body-file ARCHIVO|-`
|
||||
- `--from-json ARCHIVO|-`
|
||||
|
||||
### Iniciar y detener tiempo
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task start TSK-... --note "Diagnóstico inicial"
|
||||
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task stop TSK-... --note "Prueba completada"
|
||||
```
|
||||
|
||||
Un administrador sólo puede mantener un temporizador activo. Los eventos se ordenan por fecha y tipo para producir un total reproducible.
|
||||
|
||||
### Ajustar tiempo
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task adjust TSK-... --minutes 20 --note "Trabajo previo no registrado"
|
||||
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task adjust TSK-... --minutes -10 --note "Corregir doble conteo"
|
||||
```
|
||||
|
||||
El total efectivo nunca puede quedar negativo.
|
||||
|
||||
### Cambiar estado
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task status TSK-... --status blocked --note "Esperando refacción"
|
||||
```
|
||||
|
||||
Estados admitidos aquí: `backlog`, `todo`, `in_progress`, `blocked`, `cancelled`. Para finalizar use `task done`.
|
||||
|
||||
### Completar
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
task done TSK-... --note "Criterios de aceptación verificados"
|
||||
```
|
||||
|
||||
Si el administrador actual mantiene el temporizador, la CLI lo detiene. Rechaza completar si otra persona sigue registrando tiempo en la misma tarea.
|
||||
|
||||
### Listar
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json task list
|
||||
clusterlog --root . --json task list --status blocked
|
||||
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub task list --mine
|
||||
clusterlog --root . --json task list --project PRJ-...
|
||||
```
|
||||
|
||||
## 9. Proyectos
|
||||
|
||||
### Crear
|
||||
|
||||
```bash
|
||||
cat examples/project.json | clusterlog \
|
||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
project create --from-json -
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
- `--title`, `--description`, `--objective`
|
||||
- `--owners`
|
||||
- `--target-date YYYY-MM-DD`
|
||||
- `--status planned|active|blocked|done|cancelled|archived`
|
||||
- `--tags`, `--systems`
|
||||
- `--scope`, `--out-of-scope`
|
||||
- `--milestones`, `--success`
|
||||
- `--body-file ARCHIVO|-`
|
||||
- `--from-json ARCHIVO|-`
|
||||
|
||||
### Listar
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json project list
|
||||
clusterlog --root . --json project list --status active
|
||||
```
|
||||
|
||||
## 10. Documentación y manuales
|
||||
|
||||
```bash
|
||||
cat examples/manual.json | clusterlog \
|
||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
||||
content create --from-json -
|
||||
```
|
||||
|
||||
También acepta:
|
||||
|
||||
```bash
|
||||
clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub content create \
|
||||
--section manuales \
|
||||
--title "Reemplazar nodo de cómputo" \
|
||||
--description "Procedimiento seguro y validaciones" \
|
||||
--tags runbook,hardware \
|
||||
--systems compute \
|
||||
--body-file reemplazo.md \
|
||||
--weight 20
|
||||
```
|
||||
|
||||
`section` sólo puede ser `documentacion` o `manuales`.
|
||||
|
||||
## 11. Proyección
|
||||
|
||||
```bash
|
||||
clusterlog --root . sync
|
||||
clusterlog --root . sync --check
|
||||
```
|
||||
|
||||
`sync` lee Markdown, administradores y eventos; genera `data/generated/site_state.json`. `--check` no escribe y falla cuando el archivo derivado no coincide con la fuente.
|
||||
|
||||
Los agentes no deben editar `data/generated/site_state.json` a mano.
|
||||
|
||||
## 12. Validación
|
||||
|
||||
```bash
|
||||
clusterlog --root . --json validate
|
||||
clusterlog --root . --json validate --skip-zola
|
||||
clusterlog --root . --json validate --require-zola
|
||||
```
|
||||
|
||||
Se comprueban, entre otros:
|
||||
|
||||
- registro de administradores y `allowed_signers`;
|
||||
- IDs, autores, revisores, owners y assignees;
|
||||
- estados, fechas y referencias de proyectos;
|
||||
- eventos huérfanos o inconsistentes;
|
||||
- temporizadores concurrentes y totales negativos;
|
||||
- secretos probables;
|
||||
- vigencia de la proyección;
|
||||
- plantillas y enlaces con `zola check`, cuando está disponible.
|
||||
|
||||
## 13. Firmas
|
||||
|
||||
```bash
|
||||
clusterlog --root . verify commit HEAD
|
||||
clusterlog --root . verify commit abc1234
|
||||
```
|
||||
|
||||
La verificación usa:
|
||||
|
||||
```text
|
||||
config/allowed_signers
|
||||
namespace: git
|
||||
```
|
||||
|
||||
En CI conviene verificar todos los commits nuevos, no sólo `HEAD`.
|
||||
|
||||
## 14. Esquemas para agentes
|
||||
|
||||
```bash
|
||||
clusterlog --json schema
|
||||
clusterlog --json schema memory
|
||||
clusterlog --json schema task
|
||||
clusterlog --json schema project
|
||||
clusterlog --json schema content
|
||||
```
|
||||
|
||||
Los esquemas siguen JSON Schema 2020-12 y rechazan propiedades adicionales. Esta restricción permite detectar errores ortográficos en nombres de campos.
|
||||
|
||||
## 15. Patrón recomendado para agentes
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
SCHEMA="$(clusterlog --json schema memory)"
|
||||
# Construir input.json con base en data del sobre.
|
||||
|
||||
clusterlog --root . --json --dry-run --ssh-key "$KEY" \
|
||||
memory create --from-json input.json
|
||||
|
||||
RESULT="$(clusterlog --root . --json --commit --ssh-key "$KEY" \
|
||||
memory create --from-json input.json)"
|
||||
|
||||
printf '%s\n' "$RESULT"
|
||||
clusterlog --root . --json verify commit HEAD
|
||||
```
|
||||
|
||||
El agente debe detenerse ante cualquier código distinto de cero y presentar el error al usuario sin ocultarlo.
|
||||
|
||||
## 16. Versión
|
||||
|
||||
```bash
|
||||
clusterlog version
|
||||
```
|
||||
|
||||
No requiere `--root` ni identidad. Devuelve `{"name":"clusterlog","version":"..."}`.
|
||||
El valor de `version` se fija en tiempo de compilación:
|
||||
|
||||
```bash
|
||||
go build -ldflags "-X example.org/bitacora-cluster/internal/clusterlog.Version=v0.2.0" ...
|
||||
```
|
||||
|
||||
`make build` ya lo hace automáticamente a partir de `git describe --tags --always --dirty` (variable `CLUSTERLOG_VERSION` del `Makefile`); un build local sin tags produce algo como `abc1234-dirty`, no `dev` ni una cadena vacía.
|
||||
|
||||
## 17. Instancias nuevas
|
||||
|
||||
```bash
|
||||
clusterlog bootstrap-instance \
|
||||
--output ./clusterlog-nuevo-deploy \
|
||||
--product-name "Bitácora de Ejemplo" \
|
||||
--base-url "https://clusterlog.ejemplo.org"
|
||||
```
|
||||
|
||||
No requiere `--root`: `--output` es un directorio nuevo, normalmente fuera del repositorio actual. Tampoco usa `--commit` (no hay nada que firmar todavía). Genera:
|
||||
|
||||
- `zola.toml` con `base_url`/`title`/`[extra] product_name` sustituidos y el resto de los valores genéricos del motor;
|
||||
- `content/<sección>/_index.md` para `documentacion`, `manuales`, `memorias`, `proyectos`, `tareas` y `revision`;
|
||||
- `data/admins.json` y `config/allowed_signers` vacíos (mismo formato que produce `admin add`, sin ningún administrador todavía);
|
||||
- `data/review-events/.gitkeep`, `data/task-events/.gitkeep`, `data/generated/`;
|
||||
- `.clusterlog-version` con la versión del binario que generó el scaffold;
|
||||
- `README.md` y `.gitignore` de arranque.
|
||||
|
||||
Falla con `output_not_empty` (código `5`) si `--output` ya existe y no está vacío — nunca sobreescribe una instancia existente. Con `--dry-run` no crea el directorio; sólo muestra qué haría.
|
||||
|
||||
Después de `bootstrap-instance`, el flujo es el mismo que "Registrar al primer administrador" en `README.md`, apuntando `--root` al directorio nuevo: `admin add` → `sync` → `validate --skip-zola`. `validate` antes de la primera `sync` falla con `generated_state_stale` a propósito: la proyección todavía no existe.
|
||||
|
||||
**No incluido todavía** (fases posteriores, ver el ADR de motor/instancia cuando exista): plantillas Tera ni tema empaquetados para descarga, ni un `.gitea/workflows/ci.yml` de instancia — el motor aún no publica releases binarios.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,30 +1,24 @@
|
||||
SHELL := /usr/bin/env bash
|
||||
GO ?= go
|
||||
ZOLA ?= zola
|
||||
BINARY ?= bin/clusterlog
|
||||
CLUSTERLOG_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -X example.org/bitacora-cluster/internal/clusterlog.Version=$(CLUSTERLOG_VERSION)
|
||||
|
||||
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci
|
||||
.PHONY: all build theme templates sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci
|
||||
|
||||
all: build
|
||||
|
||||
# "build" ya no compila (esta instancia no tiene código Go): descarga el
|
||||
# binario fijado en .clusterlog-version desde fenandosr/clusterlog,
|
||||
# verificando su checksum. Se mantiene el nombre "build" para no romper
|
||||
# RUNBOOK.md ni el hábito de make build/make sync-check de siempre.
|
||||
build:
|
||||
mkdir -p $(dir $(BINARY))
|
||||
$(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/clusterlog
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
vet:
|
||||
$(GO) vet ./...
|
||||
|
||||
e2e: build
|
||||
./scripts/e2e-local.sh
|
||||
./scripts/fetch-engine.sh bin $(BINARY)
|
||||
|
||||
theme:
|
||||
./scripts/bootstrap-theme.sh
|
||||
|
||||
templates:
|
||||
./scripts/fetch-engine.sh templates templates
|
||||
|
||||
sync: build
|
||||
./$(BINARY) --root . sync
|
||||
|
||||
@@ -37,20 +31,20 @@ validate: build
|
||||
validate-local: build
|
||||
./$(BINARY) --root . validate --skip-zola
|
||||
|
||||
site: theme sync validate
|
||||
site: theme templates sync validate
|
||||
$(ZOLA) build
|
||||
|
||||
serve: theme sync
|
||||
serve: theme templates sync
|
||||
$(ZOLA) serve --interface 127.0.0.1
|
||||
|
||||
smoke: build
|
||||
./scripts/smoke.sh
|
||||
|
||||
ci: theme test vet e2e build sync sync-check validate
|
||||
ci: theme templates build sync sync-check validate
|
||||
$(ZOLA) build
|
||||
|
||||
clean:
|
||||
rm -rf bin public themes/zola.386
|
||||
rm -rf bin public themes/zola.386 templates
|
||||
|
||||
runme-install:
|
||||
./scripts/runme-install.sh
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Bitácora Operativa del Clúster
|
||||
# Bitácora Operativa del Clúster (MCPS)
|
||||
|
||||
Sitio interno y CLI auditable para conservar en un solo lugar la documentación, los manuales, las memorias de cambio, los proyectos, las tareas y la agenda de revisión semanal del equipo que administra un clúster.
|
||||
Instancia de [`clusterlog`](https://github.com/fenandosr/clusterlog): sitio
|
||||
interno y CLI auditable para conservar en un solo lugar la documentación,
|
||||
los manuales, las memorias de cambio, los proyectos, las tareas y la agenda
|
||||
de revisión semanal del equipo que administra el clúster MCPS.
|
||||
|
||||
Este repositorio contiene únicamente **datos de este deployment**
|
||||
(`content/`, `data/`, `config/`, `zola.toml`, `.clusterlog-version`). El
|
||||
código de la CLI, las plantillas y el mecanismo de tema viven en el motor:
|
||||
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog) — ver
|
||||
`docs/adr/0005-motor-instancia.md` de ese repositorio para el porqué de la
|
||||
separación.
|
||||
|
||||
La solución separa responsabilidades:
|
||||
|
||||
@@ -13,7 +23,7 @@ administrador o agente IA
|
||||
| ---- escribe Markdown/eventos JSON
|
||||
| ---- opcionalmente crea commit SSH firmado
|
||||
v
|
||||
repositorio Git ---- fuente de verdad y auditoría
|
||||
repositorio Git ---- fuente de verdad y auditoría (esta instancia)
|
||||
|
|
||||
v
|
||||
clusterlog sync ---- genera data/generated/site_state.json
|
||||
@@ -22,7 +32,7 @@ administrador o agente IA
|
||||
Zola ---- lectura, navegación, taxonomías y búsqueda
|
||||
```
|
||||
|
||||
## Qué incluye el MVP
|
||||
## Qué incluye
|
||||
|
||||
- Secciones de **Documentación**, **Manuales**, **Memorias**, **Proyectos**, **Tareas** y **Revisión**.
|
||||
- Memorias operativas con autor, huella SSH, riesgo, sistemas, etiquetas, ticket, duración, validación y reversión.
|
||||
@@ -30,79 +40,45 @@ administrador o agente IA
|
||||
- Invalidación automática de revisiones cuando cambia el contenido de una memoria.
|
||||
- Tareas con estimación, fecha objetivo, responsables, estado y tiempo real mediante eventos `start`, `stop` y `adjust`.
|
||||
- Proyectos con objetivo, responsables, alcance, hitos, criterio de éxito y estado.
|
||||
- CLI no interactiva, salida JSON, entrada JSON por archivo o `stdin`, modo `--dry-run` y códigos de salida estables.
|
||||
- Identidad por huella de llave pública SSH y commits Git firmados por SSH con `--commit`.
|
||||
- Detección heurística de secretos antes de escribir contenido.
|
||||
- Estado derivado y reproducible para que Zola siga siendo un sitio estático.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Go 1.23 o posterior.
|
||||
- Git con soporte de firmas SSH.
|
||||
- OpenSSH/`ssh-keygen` para firmar y verificar commits.
|
||||
- Zola 0.23.3 para construir el sitio.
|
||||
- Acceso al repositorio del tema durante `make theme`, o una copia local indicada con `ZOLA386_SOURCE`.
|
||||
- **No requiere Go ni un checkout del motor**: `make build`/`make templates` descargan el binario y las plantillas fijadas en [`.clusterlog-version`](.clusterlog-version) desde `fenandosr/clusterlog`, verificando su checksum.
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
```bash
|
||||
make theme
|
||||
make build
|
||||
make theme # tema zola.386 (repositorio externo, pinneado)
|
||||
make templates # plantillas del motor, versión fijada
|
||||
make build # binario clusterlog, versión fijada
|
||||
./bin/clusterlog --root . sync
|
||||
./bin/clusterlog --root . validate --skip-zola
|
||||
zola serve
|
||||
```
|
||||
|
||||
El sitio queda disponible en la dirección que muestre Zola. Antes de publicarlo, cambie `base_url` en `zola.toml` y manténgalo detrás de VPN, red privada o autenticación corporativa.
|
||||
El sitio queda disponible en la dirección que muestre Zola. Antes de publicar un cambio a `base_url`/`zola.toml`, manténgalo detrás de VPN, red privada o autenticación corporativa.
|
||||
|
||||
## Crear una instancia nueva
|
||||
## Registrar un administrador nuevo
|
||||
|
||||
Este repositorio hoy mezcla la herramienta con el contenido de un deployment. Para empezar un deployment **distinto** sin partir de este contenido, use:
|
||||
|
||||
```bash
|
||||
clusterlog bootstrap-instance --output ./mi-deploy --product-name "Mi Bitácora"
|
||||
```
|
||||
|
||||
Genera `zola.toml`, las seis secciones de `content/` vacías, `data/admins.json`/`config/allowed_signers` vacíos y `.clusterlog-version`. Ver la sección 17 de [`CLI.md`](CLI.md) para el detalle completo; sigue siendo un primer paso — todavía no incluye plantillas/tema empaquetados ni CI de instancia (el motor no publica releases binarios todavía).
|
||||
|
||||
## Registrar al primer administrador
|
||||
|
||||
La llave pública identifica el registro del administrador. Para una auditoría fuerte, las operaciones posteriores deben usar `--commit` y la rama protegida debe rechazar commits sin firma válida.
|
||||
Sólo un `owner` activo puede hacerlo (ver `content/manuales/primer-administrador.md` para el procedimiento completo, incluido el alta del segundo administrador).
|
||||
|
||||
```bash
|
||||
./bin/clusterlog \
|
||||
--root . \
|
||||
--ssh-key ~/.ssh/id_ed25519.pub \
|
||||
--commit \
|
||||
admin add \
|
||||
--id ana \
|
||||
--name "Ana Torres" \
|
||||
--email ana@example.org \
|
||||
--key ~/.ssh/id_ed25519.pub \
|
||||
--roles owner,admin
|
||||
--roles admin
|
||||
```
|
||||
|
||||
Configure Git para firmar el primer commit completo:
|
||||
|
||||
```bash
|
||||
git init
|
||||
git config user.name "Ana Torres"
|
||||
git config user.email ana@example.org
|
||||
git config gpg.format ssh
|
||||
git config user.signingkey ~/.ssh/id_ed25519
|
||||
git config commit.gpgsign true
|
||||
git add .
|
||||
git commit -S -m "bootstrap: iniciar bitácora operativa"
|
||||
```
|
||||
|
||||
A partir de ahí:
|
||||
|
||||
```bash
|
||||
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit identity
|
||||
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit memory create --from-json examples/memory.json
|
||||
```
|
||||
|
||||
`identity` no modifica el repositorio; `--commit` sólo tiene efecto en comandos que escriben archivos.
|
||||
|
||||
## Flujo para Claude Code u otro agente
|
||||
|
||||
1. El agente recopila y redacta los hechos de la sesión, sin incluir credenciales ni valores sensibles.
|
||||
@@ -136,6 +112,8 @@ Las instrucciones completas para agentes están en [`AGENTS.md`](AGENTS.md) y [`
|
||||
|
||||
## Flujo de revisión semanal
|
||||
|
||||
Versión ejecutable (Runme) en [`runbooks/weekly-review.md`](runbooks/weekly-review.md); agenda y criterio en [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md):
|
||||
|
||||
```bash
|
||||
# Ver sólo las memorias que le faltan al administrador actual
|
||||
./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
|
||||
@@ -153,12 +131,10 @@ La página `/revision/` contiene la cola pendiente y, como apoyo a la reunión,
|
||||
## Comandos de calidad
|
||||
|
||||
```bash
|
||||
make test # pruebas unitarias e integración de la CLI
|
||||
make vet # análisis estático de Go
|
||||
make e2e # flujo completo en una copia temporal
|
||||
make sync-check # comprueba que la proyección está actualizada
|
||||
make validate-local # valida sin requerir Zola
|
||||
make site # tema + plantillas + estado + validación completa + sitio
|
||||
make smoke # contratos, sincronización y validación local
|
||||
make validate-local # sin requerir Zola
|
||||
make site # tema + estado + validación completa + sitio
|
||||
make ci # equivalente local de la canalización
|
||||
```
|
||||
|
||||
@@ -168,39 +144,36 @@ Los procedimientos frecuentes también existen como notebooks ejecutables,
|
||||
sin reemplazar ningún comando canónico: [`RUNBOOK.md`](RUNBOOK.md) es el
|
||||
punto de entrada. Requiere Runme `v3.17.4` (`make runme-install`) sólo si se
|
||||
quiere abrir como notebook o correr por CLI; cada celda es, además, un
|
||||
bloque de shell normal que sigue funcionando copiado a mano. Detalles,
|
||||
modelo de identidad y limitaciones conocidas en
|
||||
[`docs/RUNME.md`](docs/RUNME.md) y la decisión en
|
||||
[`docs/adr/0004-runme-sidecar.md`](docs/adr/0004-runme-sidecar.md).
|
||||
bloque de shell normal que sigue funcionando copiado a mano. Modelo de
|
||||
identidad, limitaciones y la decisión completa viven en el motor:
|
||||
[`docs/RUNME.md`](https://github.com/fenandosr/clusterlog/blob/main/docs/RUNME.md)
|
||||
y
|
||||
[ADR-0004](https://github.com/fenandosr/clusterlog/blob/main/docs/adr/0004-runme-sidecar.md).
|
||||
|
||||
## Estructura
|
||||
|
||||
```text
|
||||
cmd/clusterlog/ punto de entrada de la CLI
|
||||
internal/clusterlog/ modelo, comandos, validación y proyección
|
||||
content/ Markdown fuente por sección
|
||||
content/ Markdown fuente por sección (memorias, manuales, ...)
|
||||
data/admins.json registro de administradores y política
|
||||
data/review-events/ un archivo por revisión
|
||||
data/task-events/ un archivo por evento de tiempo/estado
|
||||
data/generated/ proyección regenerable para Zola
|
||||
templates/ plantillas locales compatibles con Zola actual
|
||||
static/ ajustes visuales y búsqueda
|
||||
scripts/ preparación del tema y smoke tests
|
||||
docs/ decisiones arquitectónicas y operación
|
||||
config/allowed_signers firmantes SSH autorizados (generado junto a admins.json)
|
||||
templates/, themes/ descargados por make templates/make theme — no versionados
|
||||
static/ ajustes visuales y búsqueda propios de esta instancia
|
||||
scripts/ fetch del motor, tema, smoke test, verificación de firmas
|
||||
runbooks/, RUNBOOK.md documentación ejecutable de esta instancia
|
||||
docs/ guion de la reunión semanal
|
||||
.clusterlog-version versión fijada del motor que usa esta instancia
|
||||
```
|
||||
|
||||
## Documentación clave
|
||||
|
||||
- [`HOJA_DE_PROYECTO.md`](HOJA_DE_PROYECTO.md): alcance, requisitos, fases y criterios de aceptación.
|
||||
- [`CLI.md`](CLI.md): contrato de comandos, JSON y códigos de salida.
|
||||
- [`SECURITY.md`](SECURITY.md): modelo de confianza y despliegue recomendado.
|
||||
- [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`.
|
||||
- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal.
|
||||
- [`docs/ADR.md`](docs/ADR.md): decisiones de diseño de **clusterlog** (la herramienta), no del clúster que administra; formato, campos y ejemplo. Registros en [`docs/adr/`](docs/adr/), sin equivalente en la UI del sitio ni en la CLI.
|
||||
- [`docs/RUNME.md`](docs/RUNME.md): documentación ejecutable con Runme — instalación, modelo de identidad y limitaciones. Punto de entrada: [`RUNBOOK.md`](RUNBOOK.md).
|
||||
- [`VALIDATION_REPORT.md`](VALIDATION_REPORT.md): pruebas ejecutadas, límites y controles pendientes.
|
||||
- [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0.
|
||||
- [`content/manuales/`](content/manuales/): procedimientos operativos de esta instancia (acceso SSH, correo, Caddy, alta de administradores...).
|
||||
- Documentación del motor (CLI, esquemas, ADRs, `bootstrap-instance`, Runme): [fenandosr/clusterlog](https://github.com/fenandosr/clusterlog).
|
||||
|
||||
## Estado del prototipo
|
||||
## Estado
|
||||
|
||||
La CLI compila con Go 1.23 y sus pruebas cubren creación por JSON, resolución de identidad, revisión por hash, proyección de tareas y eventos independientes. La construcción de Zola está configurada en la CI incluida. Cuando el registro ya contiene administradores activos, la CI también verifica cada commit del pull request contra `config/allowed_signers`; el scaffold vacío omite esa comprobación durante el bootstrap inicial.
|
||||
Instancia activa del clúster MCPS. La CI (`.gitea/workflows/ci.yml`) descarga el binario y las plantillas del motor por versión fijada, sincroniza y valida el contenido, construye con Zola y publica en `clusterlog.mcps-epcm.org` al hacer push a `main`. También verifica cada commit del pull request contra `config/allowed_signers`.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Notas de entrega — 0.1.0
|
||||
|
||||
**Fecha:** 2026-08-12
|
||||
|
||||
## Entrega
|
||||
|
||||
Esta versión convierte la idea inicial en un MVP ejecutable compuesto por:
|
||||
|
||||
- un sitio estático Zola de consulta;
|
||||
- la CLI `clusterlog` como única interfaz de escritura;
|
||||
- Markdown y eventos JSON anexables como datos fuente;
|
||||
- Git y firmas SSH como capa de auditoría;
|
||||
- una proyección determinista para la cola de revisión, tareas y proyectos.
|
||||
|
||||
## Capacidades principales
|
||||
|
||||
- Secciones de documentación, manuales, memorias, proyectos, tareas y revisión semanal.
|
||||
- Identidad de administradores mediante huella de llave pública SSH.
|
||||
- Registro generado de firmantes autorizados para Git.
|
||||
- Memorias con contexto, cambios, validación, riesgo y reversión.
|
||||
- Revisión por todos los administradores capturados al crear la memoria.
|
||||
- Invalidación automática de revisiones tras cualquier cambio de contenido.
|
||||
- Proyectos con objetivo, alcance, hitos y criterio de éxito.
|
||||
- Tareas con estado, prioridad, responsables, estimación, vencimiento y tiempo efectivo.
|
||||
- Eventos independientes para reducir conflictos de Git.
|
||||
- Contrato JSON, entrada por `stdin`, `--dry-run`, salida estable y códigos de error para agentes de IA.
|
||||
- Validación de contenido, referencias, proyección y secretos probables.
|
||||
- Búsqueda local sin servicios externos.
|
||||
- CI con pruebas, detector de carreras, validación Zola, construcción y control de firmas.
|
||||
|
||||
## Decisiones relevantes
|
||||
|
||||
- El navegador no modifica estado en el MVP; toda escritura pasa por la CLI y por Git.
|
||||
- La huella pública atribuye una identidad registrada, pero sólo una firma Git verificada demuestra posesión autorizada de la llave.
|
||||
- Cada revisión está ligada al SHA-256 de la versión exacta de la memoria.
|
||||
- La lista de revisores se captura al crear la memoria para que altas futuras no reabran historia antigua.
|
||||
- El tema `zola.386` aporta estilos y recursos, mientras las plantillas se mantienen localmente para aislar cambios de compatibilidad de Zola.
|
||||
|
||||
## Inicio recomendado
|
||||
|
||||
```bash
|
||||
make theme
|
||||
make build
|
||||
./bin/clusterlog --root . sync
|
||||
./bin/clusterlog --root . validate --skip-zola
|
||||
zola serve
|
||||
```
|
||||
|
||||
Antes de uso operativo, complete los pendientes de producción descritos en `HOJA_DE_PROYECTO.md`, `SECURITY.md` y `VALIDATION_REPORT.md`.
|
||||
+22
-45
@@ -1,9 +1,11 @@
|
||||
# RUNBOOK
|
||||
|
||||
Punto de entrada de la documentación ejecutable de `clusterlog`. Cada celda de
|
||||
abajo envuelve un comando canónico existente (`make`, `./bin/clusterlog`, un
|
||||
script en `scripts/`) — ninguna celda contiene lógica propia. Ver
|
||||
[`docs/RUNME.md`](docs/RUNME.md) para el porqué y el modelo de identidad, y
|
||||
Punto de entrada de la documentación ejecutable de esta **instancia** de
|
||||
`clusterlog` (MCPS). Cada celda envuelve un comando canónico existente
|
||||
(`make`, `./bin/clusterlog`, un script en `scripts/`) — ninguna celda
|
||||
contiene lógica propia. El desarrollo del motor mismo (compilar, probar,
|
||||
`bootstrap-instance`) vive en
|
||||
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); ver
|
||||
[`runbooks/README.md`](runbooks/README.md) para la convención completa de
|
||||
nombres y etiquetas.
|
||||
|
||||
@@ -12,45 +14,35 @@ comando de shell normal que también puede copiarse y ejecutarse a mano.
|
||||
|
||||
## Runbooks disponibles
|
||||
|
||||
- [`runbooks/clusterlog-development.md`](runbooks/clusterlog-development.md) — ciclo de desarrollo (build, test, vet, sync, validate, sitio).
|
||||
- [`runbooks/weekly-review.md`](runbooks/weekly-review.md) — versión ejecutable de la reunión semanal ([`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md) conserva la agenda).
|
||||
- [`runbooks/_template.md`](runbooks/_template.md) — plantilla para runbooks nuevos.
|
||||
|
||||
## Comprobaciones rápidas
|
||||
|
||||
```sh {"name":"clusterlog-runme-preflight","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
```sh {"name":"clusterlog-runme-preflight","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
set -euo pipefail
|
||||
test -f go.mod && test -f Makefile || { echo "ejecute esto desde la raíz de clusterlog" >&2; exit 1; }
|
||||
echo "go: $(go version)"
|
||||
echo "git: $(git --version)"
|
||||
test -f zola.toml && test -f .clusterlog-version || { echo "ejecute esto desde la raíz de la instancia" >&2; exit 1; }
|
||||
echo "clusterlog-version: $(cat .clusterlog-version)"
|
||||
if command -v zola >/dev/null 2>&1; then echo "zola: $(zola --version)"; else echo "zola: no instalado (validate --skip-zola sigue funcionando)"; fi
|
||||
if command -v runme >/dev/null 2>&1; then echo "runme: $(runme --version)"; else echo "runme: no instalado (make runme-install)"; fi
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-build","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make build
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-test","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make test
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-sync-check","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
```sh {"name":"clusterlog-sync-check","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make sync-check
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-validate-local","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
```sh {"name":"clusterlog-validate-local","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make validate-local
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-site-build","tag":"role-developer,env-local,risk-read-only","interactive":"false"}
|
||||
```sh {"name":"clusterlog-site-build","tag":"role-operator,env-local,risk-read-only","interactive":"false"}
|
||||
make site
|
||||
```
|
||||
|
||||
`clusterlog-site-build` no lleva `ci-safe`: `make site` incluye `make theme`,
|
||||
que clona el repositorio del tema por red. La CI principal
|
||||
(`.gitea/workflows/ci.yml`) ya construye el sitio en su propio paso; repetirlo
|
||||
aquí sería una segunda copia de la misma lógica, no una celda adicional útil.
|
||||
`clusterlog-site-build` no lleva `ci-safe`: descarga el tema y las plantillas
|
||||
del motor por red (`make theme templates`). La CI (`.gitea/workflows/ci.yml`)
|
||||
ya construye el sitio en su propio paso; repetirlo aquí sería una segunda
|
||||
copia de la misma lógica, no una celda adicional útil.
|
||||
|
||||
```sh {"name":"clusterlog-review-mine","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
|
||||
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY con la ruta a su llave pública}"
|
||||
@@ -67,29 +59,14 @@ ninguna llave privada real.
|
||||
|
||||
## Ejecutar
|
||||
|
||||
Ejemplo (nota: éste es texto de referencia, no una celda ejecutable —
|
||||
usa fence ` ```text ` a propósito para que Runme no lo liste como comando):
|
||||
|
||||
```text
|
||||
runme run --filename RUNBOOK.md clusterlog-build
|
||||
```
|
||||
|
||||
o, sin Runme instalado, copie y corra el comando dentro del bloque
|
||||
directamente. `make runme-ci` ejecuta:
|
||||
|
||||
```text
|
||||
runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts
|
||||
```
|
||||
|
||||
`runme run` sí acepta `--tag`/`--all` (verificado con el binario real de
|
||||
`v3.17.4`: un tag inexistente devuelve `No tasks to execute with the tag
|
||||
provided`, y `--tag ci-safe --all` corre exactamente las celdas marcadas
|
||||
`ci-safe`, en orden, deteniéndose en el primer fallo). Como es un filtro
|
||||
explícito por etiqueta de seguridad — no "correr todo" — este uso puntual sí
|
||||
es seguro.
|
||||
|
||||
Nunca ejecute `runme run --all --skip-prompts` **sin** `--tag ci-safe` sobre
|
||||
este archivo ni sobre `runbooks/weekly-review.md`: sin ese filtro, `--all`
|
||||
también incluiría celdas que mutan estado real. Ésas deben correrse una por
|
||||
una, a mano, después de revisar sus
|
||||
es lo que corre `make runme-ci`: exactamente las celdas marcadas `ci-safe`,
|
||||
en orden, deteniéndose en el primer fallo (verificado con Runme real; ver el
|
||||
ADR-0004 del motor). Nunca ejecute `runme run --all --skip-prompts` **sin**
|
||||
`--tag ci-safe` sobre este archivo ni sobre `runbooks/weekly-review.md`: sin
|
||||
ese filtro, `--all` también incluiría celdas que mutan estado real de esta
|
||||
instancia. Ésas deben correrse una por una, a mano, después de revisar sus
|
||||
prerrequisitos.
|
||||
|
||||
+3
-2
@@ -124,8 +124,9 @@ Ante contenido sensible o firma cuestionable:
|
||||
## Documentación ejecutable (Runme)
|
||||
|
||||
`RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como
|
||||
notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004). Esto no cambia el
|
||||
modelo de confianza: se agregan las siguientes reglas.
|
||||
notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004 en el
|
||||
[repositorio del motor](https://github.com/fenandosr/clusterlog)). Esto no
|
||||
cambia el modelo de confianza: se agregan las siguientes reglas.
|
||||
|
||||
- Ejecutar una celda de Runme equivale exactamente a ejecutar el comando
|
||||
local con los permisos del usuario que la corre. No hay una elevación ni
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
76b832471eead59133fbfffe63dd5e5ef1ec708a2e4c4b9b864d665350616ad5 bin/clusterlog
|
||||
@@ -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.
|
||||
@@ -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,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))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 2,
|
||||
"generated_at": "2026-08-25T01:01:15Z",
|
||||
"source_sha256": "24c5629b3f812fee92bb28270c4c245f3c4735eef59c8cdadb91d05aeda0813d",
|
||||
"generated_at": "2026-08-27T06:37:41Z",
|
||||
"source_sha256": "594001fb9ffeca21d3c081be211f5249968d3f43e857530f45c8d69e25381aec",
|
||||
"stats": {
|
||||
"active_admins": 1,
|
||||
"pending_reviews": 7,
|
||||
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
# Decisiones de arquitectura (ADR)
|
||||
|
||||
## Alcance
|
||||
|
||||
Un ADR (*Architecture Decision Record*) documenta una decisión de diseño sobre **clusterlog**: la herramienta, su modelo de datos, su CLI, su proyección estática y su cadena de confianza. No es el lugar para decisiones operativas del clúster que administra el equipo (topología de nodos, políticas de red, capacidad, etc.); esas pertenecen a **Manuales** o **Memorias** dentro del sitio.
|
||||
|
||||
Un ADR se justifica cuando la decisión:
|
||||
|
||||
- es costosa o difícil de revertir;
|
||||
- afecta el modelo de datos, la identidad/firma, o el contrato de la CLI;
|
||||
- descarta una alternativa razonable por un motivo que no es obvio leyendo el código;
|
||||
- va a ser preguntada de nuevo en el futuro ("¿por qué no usamos X?").
|
||||
|
||||
No se justifica para elección de librerías internas reversibles, estilo de código, o correcciones de bugs — eso va en el mensaje de commit o, si amerita contexto, en una memoria.
|
||||
|
||||
## Dónde viven y cómo se gestionan
|
||||
|
||||
- Los archivos están en [`docs/adr/`](adr/), uno por decisión.
|
||||
- **No se ven en la UI del sitio ni se gestionan con la CLI de `clusterlog`.** `docs/adr/` no forma parte de `content/` (lo que Zola proyecta) y no existe ningún comando `clusterlog adr *`. Son Markdown plano, editado a mano, versionado y revisado como el resto del repositorio.
|
||||
- Se crean, discuten y aprueban por revisión de código normal (pull request), igual que cualquier otro cambio al repositorio.
|
||||
|
||||
## Nombre de archivo
|
||||
|
||||
```text
|
||||
docs/adr/NNNN-titulo-en-kebab-case.md
|
||||
```
|
||||
|
||||
- `NNNN`: número secuencial de 4 dígitos, sin reutilizar ni renumerar al superar un ADR.
|
||||
- El slug es una versión corta del título en minúsculas, con guiones.
|
||||
|
||||
## Campos y secciones
|
||||
|
||||
Cada ADR sigue esta estructura, en este orden:
|
||||
|
||||
```markdown
|
||||
# ADR-NNNN: Título de la decisión
|
||||
|
||||
- **Estado:** propuesta | aceptada | rechazada | reemplazada por ADR-MMMM
|
||||
- **Fecha:** AAAA-MM-DD
|
||||
|
||||
## Contexto
|
||||
|
||||
## Decisión
|
||||
|
||||
## Consecuencias positivas
|
||||
|
||||
## Consecuencias negativas
|
||||
|
||||
## Alternativas descartadas (opcional)
|
||||
|
||||
## Regla de interpretación (opcional)
|
||||
```
|
||||
|
||||
| Campo/Sección | Obligatorio | Contenido |
|
||||
|---|---|---|
|
||||
| Título | sí | Frase corta que nombra la decisión, no el problema. |
|
||||
| Estado | sí | Uno de: `propuesta`, `aceptada`, `rechazada`, `reemplazada por ADR-MMMM`. Un ADR aceptado no se borra ni se edita para invertir la decisión; se agrega uno nuevo que lo reemplaza y se actualiza el estado del anterior. |
|
||||
| Fecha | sí | Fecha en que el estado actual entró en vigor. |
|
||||
| Contexto | sí | La fuerza o restricción que exige decidir: qué problema, qué tensión, qué no puede seguir igual. Sin esto, la decisión no se puede evaluar más adelante. |
|
||||
| Decisión | sí | Qué se decidió, en términos concretos y verificables (no aspiracionales). |
|
||||
| Consecuencias positivas | sí | Efectos deseables que se aceptan a cambio. |
|
||||
| Consecuencias negativas | sí | Costos, riesgos o deuda que la decisión introduce a sabiendas. Un ADR sin consecuencias negativas es sospechoso. |
|
||||
| Alternativas descartadas | no | Opciones consideradas y por qué se descartaron. Útil cuando la pregunta "¿por qué no X?" es previsible. |
|
||||
| Regla de interpretación | no | Aclaración normativa para evitar que la decisión se lea de forma laxa (ver ADR-0002 como ejemplo). |
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```markdown
|
||||
# ADR-0004: Límite de tamaño para eventos JSON individuales
|
||||
|
||||
- **Estado:** aceptada
|
||||
- **Fecha:** 2026-08-18
|
||||
|
||||
## Contexto
|
||||
|
||||
Los eventos de `data/review-events/` y `data/task-events/` son archivos JSON
|
||||
inmutables, uno por evento. Nada impide hoy que un evento incluya un adjunto
|
||||
grande o un volcado de log completo, lo que degradaría `clusterlog sync` y
|
||||
ensuciaría el historial Git con blobs pesados.
|
||||
|
||||
## Decisión
|
||||
|
||||
- Cada archivo de evento se limita a 64 KB.
|
||||
- La CLI rechaza la escritura si el JSON serializado excede el límite,
|
||||
con un mensaje que indica el tamaño real y el máximo permitido.
|
||||
- El límite se valida antes de firmar o comitear, nunca después.
|
||||
|
||||
## Consecuencias positivas
|
||||
|
||||
- El historial Git permanece liviano y rápido de clonar.
|
||||
- `clusterlog sync` tiene un tiempo de ejecución más predecible.
|
||||
- Fuerza a resumir en vez de adjuntar evidencia extensa.
|
||||
|
||||
## Consecuencias negativas
|
||||
|
||||
- Evidencia grande (logs completos, capturas) debe vivir fuera del evento
|
||||
y referenciarse por enlace o ticket.
|
||||
- El límite es arbitrario y puede requerir ajuste con el uso real.
|
||||
|
||||
## Alternativas descartadas
|
||||
|
||||
- Sin límite: expone el repositorio a crecer sin control y a bloquear CI.
|
||||
- Comprimir el evento: complica la lectura directa con herramientas
|
||||
estándar de Git, que es una propiedad central de ADR-0001.
|
||||
```
|
||||
|
||||
## Relación con otros tipos de contenido
|
||||
|
||||
- **ADR**: por qué el software está diseñado así. Vive en `docs/adr/`, no se sincroniza al sitio.
|
||||
- **Manual** (`content/manuales/`): cómo ejecutar un procedimiento repetible sobre el clúster o la bitácora. Sí se publica en el sitio.
|
||||
- **Memoria**: qué se hizo, cuándo, por quién, con qué validación y reversión, en una sesión concreta. Sí se publica en el sitio.
|
||||
|
||||
Si al escribir una memoria o un manual surge una decisión de diseño reutilizable que no está documentada, es señal de que falta un ADR — no de que deba explicarse de nuevo cada vez.
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
# Runme como sidecar ejecutable
|
||||
|
||||
Este documento explica cómo y por qué `clusterlog` usa Runme, qué garantiza y
|
||||
qué no. La decisión formal está en
|
||||
[`docs/adr/0004-runme-sidecar.md`](adr/0004-runme-sidecar.md); esto es la
|
||||
guía operativa del día a día.
|
||||
|
||||
## Qué es "sidecar" aquí
|
||||
|
||||
No es un contenedor sidecar de Kubernetes ni un servicio adicional. Es una
|
||||
capa opcional y adyacente: Runme abre `RUNBOOK.md` y `runbooks/**/*.md` como
|
||||
notebooks y ejecuta las celdas, pero cada celda sólo invoca `make`,
|
||||
`./bin/clusterlog` o un script existente en `scripts/`. Si Runme no está
|
||||
instalado, todo sigue funcionando exactamente igual copiando el comando de la
|
||||
celda a una terminal — no hay una segunda implementación de la lógica en
|
||||
ningún lado.
|
||||
|
||||
## Instalación
|
||||
|
||||
Versión fijada: **`v3.17.4`** (nunca `latest`).
|
||||
|
||||
```bash
|
||||
make runme-install
|
||||
```
|
||||
|
||||
Esto corre [`scripts/runme-install.sh`](../scripts/runme-install.sh), que:
|
||||
|
||||
- descarga el binario de `github.com/runmedev/runme` para su combinación de
|
||||
SO/arquitectura (Linux x86_64/arm64, macOS x86_64/arm64);
|
||||
- verifica su SHA-256 contra los valores publicados en el `checksums.txt` del
|
||||
release (verificados de forma independiente con `sha256sum` durante esta
|
||||
implementación, no copiados a ciegas del sitio de descargas);
|
||||
- instala en `$HOME/.local/bin` (o `$RUNME_INSTALL_DIR`), nunca sobre
|
||||
`bin/clusterlog` — el script aborta si detecta un binario `clusterlog` ahí;
|
||||
- no usa `curl | sh`.
|
||||
|
||||
**Windows**: el script no lo soporta. Descargue manualmente
|
||||
`runme_windows_{x86_64,arm64}.zip` desde la página de releases y verifique su
|
||||
checksum contra el mismo `checksums.txt` antes de usarlo.
|
||||
|
||||
`make runme-install` no es un paso obligatorio de ningún flujo existente:
|
||||
`make build`, `make test`, `clusterlog sync`/`validate` y `zola build` no lo
|
||||
requieren ni lo disparan.
|
||||
|
||||
## Cómo correrlo
|
||||
|
||||
```bash
|
||||
make runme-list # runme ls --filename RUNBOOK.md
|
||||
make runme-check # clusterlog validate --skip-zola + compatibilidad blanda con Runme
|
||||
make runme-ci # runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts
|
||||
```
|
||||
|
||||
`runme-check` nunca escribe en el repositorio. `runme-ci` sólo corre celdas
|
||||
etiquetadas `ci-safe`.
|
||||
|
||||
## El flag `--tag` es real (corrección respecto al diseño inicial)
|
||||
|
||||
Durante la investigación previa a implementar, la documentación pública de
|
||||
`docs.runme.dev` que se pudo consultar no mostraba un flag de filtrado por
|
||||
etiqueta en `runme run`, sólo `--filter` (regex por nombre). El plan inicial
|
||||
asumió por eso que la "seguridad de CI" tendría que vivir como una lista de
|
||||
nombres explícita en el `Makefile`.
|
||||
|
||||
Al instalar el binario real de `v3.17.4` y correr `runme run --help`, apareció
|
||||
`-t, --tag stringArray` ("Run from a specific tag"). Se verificó
|
||||
empíricamente:
|
||||
|
||||
```console
|
||||
$ runme run --tag totally-bogus-tag-xyz --filename RUNBOOK.md --dry-run
|
||||
could not execute command: No tasks to execute with the tag provided
|
||||
|
||||
$ runme run --tag ci-safe --all --skip-prompts --filename RUNBOOK.md
|
||||
► Running task clusterlog-runme-preflight...
|
||||
...
|
||||
► ✓ Task clusterlog-runme-preflight exited with code 0
|
||||
► Running task clusterlog-build...
|
||||
...
|
||||
```
|
||||
|
||||
Es decir: `--tag` sí filtra de verdad, y `--tag ci-safe --all` corre
|
||||
exactamente y sólo las celdas marcadas `ci-safe`, en el orden en que aparecen,
|
||||
deteniéndose en el primer fallo. `make runme-ci` usa esa forma. La lección
|
||||
que sí se mantiene del plan inicial: **nunca** ejecute `runme run --all
|
||||
--skip-prompts` sin `--tag ci-safe` — sin el filtro, `--all` corre también
|
||||
las celdas que mutan estado.
|
||||
|
||||
`runme ls --json` **no** expone las etiquetas de una celda en su salida (se
|
||||
verificó con `--json` contra `RUNBOOK.md`: el JSON trae `name`, `file`,
|
||||
`first_command`, `description`, `named`, `run_all`, pero no `tags`). Por eso
|
||||
la fuente de verdad para "qué es `ci-safe`" es el atributo `tag` en el propio
|
||||
Markdown, exigido y verificado por `clusterlog validate`
|
||||
(`internal/clusterlog/runbooks.go`), no algo que se pueda inspeccionar desde
|
||||
`runme ls`.
|
||||
|
||||
## `excludeFromRunAll` es la defensa real, no la etiqueta
|
||||
|
||||
`runme ls --json` también reveló que **toda** celda es elegible para
|
||||
`--all` (`"run_all": true`) salvo que declare explícitamente
|
||||
`"excludeFromRunAll": true`. Una celda `risk-mutating`/`risk-destructive` sin
|
||||
ese atributo seguiría corriendo si alguien ejecuta `runme run --all` sin
|
||||
filtro. Por eso `clusterlog validate` exige `excludeFromRunAll:true` +
|
||||
`interactive:true` + la etiqueta `manual-only` en toda celda
|
||||
`risk-mutating`/`risk-destructive` — la etiqueta sola no protege nada; el
|
||||
atributo que Runme sí respeta es `excludeFromRunAll`.
|
||||
|
||||
## Capas de identidad (no confundirlas)
|
||||
|
||||
### Capa 1 — Identidad de contenido (Runme)
|
||||
|
||||
El atributo `name` de una celda y cualquier Lifecycle Identity que Runme
|
||||
asigne identifican **el documento y la celda**, para poder seguir sus
|
||||
cambios. No identifican quién la ejecutó ni prueban que se ejecutó. No edite
|
||||
a mano un identificador Lifecycle Identity generado por Runme.
|
||||
|
||||
### Capa 2 — Evidencia local de sesión (Session Outputs)
|
||||
|
||||
Runme puede, si se habilita, guardar una copia del documento con la salida de
|
||||
cada celda (hora, duración, código de salida, hostname, salida). Esta
|
||||
integración **no habilita esa función**. Si en el futuro alguien la activa
|
||||
localmente:
|
||||
|
||||
- debe quedar fuera de Git, del sitio Zola y de cualquier Gist;
|
||||
- es evidencia auxiliar de que algo se ejecutó en una máquina, nunca prueba
|
||||
de autoría ni sustituto de una memoria en `clusterlog`.
|
||||
|
||||
**Limitación documentada**: la documentación pública de Runme
|
||||
(`docs.runme.dev/usage/auto-save/`) no fija un nombre de archivo exacto para
|
||||
estos Session Outputs en `v3.17.4` — sólo dice "genera un archivo, agréguelo
|
||||
a `.gitignore`". Por eso este repositorio **no** agrega un patrón amplio de
|
||||
`.gitignore` que podría ocultar Markdown legítimo sin darse cuenta.
|
||||
`clusterlog validate` sí revisa (best-effort, no exhaustivo):
|
||||
|
||||
- que no exista un directorio `.runme/` versionado en el árbol de trabajo;
|
||||
- que ningún archivo dentro de `runbooks/` tenga "output" en el nombre sin
|
||||
ser `_template.md` (señal, no prueba, de que podría ser una captura de
|
||||
sesión y no contenido fuente).
|
||||
|
||||
La defensa real sigue siendo procedimental: revise el diff antes de hacer
|
||||
`git add`, igual que con cualquier otro archivo generado.
|
||||
|
||||
### Capa 3 — Auditoría canónica de Clusterlog (sin cambios)
|
||||
|
||||
La atribución fuerte sigue siendo exactamente la de siempre:
|
||||
|
||||
```text
|
||||
huella de llave pública SSH
|
||||
+
|
||||
identidad registrada en data/admins.json
|
||||
+
|
||||
commit Git firmado por SSH
|
||||
+
|
||||
verificación en CI (clusterlog verify commit)
|
||||
```
|
||||
|
||||
Runme **no** está enlazado criptográficamente a esto de ninguna forma. Un
|
||||
runbook crítico que termine en una escritura real sigue el patrón de
|
||||
`AGENTS.md`: `clusterlog identity` → operación con la herramienta canónica →
|
||||
validación observada → `--dry-run` → `--commit` en una celda manual,
|
||||
interactiva y excluida de "Run All" → `clusterlog verify commit`.
|
||||
|
||||
Esta primera iteración **no** agrega un modelo nuevo de eventos de ejecución
|
||||
(por ejemplo, un futuro `clusterlog runbook record`). Si se necesita, se
|
||||
describe primero en un ADR con su propio modelo de amenazas y contrato JSON.
|
||||
|
||||
## Abrir y cerrar el modo notebook en VS Code
|
||||
|
||||
1. Instale la extensión recomendada en
|
||||
[`.vscode/extensions.json`](../.vscode/extensions.json)
|
||||
(`stateful.runme`).
|
||||
2. Abra `RUNBOOK.md` o cualquier archivo en `runbooks/`; VS Code lo mostrará
|
||||
como notebook.
|
||||
3. Para volver a verlo como texto: clic derecho en la pestaña → "Reopen
|
||||
Editor With..." → "Text Editor".
|
||||
|
||||
Ningún archivo `.md` fuera de `RUNBOOK.md`/`runbooks/` se fuerza a abrir como
|
||||
notebook.
|
||||
|
||||
## Limitaciones conocidas de este entorno
|
||||
|
||||
- **Zola local es 0.22.1**, no la `0.23.3` que documenta `README.md` y que
|
||||
instala la CI. `make validate-local` / `validate --skip-zola` no dependen
|
||||
de la versión exacta; `make site`/`zola build` sí podrían comportarse
|
||||
distinto localmente que en CI.
|
||||
- El escaneo de `clusterlog validate` sobre runbooks es un analizador de
|
||||
líneas con estado, no un parser completo de CommonMark: reconoce fences de
|
||||
una sola línea de atributos (`` ```sh {"name":...} ``). Fences de apertura
|
||||
multilínea no están soportados.
|
||||
- La detección de "target de make inexistente" busca el patrón textual
|
||||
`make <nombre>` dentro del cuerpo de la celda; puede marcar un falso
|
||||
positivo si ese texto aparece dentro de un comentario o un `echo` (no
|
||||
ocurre en los runbooks incluidos, pero es una limitación real del
|
||||
analizador, no una garantía de que nunca puede pasar).
|
||||
- La detección de Session Outputs es best-effort (ver arriba); no hay una
|
||||
garantía de que capture todo lo que Runme pudiera llegar a escribir en el
|
||||
futuro.
|
||||
- `runme ls` lista **cualquier** fence de código, incluso uno sin atributos
|
||||
y con lenguaje `text` usado a propósito como texto de referencia (se
|
||||
confirmó con el binario real: aparecen como filas "No" en la columna
|
||||
`NAMED`). No son ejecutables por nombre y `--tag ci-safe --all` los
|
||||
ignora correctamente, pero ensucian la salida de `runme ls`/`make
|
||||
runme-list`. No se encontró una forma de excluirlos sin dejar de mostrar
|
||||
también snippets ilustrativos legítimos.
|
||||
|
||||
## Fuera de alcance de esta iteración
|
||||
|
||||
Terminal web, ejecución desde el sitio Zola, acceso directo al clúster desde
|
||||
el navegador, un servicio Runme permanente, contenedor sidecar de
|
||||
Kubernetes, Runme Cloud, publicación de Gists, autenticación propia de
|
||||
Runme, reemplazo de Git/SSH, ejecución productiva en CI, conversión masiva de
|
||||
manuales existentes, y runbooks para infraestructura que no existe en este
|
||||
repositorio (bases de datos, escalamiento, rotación de secretos, despliegue a
|
||||
producción).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,216 +0,0 @@
|
||||
# ADR-0004: Runme como sidecar de documentación ejecutable
|
||||
|
||||
- **Estado:** aceptada
|
||||
- **Fecha:** 2026-08-26
|
||||
|
||||
## Contexto
|
||||
|
||||
Los procedimientos frecuentes de `clusterlog` (compilar, probar, sincronizar,
|
||||
validar, la revisión semanal) viven como bloques de comando dentro de
|
||||
Markdown que hay que copiar y pegar a mano. Se evaluó integrar Runme
|
||||
(`v3.17.4`, fijado) para poder abrir esos mismos procedimientos como
|
||||
notebooks ejecutables desde VS Code o su CLI, sin introducir un segundo lugar
|
||||
donde vivan los mismos comandos ni debilitar el modelo de identidad/auditoría
|
||||
existente (huella SSH + commit firmado + `clusterlog verify commit`).
|
||||
|
||||
## Decisión
|
||||
|
||||
Se integra Runme como una capa opcional y adyacente:
|
||||
|
||||
- `RUNBOOK.md` (raíz) y `runbooks/**/*.md` son los únicos documentos que
|
||||
llevan atributos de celda Runme.
|
||||
- Cada celda invoca exclusivamente `make`, `./bin/clusterlog` o un script ya
|
||||
existente en `scripts/`. Ninguna celda reimplementa validación,
|
||||
autorización, resolución de identidad, rollback o gestión de secretos.
|
||||
- `clusterlog validate` (no un script aparte) hace cumplir las reglas de
|
||||
nombres, etiquetas y aislamiento de celdas mutantes — ver
|
||||
`internal/clusterlog/runbooks.go`.
|
||||
- La instalación de Runme es opcional y reproducible
|
||||
(`scripts/runme-install.sh`, checksum verificado); ningún flujo existente
|
||||
(`make build`, `make test`, `clusterlog sync`/`validate`, `zola build`)
|
||||
depende de que Runme esté instalado.
|
||||
|
||||
## Significado de "sidecar"
|
||||
|
||||
No es un sidecar de Kubernetes ni un servicio adicional. Es un envoltorio: el
|
||||
humano o agente interactúa con Markdown+Runme, que a su vez llama `make`,
|
||||
`clusterlog` o un script — nunca al revés, y nunca con lógica propia.
|
||||
|
||||
## Ubicación de los runbooks
|
||||
|
||||
`RUNBOOK.md` en la raíz (índice) y `runbooks/` como carpeta dedicada, **fuera
|
||||
de `content/`**. Se confirmó en `zola.toml` que Zola no tiene un
|
||||
`content_dir` alterno: sólo construye desde `content/`. Poner los runbooks
|
||||
fuera de esa carpeta significa que Zola nunca los toca — cero riesgo de que
|
||||
un atributo de celda `{"name":...}` rompa el front matter TOML o el
|
||||
resaltado de sintaxis de una página publicada. `clusterlog validate` además
|
||||
hace cumplir esto activamente: si una celda de Runme aparece dentro de
|
||||
`content/`, es un error (`runme_cell_in_zola_content`), no sólo una
|
||||
recomendación.
|
||||
|
||||
## Relación con `content/manuales`
|
||||
|
||||
`content/manuales/` no se toca. Sigue siendo prosa + comandos de ejemplo para
|
||||
lectura humana en el sitio Zola, sin atributos de celda ni ninguna
|
||||
dependencia de Runme. Los runbooks nuevos no son una migración de los
|
||||
manuales existentes: son procedimientos de desarrollo/operación de
|
||||
`clusterlog` mismo que hoy no existían como documento ejecutable.
|
||||
|
||||
## Fuente de verdad
|
||||
|
||||
Opción elegida de las tres consideradas (sección 4.2 del pedido original):
|
||||
**un archivo raíz delgado que enlaza procedimientos canónicos**, aplicada
|
||||
así:
|
||||
|
||||
- `docs/WEEKLY_REVIEW.md` conserva la agenda y el criterio de la reunión
|
||||
(prosa) y enlaza a `runbooks/weekly-review.md` para los comandos
|
||||
ejecutables, en vez de mantener los mismos bloques de shell en dos
|
||||
archivos.
|
||||
- `README.md` enlaza `RUNBOOK.md` y `docs/RUNME.md` en vez de duplicar
|
||||
ejemplos de comandos.
|
||||
|
||||
No se copian bloques de comando manualmente entre dos documentos en ningún
|
||||
punto de esta integración.
|
||||
|
||||
## Convención de metadatos
|
||||
|
||||
Atributos de celda como objeto JSON de una sola línea después del lenguaje
|
||||
del fence: `` ```sh {"name":"...","tag":"...","interactive":"...",
|
||||
"excludeFromRunAll":true} ``. `clusterlog validate` decodifica ese objeto con
|
||||
`encoding/json` (no confía en una regex para interpretar el contenido; la
|
||||
regex sólo reconoce la *forma* de la línea de fence).
|
||||
|
||||
## Taxonomía de etiquetas
|
||||
|
||||
Cuatro categorías, tres obligatorias por celda:
|
||||
|
||||
| Categoría | Valores | Obligatoria |
|
||||
|---|---|---|
|
||||
| Audiencia | `role-developer`, `role-operator`, `role-reviewer`, `role-owner` | sí |
|
||||
| Entorno | `env-local`, `env-ci`, `env-staging`, `env-production` | sí |
|
||||
| Riesgo | `risk-read-only`, `risk-mutating`, `risk-destructive` | sí |
|
||||
| Ejecución automatizada | `ci-safe`, `manual-only` | no, mutuamente excluyente con riesgo mutante |
|
||||
|
||||
## Modelo de identidad
|
||||
|
||||
Tres capas, documentadas en detalle en `docs/RUNME.md`:
|
||||
|
||||
1. **Identidad de contenido** (Lifecycle Identity / `name` de Runme):
|
||||
identifica documento y celda, no a quien ejecuta.
|
||||
2. **Evidencia local de sesión** (Session Outputs, no habilitados aquí):
|
||||
auxiliar, nunca auditoría canónica.
|
||||
3. **Auditoría canónica de Clusterlog**: huella SSH + `data/admins.json` +
|
||||
commit firmado + `clusterlog verify commit`, sin cambios.
|
||||
|
||||
No se afirma ni se implementa ningún enlace criptográfico entre Runme y un
|
||||
commit. Un futuro `clusterlog runbook record` queda explícitamente fuera de
|
||||
esta iteración (ver "Trabajo futuro").
|
||||
|
||||
## Modelo de evidencia
|
||||
|
||||
Session Outputs permanecen desactivados por defecto en esta integración. Si
|
||||
se habilitan más adelante, deben quedar fuera de Git, del sitio Zola y de
|
||||
Gists, y tratarse como evidencia auxiliar redactable, nunca como prueba de
|
||||
autoría.
|
||||
|
||||
**Limitación reconocida**: la documentación pública de Runme para `v3.17.4`
|
||||
no fija el nombre exacto de archivo de un Session Output
|
||||
(`docs.runme.dev/usage/auto-save/` sólo recomienda "agréguelo a
|
||||
`.gitignore`" sin dar el patrón). Se optó explícitamente por **no** agregar
|
||||
un glob amplio e inseguro a `.gitignore` que pudiera ocultar Markdown
|
||||
legítimo. En su lugar, `clusterlog validate` aplica dos señales concretas
|
||||
(directorio `.runme/` versionado; archivos en `runbooks/` con "output" en el
|
||||
nombre) como defensa best-effort, documentada como tal.
|
||||
|
||||
## CI/CD
|
||||
|
||||
`.gitea/workflows/ci.yml` (Gitea Actions, no GitHub Actions — este repo no
|
||||
tiene `.github/`) gana un job nuevo y separado que no reemplaza
|
||||
`test-and-build`: instala Runme fijado en `v3.17.4` vía
|
||||
`scripts/runme-install.sh` (checksum verificado), corre `make runme-check`
|
||||
(reglas de `clusterlog validate`, obligatorias, más un chequeo blando de
|
||||
compatibilidad de Runme) y `make runme-ci`
|
||||
(`runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts`).
|
||||
Permisos mínimos (`contents: read`), `DO_NOT_TRACK=true`,
|
||||
`SCARF_NO_ANALYTICS=true`, sin llaves ni acceso a producción.
|
||||
|
||||
La CI original de Zola instala herramientas fijadas por `curl` + tar en vez
|
||||
de una Action de marketplace (ver el paso "Instalar Zola fijado" ya
|
||||
existente); Runme sigue el mismo patrón por consistencia, no por elección
|
||||
nueva.
|
||||
|
||||
## Seguridad
|
||||
|
||||
Ver el nuevo apartado en `SECURITY.md`. En resumen: una celda Runme equivale
|
||||
a ejecutar comandos locales con los permisos de quien la corre; una etiqueta
|
||||
no es control de acceso, `excludeFromRunAll`/`interactive` son defensas
|
||||
adicionales; Runme no lee llaves privadas; Runme Cloud, Gists y `runme open`
|
||||
expuesto a `0.0.0.0` quedan fuera de alcance.
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
1. **Celdas dentro de `content/manuales/`.** Descartada: arriesga el front
|
||||
matter TOML y el resaltado de Zola, y mezcla contenido para lectores con
|
||||
contenido para ejecución; no encaja con la opción 1 de fuente única
|
||||
(Markdown único compatible con ambos) porque el pedido de Zola y el de
|
||||
Runme divergen (TOML `+++` vs. atributos JSON de celda) sin un beneficio
|
||||
real, dado que `runbooks/` fuera de `content/` ya resuelve el conflicto
|
||||
sin ese riesgo.
|
||||
2. **Validación en un script Bash aparte en vez de `clusterlog validate`.**
|
||||
Descartada para las reglas estructurales: encajan limpio en el patrón
|
||||
existente de `ValidationReport`/`report.add(...)` y ya tenían pruebas
|
||||
unitarias equivalentes para páginas/eventos. Sí se mantiene un script
|
||||
(`scripts/runme-check.sh`) para el chequeo blando que sólo tiene sentido
|
||||
con el binario real de Runme instalado.
|
||||
3. **Selección de celdas CI-safe con una lista de nombres en el Makefile en
|
||||
vez del flag `--tag`.** Fue el plan inicial, basado en documentación
|
||||
pública de Runme que no mostraba un flag de filtrado por etiqueta. Al
|
||||
instalar el binario real de `v3.17.4` se confirmó que `runme run --tag`
|
||||
sí existe y filtra correctamente (ver `docs/RUNME.md`). Se adoptó
|
||||
`--tag ci-safe --all --skip-prompts` porque es la forma más simple que
|
||||
sigue siendo una allowlist explícita por etiqueta de seguridad, no un
|
||||
"correr todo" sin filtro.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
Positivas:
|
||||
|
||||
- Los procedimientos de desarrollo se pueden ejecutar como notebook sin
|
||||
perder la posibilidad de copiar/pegar el comando a mano.
|
||||
- `clusterlog validate` ahora también protege contra celdas mutantes mal
|
||||
etiquetadas, nombres duplicados y fugas de Runme hacia `content/`.
|
||||
- La instalación reproducible con checksum evita depender de `latest` o de
|
||||
`curl | sh`.
|
||||
|
||||
Negativas / riesgos aceptados:
|
||||
|
||||
- El analizador de runbooks es un escáner de líneas, no un parser CommonMark
|
||||
completo; hay casos borde documentados en `docs/RUNME.md`.
|
||||
- La detección de Session Outputs es best-effort, no una garantía.
|
||||
- Dos documentos nuevos (`RUNBOOK.md`, `docs/RUNME.md`) que hay que mantener
|
||||
sincronizados con la realidad del `Makefile`.
|
||||
|
||||
## Limitaciones
|
||||
|
||||
- Zola local (`0.22.1`) no coincide con la versión que exige `README.md` y
|
||||
que instala la CI (`0.23.3`); no se intentó resolver esto en esta
|
||||
iteración.
|
||||
- No se ejecutó ninguna celda `risk-mutating` de verdad durante la
|
||||
implementación (por diseño: requieren identidad real y quedan fuera del
|
||||
alcance de una demostración automatizada).
|
||||
- No existe hoy un enlace criptográfico entre una ejecución de Runme y un
|
||||
commit; quien necesite esa garantía debe seguir el flujo completo de
|
||||
`AGENTS.md` fuera de Runme.
|
||||
|
||||
## Trabajo futuro
|
||||
|
||||
- Evaluar un comando `clusterlog runbook record` que capture, con su propio
|
||||
contrato JSON y modelo de amenazas, evidencia de que una celda `ci-safe`
|
||||
corrió — sólo si el equipo decide que Session Outputs no bastan como
|
||||
evidencia auxiliar.
|
||||
- Migrar manuales existentes a runbooks ejecutables uno por uno, sólo cuando
|
||||
exista infraestructura real que respalde cada procedimiento (no antes).
|
||||
- Revisar si vale la pena que `runme ls --json` exponga etiquetas en una
|
||||
versión futura de Runme, para poder validar la relación entre "celdas
|
||||
etiquetadas ci-safe" y "celdas que `runme-ci` realmente ejecutó" de forma
|
||||
automatizada en vez de manual.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "missing_command", usageText(a.Options.Executable), nil)
|
||||
}
|
||||
command := args[0]
|
||||
if command == "help" || command == "--help" || command == "-h" {
|
||||
return a.printResult("help", usageText(a.Options.Executable))
|
||||
}
|
||||
if command == "version" {
|
||||
return a.printResult("version", map[string]any{"name": "clusterlog", "version": Version})
|
||||
}
|
||||
if command == "schema" {
|
||||
return a.runSchema(args[1:])
|
||||
}
|
||||
if command == "bootstrap-instance" {
|
||||
return a.runBootstrapInstance(args[1:])
|
||||
}
|
||||
|
||||
root, err := a.ResolveRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch command {
|
||||
case "identity":
|
||||
return a.runIdentity(root, args[1:])
|
||||
case "admin":
|
||||
return a.runAdmin(root, args[1:])
|
||||
case "memory":
|
||||
return a.runMemory(root, args[1:])
|
||||
case "review":
|
||||
return a.runReview(root, args[1:])
|
||||
case "task":
|
||||
return a.runTask(root, args[1:])
|
||||
case "project":
|
||||
return a.runProject(root, args[1:])
|
||||
case "content":
|
||||
return a.runContent(root, args[1:])
|
||||
case "sync":
|
||||
return a.runSync(root, args[1:])
|
||||
case "validate":
|
||||
return a.runValidate(root, args[1:])
|
||||
case "verify":
|
||||
return a.runVerify(root, args[1:])
|
||||
default:
|
||||
return NewError(ExitUsage, "unknown_command", "comando desconocido: "+command, usageText(a.Options.Executable))
|
||||
}
|
||||
}
|
||||
|
||||
func usageText(executable string) string {
|
||||
return fmt.Sprintf(`%s [opciones globales] <comando>
|
||||
|
||||
Opciones globales (antes del comando):
|
||||
--root DIR raíz del proyecto
|
||||
--json salida estable para agentes
|
||||
--ssh-key PATH llave pública SSH o ruta base
|
||||
--commit crea un commit Git SSH firmado con sólo los archivos modificados
|
||||
--dry-run valida y muestra el resultado sin escribir
|
||||
|
||||
Comandos:
|
||||
version
|
||||
bootstrap-instance --output DIR [--product-name NOMBRE] [--base-url URL]
|
||||
identity
|
||||
admin add | admin list
|
||||
memory create
|
||||
review list | review mark ID
|
||||
task create | start ID | stop ID | adjust ID | status ID | done ID | list
|
||||
project create | list
|
||||
content create
|
||||
sync [--check]
|
||||
validate
|
||||
verify commit [SHA]
|
||||
schema [memory|task|project|content]
|
||||
`, executable)
|
||||
}
|
||||
|
||||
func newFlagSet(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
return fs
|
||||
}
|
||||
|
||||
func wasSet(fs *flag.FlagSet, name string) bool {
|
||||
found := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func (a *App) runIdentity(root string, args []string) error {
|
||||
if len(args) != 0 {
|
||||
return NewError(ExitUsage, "unexpected_arguments", "identity no acepta argumentos", args)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("identity", identity)
|
||||
}
|
||||
|
||||
func (a *App) runAdmin(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", nil)
|
||||
}
|
||||
if args[0] == "list" {
|
||||
fs := newFlagSet("admin list")
|
||||
activeOnly := fs.Bool("active", false, "muestra sólo administradores activos")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
admins := []Admin{}
|
||||
for _, admin := range registry.Admins {
|
||||
if !*activeOnly || admin.Active {
|
||||
admins = append(admins, admin)
|
||||
}
|
||||
}
|
||||
return a.printResult("admin.list", map[string]any{
|
||||
"count": len(admins), "admins": admins, "review_policy": registry.ReviewPolicy,
|
||||
})
|
||||
}
|
||||
if args[0] != "add" {
|
||||
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", args[0])
|
||||
}
|
||||
fs := newFlagSet("admin add")
|
||||
id := fs.String("id", "", "identificador estable")
|
||||
name := fs.String("name", "", "nombre")
|
||||
email := fs.String("email", "", "correo")
|
||||
keyPath := fs.String("key", "", "llave pública")
|
||||
rolesCSV := fs.String("roles", "admin", "roles separados por coma")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
adminID := slugify(*id)
|
||||
if adminID == "" || strings.TrimSpace(*name) == "" || !strings.Contains(*email, "@") {
|
||||
return NewError(ExitUsage, "invalid_admin", "id, name y un email válido son obligatorios", nil)
|
||||
}
|
||||
path := firstNonEmpty(*keyPath, a.Options.SSHKey, os.Getenv("CLUSTERLOG_SSH_KEY"))
|
||||
if path == "" {
|
||||
return NewError(ExitUsage, "missing_ssh_key", "indique --key o --ssh-key para registrar la llave pública", nil)
|
||||
}
|
||||
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
|
||||
if err != nil {
|
||||
return NewError(ExitIdentity, "invalid_ssh_key", err.Error(), path)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var actor Identity
|
||||
if len(registry.Admins) > 0 {
|
||||
actor, err = a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasRole(actor.Admin, "owner") {
|
||||
return NewError(ExitIdentity, "not_authorized", "sólo un administrador con rol owner puede agregar administradores", actor.Admin.ID)
|
||||
}
|
||||
}
|
||||
for _, admin := range registry.Admins {
|
||||
if strings.EqualFold(admin.ID, adminID) || strings.EqualFold(admin.Email, *email) {
|
||||
return NewError(ExitConflict, "admin_exists", "ya existe un administrador con ese id o correo", admin.ID)
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
if key.Fingerprint == fingerprint {
|
||||
return NewError(ExitConflict, "key_exists", "la llave SSH ya pertenece a otro administrador", admin.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
roles := splitCSV(*rolesCSV)
|
||||
if len(registry.Admins) == 0 && !contains(roles, "owner") {
|
||||
roles = append(roles, "owner")
|
||||
}
|
||||
roles = uniqueSorted(roles)
|
||||
newAdmin := Admin{
|
||||
ID: adminID, Name: strings.TrimSpace(*name), Email: strings.TrimSpace(*email),
|
||||
Active: true, Roles: roles,
|
||||
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: canonical, AddedAt: a.Options.Now().UTC().Format(time.RFC3339Nano)}},
|
||||
}
|
||||
registry.Admins = append(registry.Admins, newAdmin)
|
||||
sort.Slice(registry.Admins, func(i, j int) bool { return registry.Admins[i].ID < registry.Admins[j].ID })
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "dry_run": true})
|
||||
}
|
||||
if err := saveAdmins(root, registry); err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commitIdentity := actor
|
||||
if len(registry.Admins) == 1 {
|
||||
commitIdentity = Identity{Admin: newAdmin, Fingerprint: fingerprint, PublicKey: canonical, PublicPath: publicPath, SigningKey: signingPath}
|
||||
}
|
||||
paths := []string{filepath.Join(root, "data", "admins.json"), filepath.Join(root, "config", "allowed_signers")}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, commitIdentity, "admin: add "+newAdmin.ID, paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnings := []string{}
|
||||
if !a.Options.Commit {
|
||||
warnings = append(warnings, "proteja data/admins.json y exija commits SSH firmados antes de usarlo en producción")
|
||||
}
|
||||
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "state": state.Stats, "commit": sha}, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runMemory(root string, args []string) error {
|
||||
if len(args) == 0 || args[0] != "create" {
|
||||
return NewError(ExitUsage, "memory_usage", "uso: memory create --title ... [--from-json ARCHIVO|-]", nil)
|
||||
}
|
||||
fs := newFlagSet("memory create")
|
||||
title := fs.String("title", "", "título")
|
||||
description := fs.String("description", "", "resumen")
|
||||
tags := fs.String("tags", "", "tags separados por coma")
|
||||
systems := fs.String("systems", "", "sistemas separados por coma")
|
||||
risk := fs.String("risk", "", "low|medium|high|critical")
|
||||
ticket := fs.String("ticket", "", "ticket relacionado")
|
||||
review := fs.Bool("review", true, "requiere revisión")
|
||||
reviewPolicy := fs.String("review-policy", "", "all-active|all-active-except-author")
|
||||
bodyFile := fs.String("body-file", "", "Markdown o -")
|
||||
fromJSON := fs.String("from-json", "", "JSON o -")
|
||||
duration := fs.String("duration", "", "minutos, 90m, 2h")
|
||||
agent := fs.String("agent", "", "agente que preparó el texto")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
var input MemoryInput
|
||||
if *fromJSON != "" {
|
||||
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if wasSet(fs, "title") {
|
||||
input.Title = *title
|
||||
}
|
||||
if wasSet(fs, "description") {
|
||||
input.Description = *description
|
||||
}
|
||||
if wasSet(fs, "tags") {
|
||||
input.Tags = splitCSV(*tags)
|
||||
}
|
||||
if wasSet(fs, "systems") {
|
||||
input.Systems = splitCSV(*systems)
|
||||
}
|
||||
if wasSet(fs, "risk") {
|
||||
input.Risk = *risk
|
||||
}
|
||||
if wasSet(fs, "ticket") {
|
||||
input.Ticket = *ticket
|
||||
}
|
||||
if wasSet(fs, "review") {
|
||||
input.ReviewRequired = review
|
||||
}
|
||||
if wasSet(fs, "review-policy") {
|
||||
input.ReviewPolicy = *reviewPolicy
|
||||
}
|
||||
if wasSet(fs, "agent") {
|
||||
input.Agent = *agent
|
||||
}
|
||||
if *duration != "" {
|
||||
minutes, err := parseDurationMinutes(*duration)
|
||||
if err != nil {
|
||||
return NewError(ExitUsage, "invalid_duration", err.Error(), nil)
|
||||
}
|
||||
input.DurationMinutes = minutes
|
||||
}
|
||||
if *bodyFile != "" {
|
||||
data, err := readAll(*bodyFile, a.Options.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.BodyMarkdown = string(data)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, warnings, err := a.createMemory(root, identity, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("memory.create", result, warnings...)
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdPath := filepath.FromSlash(result["path"].(string))
|
||||
paths := []string{createdPath}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("memory(%s): %s", result["entry_id"], input.Title), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["commit"] = sha
|
||||
result["pending_reviews"] = state.Stats.PendingReviews
|
||||
return a.printResult("memory.create", result, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runReview(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID [--note TEXTO]", nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
fs := newFlagSet("review list")
|
||||
mine := fs.Bool("mine", false, "sólo pendientes del administrador actual")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queue := state.ReviewQueue
|
||||
if *mine {
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := []ReviewQueueItem{}
|
||||
for _, item := range queue {
|
||||
if contains(item.MissingReviewers, identity.Admin.ID) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
queue = filtered
|
||||
}
|
||||
return a.printResult("review.list", map[string]any{"count": len(queue), "items": queue})
|
||||
case "mark":
|
||||
if len(args) < 2 {
|
||||
return NewError(ExitUsage, "missing_entry_id", "indique el ID de la memoria", nil)
|
||||
}
|
||||
entryID := args[1]
|
||||
fs := newFlagSet("review mark")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[2:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memory, _, err := findMemory(root, entryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !memory.ReviewRequired {
|
||||
return NewError(ExitValidation, "review_not_required", "la memoria no requiere revisión", memory.EntryID)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if memory.ReviewerSnapshot {
|
||||
if !contains(memory.Reviewers, identity.Admin.ID) {
|
||||
return NewError(ExitValidation, "review_not_required_for_admin", "el administrador no forma parte del conjunto de revisores capturado para esta memoria", map[string]any{
|
||||
"admin_id": identity.Admin.ID, "reviewers": memory.Reviewers,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
policy := firstNonEmpty(memory.ReviewPolicy, registry.ReviewPolicy.Default)
|
||||
if (policy == "all-active-except-author" || (policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)) && identity.Admin.ID == memory.AuthorID {
|
||||
return NewError(ExitValidation, "review_not_required_for_author", "la política excluye al autor de la revisión", identity.Admin.ID)
|
||||
}
|
||||
}
|
||||
log, err := loadReviewLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range log.Events {
|
||||
if normalizeID(event.EntryID) == memory.EntryID && event.AdminID == identity.Admin.ID && event.ContentSHA256 == memory.ContentSHA256 {
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("review.mark", map[string]any{"entry_id": memory.EntryID, "admin_id": identity.Admin.ID, "already_reviewed": true, "pending_reviews": state.Stats.PendingReviews})
|
||||
}
|
||||
}
|
||||
eventID, err := generateID("REV", a.Options.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := ReviewEvent{ID: eventID, EntryID: memory.EntryID, AdminID: identity.Admin.ID, Fingerprint: identity.Fingerprint, ReviewedAt: a.Options.Now().UTC().Format(time.RFC3339Nano), ContentSHA256: memory.ContentSHA256, Note: strings.TrimSpace(*note)}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("review.mark", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
eventPath, err := writeReviewEvent(root, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := []string{eventPath}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("review(%s): %s", memory.EntryID, identity.Admin.ID), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("review.mark", map[string]any{"event": event, "pending_reviews": state.Stats.PendingReviews, "commit": sha})
|
||||
default:
|
||||
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID", args[0])
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:scaffold
|
||||
var scaffoldFS embed.FS
|
||||
|
||||
const scaffoldRoot = "scaffold"
|
||||
|
||||
var scaffoldSections = []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"}
|
||||
|
||||
// runBootstrapInstance genera el esqueleto de una instancia nueva de
|
||||
// clusterlog (content/, data/, config/, zola.toml) en --output. No toca el
|
||||
// proyecto actual: --output es un directorio nuevo, casi siempre fuera de
|
||||
// este repositorio, así que este comando no pasa por ResolveRoot ni por el
|
||||
// mecanismo de --commit.
|
||||
func (a *App) runBootstrapInstance(args []string) error {
|
||||
fs := newFlagSet("bootstrap-instance")
|
||||
output := fs.String("output", "", "directorio donde crear la instancia nueva")
|
||||
productName := fs.String("product-name", "Bitácora Operativa", "nombre del producto para zola.toml")
|
||||
baseURL := fs.String("base-url", "https://clusterlog.example.org", "base_url de zola.toml; cámbielo antes de publicar")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
if strings.TrimSpace(*output) == "" {
|
||||
return NewError(ExitUsage, "missing_output", "indique --output con el directorio de la instancia nueva", nil)
|
||||
}
|
||||
root, err := filepath.Abs(*output)
|
||||
if err != nil {
|
||||
return NewError(ExitInternal, "internal", err.Error(), nil)
|
||||
}
|
||||
|
||||
if entries, statErr := os.ReadDir(root); statErr == nil {
|
||||
if len(entries) > 0 {
|
||||
return NewError(ExitConflict, "output_not_empty", "el directorio de salida ya existe y no está vacío", root)
|
||||
}
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return NewError(ExitInternal, "internal", statErr.Error(), root)
|
||||
}
|
||||
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("bootstrap-instance", map[string]any{
|
||||
"output": root,
|
||||
"product_name": *productName,
|
||||
"base_url": *baseURL,
|
||||
"dry_run": true,
|
||||
})
|
||||
}
|
||||
|
||||
created, err := writeScaffold(root, *productName, *baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{}}
|
||||
if err := saveAdmins(root, registry); err != nil {
|
||||
return NewError(ExitInternal, "internal", err.Error(), nil)
|
||||
}
|
||||
created = append(created,
|
||||
filepath.Join(root, "data", "admins.json"),
|
||||
filepath.Join(root, "config", "allowed_signers"),
|
||||
)
|
||||
sort.Strings(created)
|
||||
|
||||
return a.printResult("bootstrap-instance", map[string]any{
|
||||
"output": root,
|
||||
"engine_version": Version,
|
||||
"product_name": *productName,
|
||||
"base_url": *baseURL,
|
||||
"files_created": created,
|
||||
"next_steps": []string{
|
||||
"clusterlog --root " + root + " --ssh-key <su-llave.pub> admin add --id <id> --name <nombre> --email <correo> --key <su-llave.pub> --roles owner,admin",
|
||||
"clusterlog --root " + root + " sync",
|
||||
"clusterlog --root " + root + " validate --skip-zola",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeScaffold(root, productName, baseURL string) ([]string, error) {
|
||||
created := []string{}
|
||||
|
||||
title := "Bitácora Operativa"
|
||||
if strings.TrimSpace(productName) != "" {
|
||||
title = productName
|
||||
}
|
||||
|
||||
homeTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/content/_index.md.tmpl")
|
||||
if err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", "plantilla de portada faltante", err.Error())
|
||||
}
|
||||
homeContent := renderScaffoldTemplate(string(homeTmpl), map[string]string{"TITLE": title})
|
||||
homePath := filepath.Join(root, "content", "_index.md")
|
||||
if err := writeFileAtomic(homePath, []byte(homeContent), 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), homePath)
|
||||
}
|
||||
created = append(created, homePath)
|
||||
|
||||
for _, section := range scaffoldSections {
|
||||
src := scaffoldRoot + "/content/" + section + "/_index.md"
|
||||
data, err := scaffoldFS.ReadFile(src)
|
||||
if err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", "plantilla de scaffold faltante: "+src, err.Error())
|
||||
}
|
||||
dst := filepath.Join(root, "content", section, "_index.md")
|
||||
if err := writeFileAtomic(dst, data, 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), dst)
|
||||
}
|
||||
created = append(created, dst)
|
||||
}
|
||||
|
||||
zolaTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/zola.toml.tmpl")
|
||||
if err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", "plantilla zola.toml faltante", err.Error())
|
||||
}
|
||||
zolaContent := renderScaffoldTemplate(string(zolaTmpl), map[string]string{
|
||||
"BASE_URL": baseURL,
|
||||
"TITLE": title,
|
||||
"PRODUCT_NAME": productName,
|
||||
})
|
||||
zolaPath := filepath.Join(root, "zola.toml")
|
||||
if err := writeFileAtomic(zolaPath, []byte(zolaContent), 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), zolaPath)
|
||||
}
|
||||
created = append(created, zolaPath)
|
||||
|
||||
gitignoreTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/gitignore.tmpl")
|
||||
if err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", "plantilla .gitignore faltante", err.Error())
|
||||
}
|
||||
gitignorePath := filepath.Join(root, ".gitignore")
|
||||
if err := writeFileAtomic(gitignorePath, gitignoreTmpl, 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), gitignorePath)
|
||||
}
|
||||
created = append(created, gitignorePath)
|
||||
|
||||
readmeTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/README.md.tmpl")
|
||||
if err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", "plantilla README faltante", err.Error())
|
||||
}
|
||||
readmeContent := renderScaffoldTemplate(string(readmeTmpl), map[string]string{
|
||||
"TITLE": title,
|
||||
"PRODUCT_NAME": productName,
|
||||
})
|
||||
readmePath := filepath.Join(root, "README.md")
|
||||
if err := writeFileAtomic(readmePath, []byte(readmeContent), 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), readmePath)
|
||||
}
|
||||
created = append(created, readmePath)
|
||||
|
||||
versionPath := filepath.Join(root, ".clusterlog-version")
|
||||
if err := writeFileAtomic(versionPath, []byte(Version+"\n"), 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), versionPath)
|
||||
}
|
||||
created = append(created, versionPath)
|
||||
|
||||
for _, dir := range []string{filepath.Join(root, "data", "review-events"), filepath.Join(root, "data", "task-events")} {
|
||||
keep := filepath.Join(dir, ".gitkeep")
|
||||
if err := writeFileAtomic(keep, []byte{}, 0o644); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), keep)
|
||||
}
|
||||
created = append(created, keep)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "data", "generated"), 0o755); err != nil {
|
||||
return nil, NewError(ExitInternal, "internal", err.Error(), nil)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func renderScaffoldTemplate(text string, values map[string]string) string {
|
||||
for key, value := range values {
|
||||
text = strings.ReplaceAll(text, "{{"+key+"}}", value)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newBootstrapApp(t *testing.T, dryRun bool) *App {
|
||||
t.Helper()
|
||||
return New(Options{DryRun: dryRun, JSON: true, Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, Executable: "clusterlog"})
|
||||
}
|
||||
|
||||
func TestBootstrapInstanceCreatesExpectedFiles(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
out := filepath.Join(base, "instancia-nueva")
|
||||
app := newBootstrapApp(t, false)
|
||||
|
||||
err := app.runBootstrapInstance([]string{"--output", out, "--product-name", "Bitácora de Prueba", "--base-url", "https://clusterlog.prueba.org"})
|
||||
if err != nil {
|
||||
t.Fatalf("runBootstrapInstance falló: %v", err)
|
||||
}
|
||||
|
||||
for _, section := range scaffoldSections {
|
||||
path := filepath.Join(out, "content", section, "_index.md")
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Fatalf("falta %s: %v", path, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
homeData, err := os.ReadFile(filepath.Join(out, "content", "_index.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("falta la portada content/_index.md (necesaria para que zola build no falle): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(homeData), `title = "Bitácora de Prueba"`) {
|
||||
t.Fatalf("la portada no sustituyó TITLE: %s", homeData)
|
||||
}
|
||||
|
||||
zolaData, err := os.ReadFile(filepath.Join(out, "zola.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zola := string(zolaData)
|
||||
if !strings.Contains(zola, `base_url = "https://clusterlog.prueba.org"`) {
|
||||
t.Fatalf("zola.toml no tiene el base_url esperado: %s", zola)
|
||||
}
|
||||
if !strings.Contains(zola, `title = "Bitácora de Prueba"`) {
|
||||
t.Fatalf("zola.toml no tiene el title esperado: %s", zola)
|
||||
}
|
||||
if !strings.Contains(zola, `product_name = "Bitácora de Prueba"`) {
|
||||
t.Fatalf("zola.toml no tiene product_name esperado: %s", zola)
|
||||
}
|
||||
|
||||
var registry AdminRegistry
|
||||
adminsData, err := os.ReadFile(filepath.Join(out, "data", "admins.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(adminsData, ®istry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registry.Version != 1 || len(registry.Admins) != 0 || registry.ReviewPolicy.Default != "all-active" {
|
||||
t.Fatalf("registro de admins inesperado: %+v", registry)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(out, "config", "allowed_signers")); err != nil {
|
||||
t.Fatalf("falta config/allowed_signers: %v", err)
|
||||
}
|
||||
|
||||
versionData, err := os.ReadFile(filepath.Join(out, ".clusterlog-version"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(string(versionData)); trimmed != Version {
|
||||
t.Fatalf(".clusterlog-version = %q, se esperaba %q", trimmed, Version)
|
||||
}
|
||||
|
||||
for _, dir := range []string{"review-events", "task-events"} {
|
||||
if _, err := os.Stat(filepath.Join(out, "data", dir, ".gitkeep")); err != nil {
|
||||
t.Fatalf("falta data/%s/.gitkeep: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(out, "README.md")); err != nil {
|
||||
t.Fatalf("falta README.md: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(out, ".gitignore")); err != nil {
|
||||
t.Fatalf("falta .gitignore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInstanceScaffoldValidatesClean(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
out := filepath.Join(base, "instancia-nueva")
|
||||
app := newBootstrapApp(t, false)
|
||||
if err := app.runBootstrapInstance([]string{"--output", out}); err != nil {
|
||||
t.Fatalf("runBootstrapInstance falló: %v", err)
|
||||
}
|
||||
|
||||
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
|
||||
validateApp := &App{}
|
||||
registry, err := loadAdmins(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validateApp.validateAdmins(out, registry, &report)
|
||||
pages := validateApp.validatePages(out, registry, &report)
|
||||
validateApp.validateEvents(out, registry, pages, &report)
|
||||
if len(report.Errors) != 0 {
|
||||
t.Fatalf("el scaffold recién creado no debería tener errores de validate: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInstanceRefusesNonEmptyOutput(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
out := filepath.Join(base, "instancia-existente")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join(out, "algo.txt"), "ya había algo aquí")
|
||||
|
||||
app := newBootstrapApp(t, false)
|
||||
err := app.runBootstrapInstance([]string{"--output", out})
|
||||
if err == nil {
|
||||
t.Fatal("se esperaba un error por directorio no vacío")
|
||||
}
|
||||
cliErr, ok := err.(*CLIError)
|
||||
if !ok || cliErr.Kind != "output_not_empty" {
|
||||
t.Fatalf("se esperaba output_not_empty, se obtuvo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInstanceDryRunWritesNothing(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
out := filepath.Join(base, "instancia-dry-run")
|
||||
app := newBootstrapApp(t, true)
|
||||
if err := app.runBootstrapInstance([]string{"--output", out}); err != nil {
|
||||
t.Fatalf("runBootstrapInstance --dry-run falló: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
t.Fatal("--dry-run no debería haber creado el directorio de salida")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInstanceRequiresOutput(t *testing.T) {
|
||||
app := newBootstrapApp(t, false)
|
||||
err := app.runBootstrapInstance([]string{})
|
||||
if err == nil {
|
||||
t.Fatal("se esperaba un error por --output faltante")
|
||||
}
|
||||
cliErr, ok := err.(*CLIError)
|
||||
if !ok || cliErr.Kind != "missing_output" {
|
||||
t.Fatalf("se esperaba missing_output, se obtuvo: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func loadAdmins(root string) (AdminRegistry, error) {
|
||||
var registry AdminRegistry
|
||||
if err := loadJSON(filepath.Join(root, "data", "admins.json"), ®istry); err != nil {
|
||||
return registry, err
|
||||
}
|
||||
if registry.Version == 0 {
|
||||
registry.Version = 1
|
||||
}
|
||||
if registry.ReviewPolicy.Default == "" {
|
||||
registry.ReviewPolicy.Default = "all-active"
|
||||
registry.ReviewPolicy.IncludeAuthor = true
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func saveAdmins(root string, registry AdminRegistry) error {
|
||||
if err := writeJSONAtomic(filepath.Join(root, "data", "admins.json"), registry); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAllowedSigners(root, registry)
|
||||
}
|
||||
|
||||
func writeAllowedSigners(root string, registry AdminRegistry) error {
|
||||
lines := []string{
|
||||
"# Archivo generado por clusterlog. Contiene únicamente llaves públicas.",
|
||||
"# principal namespaces=\"git\" tipo base64",
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, admin := range registry.Admins {
|
||||
if !admin.Active {
|
||||
continue
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
fields := strings.Fields(key.PublicKey)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
|
||||
if !seen[line] {
|
||||
lines = append(lines, line)
|
||||
seen[line] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(lines[2:])
|
||||
return writeFileAtomic(filepath.Join(root, "config", "allowed_signers"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func parsePublicKeyLine(line string) (canonical, fingerprint string, err error) {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) < 2 {
|
||||
return "", "", fmt.Errorf("la llave pública debe tener formato OpenSSH: tipo base64 [comentario]")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(fields[1])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("base64 inválido en llave pública: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(decoded)
|
||||
fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
canonical = fields[0] + " " + fields[1]
|
||||
if len(fields) > 2 {
|
||||
canonical += " " + strings.Join(fields[2:], " ")
|
||||
}
|
||||
return canonical, fingerprint, nil
|
||||
}
|
||||
|
||||
func readPublicKey(path string) (canonical, fingerprint, publicPath, signingPath string, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", "", "", "", fmt.Errorf("ruta de llave vacía")
|
||||
}
|
||||
expanded, err := expandPath(path)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
original := expanded
|
||||
// user.signingkey suele apuntar a la llave privada; para identificar al
|
||||
// administrador siempre leemos la contraparte pública cuando existe.
|
||||
if !strings.HasSuffix(expanded, ".pub") {
|
||||
if _, pubErr := os.Stat(expanded + ".pub"); pubErr == nil {
|
||||
expanded += ".pub"
|
||||
}
|
||||
}
|
||||
info, statErr := os.Stat(expanded)
|
||||
if statErr != nil {
|
||||
return "", "", "", "", statErr
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", "", "", "", fmt.Errorf("%s es un directorio", expanded)
|
||||
}
|
||||
data, err := os.ReadFile(expanded)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
canonical, fingerprint, err = parsePublicKeyLine(string(data))
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
publicPath = expanded
|
||||
signingPath = expanded
|
||||
if original != expanded {
|
||||
signingPath = original
|
||||
} else if strings.HasSuffix(expanded, ".pub") {
|
||||
privateCandidate := strings.TrimSuffix(expanded, ".pub")
|
||||
if _, err := os.Stat(privateCandidate); err == nil {
|
||||
signingPath = privateCandidate
|
||||
}
|
||||
}
|
||||
return canonical, fingerprint, publicPath, signingPath, nil
|
||||
}
|
||||
|
||||
func expandPath(path string) (string, error) {
|
||||
if path == "~" || strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "~" {
|
||||
path = home
|
||||
} else {
|
||||
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
func candidateKeyPaths(explicit string) []string {
|
||||
candidates := []string{}
|
||||
if explicit != "" {
|
||||
candidates = append(candidates, explicit)
|
||||
}
|
||||
if env := os.Getenv("CLUSTERLOG_SSH_KEY"); env != "" {
|
||||
candidates = append(candidates, env)
|
||||
}
|
||||
if output, err := exec.Command("git", "config", "--get", "user.signingkey").Output(); err == nil {
|
||||
value := strings.TrimSpace(string(output))
|
||||
if strings.HasPrefix(value, "key::") {
|
||||
// Una llave inline se identifica, pero no sirve como ruta para firmar.
|
||||
} else if value != "" {
|
||||
candidates = append(candidates, value)
|
||||
}
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
for _, name := range []string{"id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"} {
|
||||
candidates = append(candidates, filepath.Join(home, ".ssh", name))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ResolveIdentity(root string) (Identity, error) {
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
attempted := []string{}
|
||||
for _, path := range candidateKeyPaths(a.Options.SSHKey) {
|
||||
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
|
||||
if err != nil {
|
||||
attempted = append(attempted, fmt.Sprintf("%s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
for _, admin := range registry.Admins {
|
||||
if !admin.Active {
|
||||
continue
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
if key.Fingerprint == fingerprint {
|
||||
return Identity{
|
||||
Admin: admin, Fingerprint: fingerprint, PublicKey: canonical,
|
||||
PublicPath: publicPath, SigningKey: signingPath,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attempted = append(attempted, fmt.Sprintf("%s: %s no está registrada", path, fingerprint))
|
||||
}
|
||||
return Identity{}, NewError(ExitIdentity, "identity_not_found",
|
||||
"ninguna llave SSH local coincide con un administrador activo", map[string]any{
|
||||
"attempted": attempted,
|
||||
"hint": "use --ssh-key ~/.ssh/id_ed25519.pub o CLUSTERLOG_SSH_KEY",
|
||||
})
|
||||
}
|
||||
|
||||
func adminByID(registry AdminRegistry, id string) (Admin, bool) {
|
||||
id = normalizeID(id)
|
||||
for _, admin := range registry.Admins {
|
||||
if normalizeID(admin.ID) == id {
|
||||
return admin, true
|
||||
}
|
||||
}
|
||||
return Admin{}, false
|
||||
}
|
||||
|
||||
func activeAdminIDs(registry AdminRegistry) []string {
|
||||
ids := []string{}
|
||||
for _, admin := range registry.Admins {
|
||||
if admin.Active {
|
||||
ids = append(ids, admin.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Este archivo valida los runbooks ejecutables (Runme) que viven en
|
||||
// RUNBOOK.md y runbooks/**/*.md. No ejecuta Runme ni ninguna celda: sólo
|
||||
// analiza el Markdown como texto para hacer cumplir las reglas de la
|
||||
// sección 12 del ADR 0004 antes de que alguien abra el documento.
|
||||
//
|
||||
// El análisis es un escáner de líneas con estado (no una única expresión
|
||||
// regular como defensa completa): la regex sólo reconoce la FORMA de una
|
||||
// línea de fence, y el contenido de atributos siempre se decodifica con
|
||||
// encoding/json.
|
||||
|
||||
var runmeFenceLine = regexp.MustCompile("^```([A-Za-z0-9_+-]*)\\s*(\\{.*\\})?\\s*$")
|
||||
|
||||
type runmeCell struct {
|
||||
Path string
|
||||
Line int
|
||||
Lang string
|
||||
Name string
|
||||
Tags []string
|
||||
Attrs map[string]any
|
||||
Body string
|
||||
}
|
||||
|
||||
func hasTag(tags []string, tag string) bool {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasTagPrefix(tags []string, prefix string) bool {
|
||||
for _, t := range tags {
|
||||
if strings.HasPrefix(t, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func attrBool(attrs map[string]any, key string) (bool, bool) {
|
||||
value, ok := attrs[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case string:
|
||||
return strings.EqualFold(v, "true"), true
|
||||
default:
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
|
||||
// scanRunmeCells recorre un documento Markdown y devuelve las celdas que
|
||||
// declaran atributos Runme con al menos un "name". Los fences sin atributos
|
||||
// (bloques de código normales) se ignoran para efectos de reglas de Runme,
|
||||
// pero igual se respeta su apertura/cierre para no desalinear el escaneo.
|
||||
func scanRunmeCells(path string, report *ValidationReport) []runmeCell {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
report.add("error", "runbook_read_failed", path, err.Error(), nil)
|
||||
return nil
|
||||
}
|
||||
cells := []runmeCell{}
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
|
||||
inFence := false
|
||||
var current *runmeCell
|
||||
var body strings.Builder
|
||||
for i, raw := range lines {
|
||||
lineNo := i + 1
|
||||
trimmed := strings.TrimRight(raw, " \t")
|
||||
if !inFence {
|
||||
m := runmeFenceLine.FindStringSubmatch(trimmed)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
inFence = true
|
||||
lang, attrsRaw := m[1], m[2]
|
||||
if attrsRaw == "" {
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
var attrs map[string]any
|
||||
if jsonErr := json.Unmarshal([]byte(attrsRaw), &attrs); jsonErr != nil {
|
||||
report.add("error", "runme_cell_invalid_attrs", path, "los atributos de la celda no son JSON válido: "+jsonErr.Error(), map[string]any{"line": lineNo})
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
name, _ := attrs["name"].(string)
|
||||
if name == "" {
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
tagsRaw, _ := attrs["tag"].(string)
|
||||
tags := []string{}
|
||||
for _, t := range strings.Split(tagsRaw, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
current = &runmeCell{Path: path, Line: lineNo, Lang: lang, Name: name, Tags: tags, Attrs: attrs}
|
||||
body.Reset()
|
||||
continue
|
||||
}
|
||||
// Dentro de un fence: una línea de cierre es ``` sola en la línea.
|
||||
if trimmed == "```" {
|
||||
inFence = false
|
||||
if current != nil {
|
||||
current.Body = body.String()
|
||||
cells = append(cells, *current)
|
||||
current = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if current != nil {
|
||||
body.WriteString(raw)
|
||||
body.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func findRunbookFiles(root string) ([]string, error) {
|
||||
paths := []string{}
|
||||
rootRunbook := filepath.Join(root, "RUNBOOK.md")
|
||||
if info, err := os.Stat(rootRunbook); err == nil && !info.IsDir() {
|
||||
paths = append(paths, rootRunbook)
|
||||
}
|
||||
base := filepath.Join(root, "runbooks")
|
||||
walkErr := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, path)
|
||||
return nil
|
||||
})
|
||||
if os.IsNotExist(walkErr) {
|
||||
walkErr = nil
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, walkErr
|
||||
}
|
||||
|
||||
func makefileTargets(root string) map[string]bool {
|
||||
targets := map[string]bool{}
|
||||
data, err := os.ReadFile(filepath.Join(root, "Makefile"))
|
||||
if err != nil {
|
||||
return targets
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
targetLine := regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*:`)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "\t") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(line), ".PHONY:") {
|
||||
for _, name := range strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), ".PHONY:")) {
|
||||
targets[name] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := targetLine.FindStringSubmatch(line); m != nil && !strings.HasPrefix(m[1], ".") {
|
||||
targets[m[1]] = true
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
var makeInvocation = regexp.MustCompile(`\bmake\s+([A-Za-z0-9_.-]+)`)
|
||||
var scriptInvocation = regexp.MustCompile(`\.\/(scripts\/[A-Za-z0-9_.-]+\.sh)`)
|
||||
var infraToolPattern = regexp.MustCompile(`\b(kubectl|terraform|helm)\b`)
|
||||
|
||||
const (
|
||||
runmeUnknownAttrWarning = "unknown_runme_attr"
|
||||
)
|
||||
|
||||
var knownRunmeAttrs = map[string]bool{
|
||||
"name": true, "tag": true, "interactive": true, "excludeFromRunAll": true,
|
||||
"id": true, "background": true, "promptEnv": true, "cwd": true, "category": true,
|
||||
}
|
||||
|
||||
// validateRunbooks aplica las reglas de la sección 12 del ADR 0004 sobre
|
||||
// RUNBOOK.md y runbooks/**/*.md, y confirma que ninguna celda de Runme se
|
||||
// haya colado en content/ (donde Zola sí publica).
|
||||
func (a *App) validateRunbooks(root string, report *ValidationReport) {
|
||||
files, err := findRunbookFiles(root)
|
||||
if err != nil {
|
||||
report.add("error", "runbook_walk_failed", filepath.Join(root, "runbooks"), err.Error(), nil)
|
||||
}
|
||||
|
||||
targets := makefileTargets(root)
|
||||
names := map[string]string{}
|
||||
var allCells []runmeCell
|
||||
|
||||
for _, path := range files {
|
||||
report.Checks["runbook_files"]++
|
||||
cells := scanRunmeCells(path, report)
|
||||
allCells = append(allCells, cells...)
|
||||
if findings := scanSecrets(mustReadFile(path)); len(findings) > 0 {
|
||||
report.add("error", "possible_secret", path, "el runbook parece incluir secretos", findings)
|
||||
}
|
||||
}
|
||||
|
||||
for _, cell := range allCells {
|
||||
report.Checks["runme_cells"]++
|
||||
if prior, ok := names[cell.Name]; ok && prior != cell.Path {
|
||||
report.add("error", "duplicate_runme_cell_name", cell.Path, "nombre de celda Runme duplicado en el repositorio", map[string]any{"name": cell.Name, "also_in": prior, "line": cell.Line})
|
||||
} else {
|
||||
names[cell.Name] = cell.Path
|
||||
}
|
||||
|
||||
hasRole := hasTagPrefix(cell.Tags, "role-")
|
||||
hasEnv := hasTagPrefix(cell.Tags, "env-")
|
||||
hasRisk := hasTagPrefix(cell.Tags, "risk-")
|
||||
if !hasRole || !hasEnv || !hasRisk {
|
||||
missing := []string{}
|
||||
if !hasRole {
|
||||
missing = append(missing, "role-*")
|
||||
}
|
||||
if !hasEnv {
|
||||
missing = append(missing, "env-*")
|
||||
}
|
||||
if !hasRisk {
|
||||
missing = append(missing, "risk-*")
|
||||
}
|
||||
report.add("error", "runme_cell_missing_tag_category", cell.Path, "a la celda le faltan categorías de etiqueta obligatorias", map[string]any{"name": cell.Name, "missing": missing, "line": cell.Line})
|
||||
}
|
||||
|
||||
mutating := hasTag(cell.Tags, "risk-mutating") || hasTag(cell.Tags, "risk-destructive")
|
||||
ciSafe := hasTag(cell.Tags, "ci-safe")
|
||||
manualOnly := hasTag(cell.Tags, "manual-only")
|
||||
|
||||
if mutating {
|
||||
interactive, hasInteractive := attrBool(cell.Attrs, "interactive")
|
||||
exclude, hasExclude := attrBool(cell.Attrs, "excludeFromRunAll")
|
||||
if !hasInteractive || !interactive || !hasExclude || !exclude {
|
||||
report.add("error", "runme_risk_cell_not_isolated", cell.Path, "una celda risk-mutating/risk-destructive debe ser interactive:true y excludeFromRunAll:true", map[string]any{"name": cell.Name, "line": cell.Line})
|
||||
}
|
||||
if !manualOnly {
|
||||
report.add("error", "runme_risk_cell_not_manual_only", cell.Path, "una celda risk-mutating/risk-destructive debe llevar la etiqueta manual-only", map[string]any{"name": cell.Name, "line": cell.Line})
|
||||
}
|
||||
}
|
||||
if ciSafe && mutating {
|
||||
report.add("error", "runme_ci_safe_risk_conflict", cell.Path, "una celda no puede ser ci-safe y risk-mutating/risk-destructive a la vez", map[string]any{"name": cell.Name, "line": cell.Line})
|
||||
}
|
||||
if ciSafe && manualOnly {
|
||||
report.add("error", "runme_contradictory_tags", cell.Path, "una celda no puede ser ci-safe y manual-only a la vez", map[string]any{"name": cell.Name, "line": cell.Line})
|
||||
}
|
||||
|
||||
for key := range cell.Attrs {
|
||||
if !knownRunmeAttrs[key] {
|
||||
report.add("warning", runmeUnknownAttrWarning, cell.Path, "atributo de celda Runme no reconocido por esta validación", map[string]any{"name": cell.Name, "attr": key, "line": cell.Line})
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range makeInvocation.FindAllStringSubmatch(cell.Body, -1) {
|
||||
if !targets[m[1]] {
|
||||
report.add("error", "runme_cell_unknown_make_target", cell.Path, "la celda invoca un target de make que no existe", map[string]any{"name": cell.Name, "target": m[1]})
|
||||
}
|
||||
}
|
||||
for _, m := range scriptInvocation.FindAllStringSubmatch(cell.Body, -1) {
|
||||
scriptPath := filepath.Join(root, filepath.FromSlash(m[1]))
|
||||
if _, statErr := os.Stat(scriptPath); statErr != nil {
|
||||
report.add("error", "runme_cell_unknown_script", cell.Path, "la celda invoca un script que no existe en el repositorio", map[string]any{"name": cell.Name, "script": m[1]})
|
||||
}
|
||||
}
|
||||
if infraToolPattern.MatchString(cell.Body) {
|
||||
report.add("warning", "runme_cell_infra_not_present", cell.Path, "la celda referencia una herramienta de infraestructura (kubectl/terraform/helm) que no tiene configuración real en este repositorio", map[string]any{"name": cell.Name})
|
||||
}
|
||||
}
|
||||
|
||||
a.checkNoRunmeCellsInZolaContent(root, report)
|
||||
a.checkRunmeSessionArtifacts(root, report)
|
||||
}
|
||||
|
||||
func mustReadFile(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// checkNoRunmeCellsInZolaContent hace cumplir la decisión de arquitectura:
|
||||
// las celdas de Runme viven fuera de content/, nunca dentro. Si alguna
|
||||
// página publicada por Zola trae atributos de celda Runme, es un error.
|
||||
func (a *App) checkNoRunmeCellsInZolaContent(root string, report *ValidationReport) {
|
||||
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
|
||||
for _, section := range sections {
|
||||
files, err := listMarkdownFiles(root, section)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, path := range files {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
for i, line := range strings.Split(string(data), "\n") {
|
||||
if m := runmeFenceLine.FindStringSubmatch(strings.TrimRight(line, " \t")); m != nil && m[2] != "" {
|
||||
var attrs map[string]any
|
||||
if json.Unmarshal([]byte(m[2]), &attrs) == nil {
|
||||
if _, ok := attrs["name"]; ok {
|
||||
report.add("error", "runme_cell_in_zola_content", path, "content/ es publicado por Zola; las celdas de Runme deben vivir en runbooks/", map[string]any{"line": i + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkRunmeSessionArtifacts es una defensa best-effort: la documentación
|
||||
// pública de Runme (docs.runme.dev/usage/auto-save) no fija un nombre de
|
||||
// archivo exacto para los Session Outputs en v3.17.4, así que en vez de un
|
||||
// glob amplio en .gitignore (que podría ocultar Markdown legítimo), se
|
||||
// buscan señales concretas y se documenta la limitación en docs/RUNME.md.
|
||||
func (a *App) checkRunmeSessionArtifacts(root string, report *ValidationReport) {
|
||||
runmeStateDir := filepath.Join(root, ".runme")
|
||||
if info, err := os.Stat(runmeStateDir); err == nil && info.IsDir() {
|
||||
report.add("error", "runme_state_dir_tracked", runmeStateDir, "existe un directorio .runme en el árbol de trabajo; nunca debe versionarse (estado local de Runme)", nil)
|
||||
}
|
||||
_ = filepath.WalkDir(filepath.Join(root, "runbooks"), func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(path))
|
||||
if strings.Contains(base, "output") && base != "_template.md" {
|
||||
report.add("warning", "possible_runme_session_output", path, "el nombre sugiere un Session Output de Runme capturado; confirme que es contenido fuente y no una salida de ejecución antes de conservarlo", nil)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newRunbookTestRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mustWriteTestFile(t, filepath.Join(root, "Makefile"), ".PHONY: build test\nbuild:\n\techo build\ntest:\n\techo test\n")
|
||||
mustWriteTestFile(t, filepath.Join(root, "scripts", "smoke.sh"), "#!/usr/bin/env bash\necho ok\n")
|
||||
return root
|
||||
}
|
||||
|
||||
func runRunbookValidation(t *testing.T, root string) *ValidationReport {
|
||||
t.Helper()
|
||||
report := &ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
|
||||
app := &App{}
|
||||
app.validateRunbooks(root, report)
|
||||
return report
|
||||
}
|
||||
|
||||
func hasErrorCode(report *ValidationReport, code string) bool {
|
||||
for _, issue := range report.Errors {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestValidateRunbooksAcceptsWellFormedCell(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
|
||||
"make build\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if len(report.Errors) != 0 {
|
||||
t.Fatalf("no se esperaban errores, hay: %+v", report.Errors)
|
||||
}
|
||||
if report.Checks["runme_cells"] != 1 {
|
||||
t.Fatalf("se esperaba contar 1 celda, hay %d", report.Checks["runme_cells"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksDetectsDuplicateNames(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
cell := "```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\nmake build\n```\n"
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+cell)
|
||||
mustWriteTestFile(t, filepath.Join(root, "runbooks", "clusterlog-development.md"), "# Dev\n\n"+cell)
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "duplicate_runme_cell_name") {
|
||||
t.Fatalf("se esperaba duplicate_runme_cell_name, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRequiresTagCategories(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-build\",\"tag\":\"ci-safe\",\"interactive\":\"false\"}\n"+
|
||||
"make build\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_cell_missing_tag_category") {
|
||||
t.Fatalf("se esperaba runme_cell_missing_tag_category, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRequiresIsolationForMutatingCells(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-memory-create\",\"tag\":\"role-operator,env-production,risk-mutating,manual-only\"}\n"+
|
||||
"make build\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_risk_cell_not_isolated") {
|
||||
t.Fatalf("se esperaba runme_risk_cell_not_isolated, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRejectsCiSafeMutatingConflict(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-memory-create\",\"tag\":\"role-operator,env-production,risk-mutating,ci-safe\",\"interactive\":\"true\",\"excludeFromRunAll\":true}\n"+
|
||||
"make build\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_ci_safe_risk_conflict") {
|
||||
t.Fatalf("se esperaba runme_ci_safe_risk_conflict, errores: %+v", report.Errors)
|
||||
}
|
||||
if !hasErrorCode(report, "runme_risk_cell_not_manual_only") {
|
||||
t.Fatalf("se esperaba runme_risk_cell_not_manual_only, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRejectsUnknownMakeTarget(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-deploy\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
|
||||
"make deploy-to-mars\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_cell_unknown_make_target") {
|
||||
t.Fatalf("se esperaba runme_cell_unknown_make_target, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRejectsUnknownScript(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-preflight\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
|
||||
"./scripts/does-not-exist.sh\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_cell_unknown_script") {
|
||||
t.Fatalf("se esperaba runme_cell_unknown_script, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksDetectsSecrets(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-leak\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
|
||||
"export password: hunter2-super-secret\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "possible_secret") {
|
||||
t.Fatalf("se esperaba possible_secret, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRejectsRunmeCellsInsideZolaContent(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "content", "manuales"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join(root, "content", "manuales", "ejemplo.md"), "+++\ntitle = \"x\"\n+++\n\n"+
|
||||
"```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\"}\n"+
|
||||
"make build\n"+
|
||||
"```\n")
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_cell_in_zola_content") {
|
||||
t.Fatalf("se esperaba runme_cell_in_zola_content, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksRejectsTrackedRunmeStateDir(t *testing.T) {
|
||||
root := newRunbookTestRoot(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".runme"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := runRunbookValidation(t, root)
|
||||
if !hasErrorCode(report, "runme_state_dir_tracked") {
|
||||
t.Fatalf("se esperaba runme_state_dir_tracked, errores: %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunbooksNoRunbooksIsNoop(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
report := runRunbookValidation(t, root)
|
||||
if len(report.Errors) != 0 || len(report.Warnings) != 0 {
|
||||
t.Fatalf("sin runbooks no debería haber hallazgos: %+v / %+v", report.Errors, report.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
# {{TITLE}}
|
||||
|
||||
Este repositorio es una **instancia de clusterlog**: contiene el contenido,
|
||||
los datos y la configuración de un deployment concreto, no el código del
|
||||
motor. Se generó con:
|
||||
|
||||
```bash
|
||||
clusterlog bootstrap-instance --output . --product-name "{{PRODUCT_NAME}}"
|
||||
```
|
||||
|
||||
## Qué hay aquí
|
||||
|
||||
- `content/` — documentación, manuales, memorias, proyectos y tareas de esta
|
||||
instancia. Empieza con las seis secciones vacías (`_index.md` únicamente).
|
||||
- `data/admins.json`, `config/allowed_signers` — registro de administradores,
|
||||
vacío hasta el primer `admin add`.
|
||||
- `zola.toml` — configuración de este deployment (`base_url`, nombre del
|
||||
producto). Cámbiela antes de publicar.
|
||||
- `.clusterlog-version` — versión del motor `clusterlog` usada para generar
|
||||
este esqueleto. Manténgala sincronizada con el binario que use su CI.
|
||||
|
||||
## Primeros pasos
|
||||
|
||||
1. Cambie `base_url` en `zola.toml` por el dominio real de este deployment.
|
||||
2. Registre al primer administrador (recibe `owner` automáticamente):
|
||||
|
||||
```bash
|
||||
clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub admin add \
|
||||
--id ana --name "Ana Torres" --email ana@example.org \
|
||||
--key ~/.ssh/id_ed25519.pub --roles owner,admin
|
||||
```
|
||||
|
||||
3. Inicialice Git y firme el primer commit (vea la sección "Registrar al
|
||||
primer administrador" en la documentación del motor para el detalle
|
||||
completo de `git config`/`commit -S`).
|
||||
4. Genere la proyección y valide:
|
||||
|
||||
```bash
|
||||
clusterlog --root . sync
|
||||
clusterlog --root . validate --skip-zola
|
||||
```
|
||||
|
||||
## Pendiente (no incluido por este comando)
|
||||
|
||||
- Plantillas Tera y el tema `zola.386`: todavía se obtienen desde un checkout
|
||||
del repositorio del motor (`make theme` / `scripts/bootstrap-theme.sh`).
|
||||
Empaquetarlos en un release descargable del motor es trabajo de una fase
|
||||
posterior.
|
||||
- Un workflow de CI para esta instancia (que descargue el binario del motor
|
||||
por versión fijada) no se generó todavía: el motor aún no publica releases
|
||||
binarios. Se agrega en cuanto exista ese release.
|
||||
@@ -1,14 +0,0 @@
|
||||
+++
|
||||
title = "{{TITLE}}"
|
||||
description = "Punto único para entender, operar y revisar los cambios técnicos del clúster."
|
||||
sort_by = "date"
|
||||
template = "index.html"
|
||||
+++
|
||||
|
||||
La bitácora reúne conocimiento estable y actividad operativa en un mismo sitio, sin mezclar sus ciclos de vida:
|
||||
|
||||
- **Documentación** explica cómo está construido el clúster.
|
||||
- **Manuales** indican cómo ejecutar procedimientos repetibles.
|
||||
- **Memorias** registran qué se cambió, quién lo hizo y cómo se validó.
|
||||
- **Proyectos** agrupan objetivos, alcance e hitos.
|
||||
- **Tareas** muestran trabajo pendiente, responsables y tiempo real.
|
||||
@@ -1,9 +0,0 @@
|
||||
+++
|
||||
title = "Documentación"
|
||||
description = "Arquitectura, decisiones, inventarios, políticas y explicaciones técnicas duraderas."
|
||||
sort_by = "weight"
|
||||
template = "section.html"
|
||||
page_template = "page.html"
|
||||
+++
|
||||
|
||||
Aquí vive el conocimiento descriptivo del clúster: el **qué**, el **por qué** y las relaciones entre componentes.
|
||||
@@ -1,9 +0,0 @@
|
||||
+++
|
||||
title = "Manuales"
|
||||
description = "Runbooks y procedimientos operativos verificables, con prerrequisitos, pasos y reversión."
|
||||
sort_by = "weight"
|
||||
template = "section.html"
|
||||
page_template = "page.html"
|
||||
+++
|
||||
|
||||
Los manuales deben ser ejecutables por una persona distinta de quien los escribió y declarar claramente sus condiciones de seguridad.
|
||||
@@ -1,10 +0,0 @@
|
||||
+++
|
||||
title = "Memorias"
|
||||
description = "Historia cronológica y auditable de intervenciones, cambios, incidentes y correcciones."
|
||||
sort_by = "date"
|
||||
template = "memories.html"
|
||||
page_template = "memory.html"
|
||||
generate_feeds = true
|
||||
+++
|
||||
|
||||
Cada entrada describe una sesión de trabajo o una unidad de cambio. Las entradas sujetas a revisión aparecen también en **Revisión semanal**.
|
||||
@@ -1,9 +0,0 @@
|
||||
+++
|
||||
title = "Proyectos"
|
||||
description = "Objetivos compartidos, alcance, responsables, hitos y criterios de éxito."
|
||||
sort_by = "date"
|
||||
template = "projects.html"
|
||||
page_template = "project.html"
|
||||
+++
|
||||
|
||||
Los proyectos dan contexto a las tareas y permiten explicar por qué existe cada línea de trabajo.
|
||||
@@ -1,8 +0,0 @@
|
||||
+++
|
||||
title = "Revisión semanal"
|
||||
description = "Memorias que aún requieren confirmación de uno o más administradores."
|
||||
template = "review.html"
|
||||
in_search_index = false
|
||||
+++
|
||||
|
||||
Esta página es la agenda operativa de la reunión semanal. Una memoria desaparece cuando todos los revisores requeridos han registrado su revisión para el hash actual del contenido.
|
||||
@@ -1,9 +0,0 @@
|
||||
+++
|
||||
title = "Tareas"
|
||||
description = "Trabajo visible para el equipo, con prioridad, estimación, fecha objetivo y tiempo acumulado."
|
||||
sort_by = "date"
|
||||
template = "tasks.html"
|
||||
page_template = "task.html"
|
||||
+++
|
||||
|
||||
El estado actual y el tiempo real se calculan a partir de eventos anexables; la definición original de la tarea permanece legible en Markdown.
|
||||
@@ -1,5 +0,0 @@
|
||||
/public/
|
||||
/bin/
|
||||
/themes/zola.386/
|
||||
.DS_Store
|
||||
*.tmp
|
||||
@@ -1,53 +0,0 @@
|
||||
base_url = "{{BASE_URL}}"
|
||||
title = "{{TITLE}}"
|
||||
description = "Documentación, manuales, memorias de cambio, proyectos y tareas del equipo de soporte."
|
||||
default_language = "es"
|
||||
theme = "zola.386"
|
||||
output_dir = "public"
|
||||
compile_sass = true
|
||||
minify_html = true
|
||||
generate_feeds = true
|
||||
feed_filenames = ["atom.xml", "rss.xml"]
|
||||
build_search_index = true
|
||||
generate_sitemap = true
|
||||
generate_robots_txt = false
|
||||
|
||||
taxonomies = [
|
||||
{ name = "categories" },
|
||||
{ name = "tags", feed = true },
|
||||
{ name = "systems" },
|
||||
]
|
||||
|
||||
[markdown]
|
||||
external_links_target_blank = true
|
||||
external_links_no_referrer = true
|
||||
smart_punctuation = true
|
||||
github_alerts = true
|
||||
insert_anchor_links = "right"
|
||||
|
||||
[markdown.highlighting]
|
||||
error_on_missing_language = false
|
||||
style = "inline"
|
||||
theme = "vitesse-black"
|
||||
|
||||
[search]
|
||||
include_title = true
|
||||
include_description = true
|
||||
include_date = true
|
||||
include_path = true
|
||||
include_content = true
|
||||
truncate_content_length = 12000
|
||||
index_format = "elasticlunr_json"
|
||||
|
||||
[slugify]
|
||||
paths = "on"
|
||||
taxonomies = "on"
|
||||
anchors = "on"
|
||||
paths_keep_dates = false
|
||||
|
||||
[extra]
|
||||
product_name = "{{PRODUCT_NAME}}"
|
||||
product_short_name = "clusterlog"
|
||||
repository_label = "Repositorio interno"
|
||||
review_meeting_label = "Revisión semanal"
|
||||
footer_text = "Fuente auditable: Markdown + eventos JSON + Git"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}},
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Version se puede sobreescribir en tiempo de build con:
|
||||
//
|
||||
// -ldflags "-X example.org/bitacora-cluster/internal/clusterlog.Version=v0.2.0"
|
||||
//
|
||||
// Ver la variable CLUSTERLOG_VERSION en el Makefile.
|
||||
var Version = "0.1.0"
|
||||
|
||||
const (
|
||||
ExitOK = 0
|
||||
ExitUsage = 2
|
||||
ExitIdentity = 3
|
||||
ExitValidation = 4
|
||||
ExitConflict = 5
|
||||
ExitGit = 6
|
||||
ExitInternal = 10
|
||||
)
|
||||
|
||||
type CLIError struct {
|
||||
Code int
|
||||
Kind string
|
||||
Message string
|
||||
Details any
|
||||
}
|
||||
|
||||
func (e *CLIError) Error() string { return e.Message }
|
||||
|
||||
func NewError(code int, kind, message string, details any) *CLIError {
|
||||
return &CLIError{Code: code, Kind: kind, Message: message, Details: details}
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Root string
|
||||
JSON bool
|
||||
SSHKey string
|
||||
Commit bool
|
||||
DryRun bool
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Stdin io.Reader
|
||||
Now func() time.Time
|
||||
Executable string
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Options Options
|
||||
}
|
||||
|
||||
func New(options Options) *App {
|
||||
if options.Stdout == nil {
|
||||
options.Stdout = os.Stdout
|
||||
}
|
||||
if options.Stderr == nil {
|
||||
options.Stderr = os.Stderr
|
||||
}
|
||||
if options.Stdin == nil {
|
||||
options.Stdin = os.Stdin
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if options.Executable == "" {
|
||||
options.Executable = "clusterlog"
|
||||
}
|
||||
return &App{Options: options}
|
||||
}
|
||||
|
||||
type ReviewPolicy struct {
|
||||
Default string `json:"default"`
|
||||
IncludeAuthor bool `json:"include_author"`
|
||||
}
|
||||
|
||||
type SSHKey struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
AddedAt string `json:"added_at,omitempty"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Active bool `json:"active"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
SSHKeys []SSHKey `json:"ssh_keys"`
|
||||
}
|
||||
|
||||
type AdminRegistry struct {
|
||||
Version int `json:"version"`
|
||||
ReviewPolicy ReviewPolicy `json:"review_policy"`
|
||||
Admins []Admin `json:"admins"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
Admin Admin `json:"admin"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
PublicPath string `json:"public_key_path"`
|
||||
SigningKey string `json:"signing_key_path"`
|
||||
}
|
||||
|
||||
type ReviewEvent struct {
|
||||
ID string `json:"id"`
|
||||
EntryID string `json:"entry_id"`
|
||||
AdminID string `json:"admin_id"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ReviewedAt string `json:"reviewed_at"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type ReviewLog struct {
|
||||
Version int `json:"version"`
|
||||
Events []ReviewEvent `json:"events"`
|
||||
}
|
||||
|
||||
type TaskEvent struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Type string `json:"type"`
|
||||
AdminID string `json:"admin_id"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
At string `json:"at"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Minutes int `json:"minutes,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type TaskLog struct {
|
||||
Version int `json:"version"`
|
||||
Events []TaskEvent `json:"events"`
|
||||
}
|
||||
|
||||
type PageMeta struct {
|
||||
Path string
|
||||
Body string
|
||||
Root map[string]any
|
||||
Taxonomies map[string][]string
|
||||
Extra map[string]any
|
||||
RawFront string
|
||||
ContentHash string
|
||||
}
|
||||
|
||||
func (p PageMeta) String(key string) string {
|
||||
if v, ok := p.Root[key]; ok {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraString(key string) string {
|
||||
if v, ok := p.Extra[key]; ok {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraBool(key string) bool {
|
||||
v, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraInt(key string) int {
|
||||
v, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraStrings(key string) []string {
|
||||
v, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch values := v.(type) {
|
||||
case []string:
|
||||
return values
|
||||
case []any:
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
out = append(out, fmt.Sprint(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type MemoryRecord struct {
|
||||
EntryID string `json:"entry_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorFingerprint string `json:"author_fingerprint"`
|
||||
Risk string `json:"risk"`
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
ReviewPolicy string `json:"review_policy"`
|
||||
ReviewerSnapshot bool `json:"reviewer_snapshot"`
|
||||
Reviewers []string `json:"reviewers"`
|
||||
Tags []string `json:"tags"`
|
||||
Systems []string `json:"systems"`
|
||||
Slug string `json:"slug"`
|
||||
Permalink string `json:"permalink"`
|
||||
SourcePath string `json:"source_path"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
}
|
||||
|
||||
type ReviewQueueItem struct {
|
||||
EntryID string `json:"entry_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Risk string `json:"risk"`
|
||||
Tags []string `json:"tags"`
|
||||
Systems []string `json:"systems"`
|
||||
Permalink string `json:"permalink"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
ReviewedBy []string `json:"reviewed_by"`
|
||||
MissingReviewers []string `json:"missing_reviewers"`
|
||||
RequiredCount int `json:"required_count"`
|
||||
ReviewedCount int `json:"reviewed_count"`
|
||||
ConfigurationError string `json:"configuration_error,omitempty"`
|
||||
}
|
||||
|
||||
type TaskState struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Assignees []string `json:"assignees"`
|
||||
EstimateMinutes int `json:"estimate_minutes"`
|
||||
ActualMinutes int `json:"actual_minutes"`
|
||||
Due string `json:"due,omitempty"`
|
||||
RunningBy []string `json:"running_by"`
|
||||
Slug string `json:"slug"`
|
||||
Permalink string `json:"permalink"`
|
||||
SourcePath string `json:"source_path"`
|
||||
}
|
||||
|
||||
type ProjectState struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Owners []string `json:"owners"`
|
||||
TargetDate string `json:"target_date,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Permalink string `json:"permalink"`
|
||||
SourcePath string `json:"source_path"`
|
||||
}
|
||||
|
||||
type SiteStats struct {
|
||||
ActiveAdmins int `json:"active_admins"`
|
||||
PendingReviews int `json:"pending_reviews"`
|
||||
OpenTasks int `json:"open_tasks"`
|
||||
ActiveProjects int `json:"active_projects"`
|
||||
}
|
||||
|
||||
type SiteState struct {
|
||||
Version int `json:"version"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
Stats SiteStats `json:"stats"`
|
||||
ReviewQueue []ReviewQueueItem `json:"review_queue"`
|
||||
RecentMemories []MemoryRecord `json:"recent_memories"`
|
||||
Tasks []TaskState `json:"tasks"`
|
||||
Projects []ProjectState `json:"projects"`
|
||||
}
|
||||
|
||||
type ResultEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error any `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) printResult(command string, data any, warnings ...string) error {
|
||||
if a.Options.JSON {
|
||||
enc := json.NewEncoder(a.Options.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings})
|
||||
}
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
fmt.Fprintln(a.Options.Stdout, v)
|
||||
default:
|
||||
bytes, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(a.Options.Stdout, string(bytes))
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrintError(w io.Writer, jsonMode bool, err error) int {
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
cliErr = NewError(ExitInternal, "internal", err.Error(), nil)
|
||||
}
|
||||
if jsonMode {
|
||||
payload := ResultEnvelope{OK: false, Error: map[string]any{
|
||||
"kind": cliErr.Kind,
|
||||
"message": cliErr.Message,
|
||||
"details": cliErr.Details,
|
||||
"exit_code": cliErr.Code,
|
||||
}}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
} else {
|
||||
fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message)
|
||||
if cliErr.Details != nil {
|
||||
bytes, _ := json.MarshalIndent(cliErr.Details, "", " ")
|
||||
fmt.Fprintln(w, string(bytes))
|
||||
}
|
||||
}
|
||||
return cliErr.Code
|
||||
}
|
||||
|
||||
func (a *App) ResolveRoot() (string, error) {
|
||||
if a.Options.Root != "" {
|
||||
root, err := filepath.Abs(a.Options.Root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil {
|
||||
return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd)
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ValidationIssue struct {
|
||||
Level string `json:"level"`
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type ValidationReport struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []ValidationIssue `json:"errors"`
|
||||
Warnings []ValidationIssue `json:"warnings"`
|
||||
Checks map[string]int `json:"checks"`
|
||||
ZolaChecked bool `json:"zola_checked"`
|
||||
}
|
||||
|
||||
type pageRecord struct {
|
||||
Section string
|
||||
Meta PageMeta
|
||||
ID string
|
||||
}
|
||||
|
||||
func (r *ValidationReport) add(level, code, path, message string, details any) {
|
||||
issue := ValidationIssue{Level: level, Code: code, Path: filepath.ToSlash(path), Message: message, Details: details}
|
||||
if level == "warning" {
|
||||
r.Warnings = append(r.Warnings, issue)
|
||||
} else {
|
||||
r.Errors = append(r.Errors, issue)
|
||||
}
|
||||
}
|
||||
|
||||
func valueStrings(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, fmt.Sprint(item))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if v != "" {
|
||||
return []string{v}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprintBelongs(registry AdminRegistry, adminID, fingerprint string) bool {
|
||||
admin, ok := adminByID(registry, adminID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
if key.Fingerprint == fingerprint {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validTaskStatus(status string) bool {
|
||||
switch status {
|
||||
case "backlog", "todo", "in_progress", "blocked", "done", "cancelled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validProjectStatus(status string) bool {
|
||||
switch status {
|
||||
case "planned", "active", "blocked", "done", "completed", "cancelled", "archived":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runValidate(root string, args []string) error {
|
||||
fs := newFlagSet("validate")
|
||||
skipZola := fs.Bool("skip-zola", false, "omite zola check")
|
||||
requireZola := fs.Bool("require-zola", false, "falla si Zola no está instalado")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
|
||||
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
report.add("error", "admins_invalid", filepath.Join(root, "data", "admins.json"), err.Error(), nil)
|
||||
} else {
|
||||
a.validateAdmins(root, registry, &report)
|
||||
}
|
||||
|
||||
pages := a.validatePages(root, registry, &report)
|
||||
a.validateEvents(root, registry, pages, &report)
|
||||
a.validateRunbooks(root, &report)
|
||||
|
||||
if len(report.Errors) == 0 {
|
||||
if _, _, _, err := a.Sync(root, true); err != nil {
|
||||
report.add("error", "generated_state_stale", filepath.Join(root, "data", "generated", "site_state.json"), err.Error(), nil)
|
||||
} else {
|
||||
report.Checks["generated_state"] = 1
|
||||
}
|
||||
}
|
||||
|
||||
if !*skipZola {
|
||||
zolaPath, lookupErr := exec.LookPath("zola")
|
||||
if lookupErr != nil {
|
||||
if *requireZola {
|
||||
report.add("error", "zola_missing", "", "Zola no está instalado o no está en PATH", nil)
|
||||
} else {
|
||||
report.add("warning", "zola_missing", "", "Zola no está instalado; se omitió la validación de plantillas y enlaces", nil)
|
||||
}
|
||||
} else {
|
||||
report.ZolaChecked = true
|
||||
cmd := exec.Command(zolaPath, "check")
|
||||
cmd.Dir = root
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
report.add("error", "zola_check_failed", filepath.Join(root, "zola.toml"), "zola check falló", strings.TrimSpace(output.String()))
|
||||
} else {
|
||||
report.Checks["zola"] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.Valid = len(report.Errors) == 0
|
||||
if !report.Valid {
|
||||
return NewError(ExitValidation, "validation_failed", "la validación del proyecto encontró errores", report)
|
||||
}
|
||||
warnings := make([]string, 0, len(report.Warnings))
|
||||
for _, issue := range report.Warnings {
|
||||
warnings = append(warnings, issue.Message)
|
||||
}
|
||||
return a.printResult("validate", report, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) validateAdmins(root string, registry AdminRegistry, report *ValidationReport) {
|
||||
if registry.ReviewPolicy.Default != "all-active" && registry.ReviewPolicy.Default != "all-active-except-author" {
|
||||
report.add("error", "invalid_default_review_policy", filepath.Join(root, "data", "admins.json"), "review_policy.default no es válido", registry.ReviewPolicy.Default)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
emails := map[string]string{}
|
||||
fingerprints := map[string]string{}
|
||||
ownerCount := 0
|
||||
for _, admin := range registry.Admins {
|
||||
report.Checks["admins"]++
|
||||
idKey := normalizeID(admin.ID)
|
||||
if admin.ID == "" || slugify(admin.ID) != admin.ID {
|
||||
report.add("error", "invalid_admin_id", filepath.Join(root, "data", "admins.json"), "el ID del administrador debe ser un slug estable en minúsculas", admin.ID)
|
||||
}
|
||||
if prior, ok := ids[idKey]; ok {
|
||||
report.add("error", "duplicate_admin_id", filepath.Join(root, "data", "admins.json"), "ID de administrador duplicado", []string{prior, admin.ID})
|
||||
}
|
||||
ids[idKey] = admin.ID
|
||||
emailKey := strings.ToLower(strings.TrimSpace(admin.Email))
|
||||
if emailKey == "" || !strings.Contains(emailKey, "@") {
|
||||
report.add("error", "invalid_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador inválido", admin.ID)
|
||||
}
|
||||
if prior, ok := emails[emailKey]; ok {
|
||||
report.add("error", "duplicate_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador duplicado", []string{prior, admin.ID})
|
||||
}
|
||||
emails[emailKey] = admin.ID
|
||||
if hasRole(admin, "owner") && admin.Active {
|
||||
ownerCount++
|
||||
}
|
||||
if admin.Active && len(admin.SSHKeys) == 0 {
|
||||
report.add("error", "active_admin_without_key", filepath.Join(root, "data", "admins.json"), "un administrador activo debe tener al menos una llave SSH", admin.ID)
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
report.Checks["ssh_keys"]++
|
||||
_, computed, err := parsePublicKeyLine(key.PublicKey)
|
||||
if err != nil {
|
||||
report.add("error", "invalid_public_key", filepath.Join(root, "data", "admins.json"), err.Error(), admin.ID)
|
||||
continue
|
||||
}
|
||||
if key.Fingerprint != computed {
|
||||
report.add("error", "fingerprint_mismatch", filepath.Join(root, "data", "admins.json"), "la huella almacenada no corresponde a la llave pública", map[string]any{"admin_id": admin.ID, "stored": key.Fingerprint, "computed": computed})
|
||||
}
|
||||
if prior, ok := fingerprints[key.Fingerprint]; ok {
|
||||
report.add("error", "duplicate_ssh_key", filepath.Join(root, "data", "admins.json"), "una llave SSH está asignada a más de un administrador", []string{prior, admin.ID})
|
||||
}
|
||||
fingerprints[key.Fingerprint] = admin.ID
|
||||
}
|
||||
}
|
||||
if len(registry.Admins) > 0 && ownerCount == 0 {
|
||||
report.add("error", "missing_owner", filepath.Join(root, "data", "admins.json"), "debe existir al menos un administrador activo con rol owner", nil)
|
||||
}
|
||||
|
||||
allowedPath := filepath.Join(root, "config", "allowed_signers")
|
||||
allowedData, err := os.ReadFile(allowedPath)
|
||||
if err != nil {
|
||||
report.add("error", "allowed_signers_missing", allowedPath, "no se pudo leer el archivo de firmantes autorizados", err.Error())
|
||||
return
|
||||
}
|
||||
allowed := string(allowedData)
|
||||
for _, admin := range registry.Admins {
|
||||
if !admin.Active {
|
||||
continue
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
fields := strings.Fields(key.PublicKey)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
expected := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
|
||||
if !strings.Contains(allowed, expected) {
|
||||
report.add("error", "allowed_signer_missing", allowedPath, "falta una llave activa en allowed_signers", map[string]any{"admin_id": admin.ID, "fingerprint": key.Fingerprint})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) validatePages(root string, registry AdminRegistry, report *ValidationReport) []pageRecord {
|
||||
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
|
||||
pages := []pageRecord{}
|
||||
ids := []string{}
|
||||
for _, section := range sections {
|
||||
files, err := listMarkdownFiles(root, section)
|
||||
if err != nil {
|
||||
report.add("error", "content_walk_failed", filepath.Join(root, "content", section), err.Error(), nil)
|
||||
continue
|
||||
}
|
||||
for _, path := range files {
|
||||
report.Checks["content_pages"]++
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
report.add("error", "content_read_failed", path, readErr.Error(), nil)
|
||||
continue
|
||||
}
|
||||
if findings := scanSecrets(string(data)); len(findings) > 0 {
|
||||
report.add("error", "possible_secret", path, "el contenido parece incluir secretos", findings)
|
||||
}
|
||||
meta, parseErr := parseMarkdown(path)
|
||||
if parseErr != nil {
|
||||
report.add("error", "front_matter_invalid", path, parseErr.Error(), nil)
|
||||
continue
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
report.add("error", "missing_page_id", path, "la página no tiene extra.id", nil)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
pages = append(pages, pageRecord{Section: section, Meta: meta, ID: id})
|
||||
if strings.TrimSpace(meta.String("title")) == "" {
|
||||
report.add("error", "missing_title", path, "la página no tiene título", nil)
|
||||
}
|
||||
authors := valueStrings(meta.Root["authors"])
|
||||
for _, author := range authors {
|
||||
if _, ok := adminByID(registry, author); !ok && author != "sistema" {
|
||||
report.add("error", "unknown_author", path, "la página referencia un autor no registrado", author)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, duplicate := range pageIDDuplicates(ids) {
|
||||
report.add("error", "duplicate_page_id", filepath.Join(root, "content"), "hay IDs de contenido duplicados", duplicate)
|
||||
}
|
||||
|
||||
projectIDs := map[string]bool{}
|
||||
for _, page := range pages {
|
||||
if page.Section == "proyectos" {
|
||||
projectIDs[page.ID] = true
|
||||
}
|
||||
}
|
||||
for _, page := range pages {
|
||||
path := page.Meta.Path
|
||||
switch page.Section {
|
||||
case "memorias":
|
||||
if !strings.HasPrefix(page.ID, "MEM-") {
|
||||
report.add("error", "invalid_memory_id", path, "el ID de memoria debe iniciar con MEM-", page.ID)
|
||||
}
|
||||
authorID := page.Meta.ExtraString("author_id")
|
||||
if _, ok := adminByID(registry, authorID); !ok && authorID != "sistema" {
|
||||
report.add("error", "unknown_memory_author", path, "author_id no corresponde a un administrador", authorID)
|
||||
}
|
||||
fingerprint := page.Meta.ExtraString("author_fingerprint")
|
||||
if authorID != "sistema" && !fingerprintBelongs(registry, authorID, fingerprint) {
|
||||
report.add("error", "memory_fingerprint_mismatch", path, "author_fingerprint no pertenece al autor", map[string]any{"author_id": authorID, "fingerprint": fingerprint})
|
||||
}
|
||||
policy := page.Meta.ExtraString("review_policy")
|
||||
if policy != "" && policy != "all-active" && policy != "all-active-except-author" {
|
||||
report.add("error", "invalid_review_policy", path, "review_policy no es válido", policy)
|
||||
}
|
||||
if page.Meta.ExtraBool("reviewer_snapshot") {
|
||||
seenReviewers := map[string]bool{}
|
||||
for _, reviewer := range page.Meta.ExtraStrings("reviewers") {
|
||||
if seenReviewers[reviewer] {
|
||||
report.add("error", "duplicate_reviewer", path, "un revisor aparece más de una vez", reviewer)
|
||||
}
|
||||
seenReviewers[reviewer] = true
|
||||
if _, ok := adminByID(registry, reviewer); !ok {
|
||||
report.add("error", "unknown_reviewer", path, "el conjunto capturado referencia un administrador inexistente", reviewer)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "tareas":
|
||||
if !strings.HasPrefix(page.ID, "TSK-") {
|
||||
report.add("error", "invalid_task_id", path, "el ID de tarea debe iniciar con TSK-", page.ID)
|
||||
}
|
||||
priority := page.Meta.ExtraString("priority")
|
||||
if priority != "low" && priority != "medium" && priority != "high" && priority != "urgent" {
|
||||
report.add("error", "invalid_task_priority", path, "priority no es válida", priority)
|
||||
}
|
||||
status := firstNonEmpty(page.Meta.ExtraString("status"), "todo")
|
||||
if !validTaskStatus(status) {
|
||||
report.add("error", "invalid_task_status", path, "status no es válido", status)
|
||||
}
|
||||
if due := page.Meta.ExtraString("due"); validateDate(due) != nil {
|
||||
report.add("error", "invalid_due", path, "due debe usar YYYY-MM-DD", due)
|
||||
}
|
||||
projectID := normalizeID(page.Meta.ExtraString("project_id"))
|
||||
if projectID != "" && !projectIDs[projectID] {
|
||||
report.add("error", "unknown_project", path, "project_id no existe", projectID)
|
||||
}
|
||||
for _, assignee := range page.Meta.ExtraStrings("assignees") {
|
||||
admin, ok := adminByID(registry, assignee)
|
||||
if !ok || !admin.Active {
|
||||
report.add("error", "invalid_assignee", path, "assignee no existe o está inactivo", assignee)
|
||||
}
|
||||
}
|
||||
case "proyectos":
|
||||
if !strings.HasPrefix(page.ID, "PRJ-") {
|
||||
report.add("error", "invalid_project_id", path, "el ID de proyecto debe iniciar con PRJ-", page.ID)
|
||||
}
|
||||
status := firstNonEmpty(page.Meta.ExtraString("status"), "planned")
|
||||
if !validProjectStatus(status) {
|
||||
report.add("error", "invalid_project_status", path, "status no es válido", status)
|
||||
}
|
||||
if target := page.Meta.ExtraString("target_date"); validateDate(target) != nil {
|
||||
report.add("error", "invalid_target_date", path, "target_date debe usar YYYY-MM-DD", target)
|
||||
}
|
||||
for _, owner := range page.Meta.ExtraStrings("owners") {
|
||||
admin, ok := adminByID(registry, owner)
|
||||
if !ok || !admin.Active {
|
||||
report.add("error", "invalid_owner", path, "owner no existe o está inactivo", owner)
|
||||
}
|
||||
}
|
||||
case "documentacion":
|
||||
if !strings.HasPrefix(page.ID, "DOC-") {
|
||||
report.add("error", "invalid_document_id", path, "el ID de documentación debe iniciar con DOC-", page.ID)
|
||||
}
|
||||
case "manuales":
|
||||
if !strings.HasPrefix(page.ID, "RUN-") {
|
||||
report.add("error", "invalid_manual_id", path, "el ID de manual debe iniciar con RUN-", page.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func (a *App) validateEvents(root string, registry AdminRegistry, pages []pageRecord, report *ValidationReport) {
|
||||
memories := map[string]pageRecord{}
|
||||
tasks := map[string]pageRecord{}
|
||||
for _, page := range pages {
|
||||
if page.Section == "memorias" {
|
||||
memories[page.ID] = page
|
||||
}
|
||||
if page.Section == "tareas" {
|
||||
tasks[page.ID] = page
|
||||
}
|
||||
}
|
||||
reviews, err := loadReviewLog(root)
|
||||
if err != nil {
|
||||
report.add("error", "review_log_invalid", filepath.Join(root, "data", reviewEventsDirName), err.Error(), nil)
|
||||
} else {
|
||||
seen := map[string]bool{}
|
||||
for _, event := range reviews.Events {
|
||||
report.Checks["review_events"]++
|
||||
if seen[event.ID] {
|
||||
report.add("error", "duplicate_review_event", filepath.Join(root, "data", reviewEventsDirName), "ID de evento de revisión duplicado", event.ID)
|
||||
}
|
||||
seen[event.ID] = true
|
||||
if _, ok := memories[normalizeID(event.EntryID)]; !ok {
|
||||
report.add("warning", "orphan_review_event", filepath.Join(root, "data", reviewEventsDirName), "el evento apunta a una memoria que ya no está en el árbol actual", event.EntryID)
|
||||
}
|
||||
if _, ok := adminByID(registry, event.AdminID); !ok {
|
||||
report.add("error", "unknown_review_admin", filepath.Join(root, "data", reviewEventsDirName), "admin_id de revisión no existe", event.AdminID)
|
||||
}
|
||||
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
|
||||
report.add("error", "review_fingerprint_mismatch", filepath.Join(root, "data", reviewEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
|
||||
}
|
||||
if _, parseErr := time.Parse(time.RFC3339, event.ReviewedAt); parseErr != nil {
|
||||
report.add("error", "invalid_review_time", filepath.Join(root, "data", reviewEventsDirName), "reviewed_at no es RFC3339", event.ID)
|
||||
}
|
||||
if len(event.ContentSHA256) != 64 {
|
||||
report.add("error", "invalid_review_hash", filepath.Join(root, "data", reviewEventsDirName), "content_sha256 no tiene longitud SHA-256", event.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
report.add("error", "task_log_invalid", filepath.Join(root, "data", taskEventsDirName), err.Error(), nil)
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
activeTimer := map[string]TaskEvent{}
|
||||
totalMinutes := map[string]int{}
|
||||
for _, event := range log.Events {
|
||||
report.Checks["task_events"]++
|
||||
if seen[event.ID] {
|
||||
report.add("error", "duplicate_task_event", filepath.Join(root, "data", taskEventsDirName), "ID de evento de tarea duplicado", event.ID)
|
||||
}
|
||||
seen[event.ID] = true
|
||||
if _, ok := tasks[normalizeID(event.TaskID)]; !ok {
|
||||
report.add("error", "unknown_task_event_target", filepath.Join(root, "data", taskEventsDirName), "el evento apunta a una tarea inexistente", event.TaskID)
|
||||
}
|
||||
if _, ok := adminByID(registry, event.AdminID); !ok {
|
||||
report.add("error", "unknown_task_admin", filepath.Join(root, "data", taskEventsDirName), "admin_id de evento no existe", event.AdminID)
|
||||
}
|
||||
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
|
||||
report.add("error", "task_fingerprint_mismatch", filepath.Join(root, "data", taskEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
|
||||
}
|
||||
if _, parseErr := time.Parse(time.RFC3339, event.At); parseErr != nil {
|
||||
report.add("error", "invalid_task_event_time", filepath.Join(root, "data", taskEventsDirName), "at no es RFC3339", event.ID)
|
||||
}
|
||||
switch event.Type {
|
||||
case "start":
|
||||
if active, ok := activeTimer[event.AdminID]; ok {
|
||||
code := "duplicate_task_start"
|
||||
message := "el administrador inició dos veces la misma tarea sin detenerla"
|
||||
if normalizeID(active.TaskID) != normalizeID(event.TaskID) {
|
||||
code = "concurrent_task_timers"
|
||||
message = "el administrador tiene más de un temporizador activo"
|
||||
}
|
||||
report.add("error", code, filepath.Join(root, "data", taskEventsDirName), message, map[string]any{
|
||||
"admin_id": event.AdminID, "active_event": active.ID, "new_event": event.ID,
|
||||
})
|
||||
}
|
||||
activeTimer[event.AdminID] = event
|
||||
case "stop":
|
||||
active, ok := activeTimer[event.AdminID]
|
||||
if !ok || normalizeID(active.TaskID) != normalizeID(event.TaskID) {
|
||||
report.add("error", "task_stop_without_start", filepath.Join(root, "data", taskEventsDirName), "el evento stop no corresponde a un temporizador activo", event.ID)
|
||||
} else {
|
||||
delete(activeTimer, event.AdminID)
|
||||
}
|
||||
if event.Minutes <= 0 {
|
||||
report.add("error", "invalid_stop_minutes", filepath.Join(root, "data", taskEventsDirName), "un evento stop debe registrar minutos positivos", event.ID)
|
||||
}
|
||||
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
|
||||
case "adjust":
|
||||
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
|
||||
if event.Minutes == 0 {
|
||||
report.add("error", "invalid_adjust_minutes", filepath.Join(root, "data", taskEventsDirName), "un ajuste no puede ser cero", event.ID)
|
||||
}
|
||||
case "status":
|
||||
if !validTaskStatus(event.Status) {
|
||||
report.add("error", "invalid_status_event", filepath.Join(root, "data", taskEventsDirName), "status no es válido", event.Status)
|
||||
}
|
||||
default:
|
||||
report.add("error", "invalid_task_event_type", filepath.Join(root, "data", taskEventsDirName), "tipo de evento desconocido", event.Type)
|
||||
}
|
||||
}
|
||||
|
||||
for taskID, minutes := range totalMinutes {
|
||||
if minutes < 0 {
|
||||
report.add("error", "negative_task_time", filepath.Join(root, "data", taskEventsDirName), "los eventos dejan el tiempo acumulado por debajo de cero", map[string]any{
|
||||
"task_id": taskID, "actual_minutes": minutes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La lectura estable de errores facilita comparaciones en CI y por agentes.
|
||||
sort.Slice(report.Errors, func(i, j int) bool {
|
||||
if report.Errors[i].Path == report.Errors[j].Path {
|
||||
return report.Errors[i].Code < report.Errors[j].Code
|
||||
}
|
||||
return report.Errors[i].Path < report.Errors[j].Path
|
||||
})
|
||||
sort.Slice(report.Warnings, func(i, j int) bool {
|
||||
if report.Warnings[i].Path == report.Warnings[j].Path {
|
||||
return report.Warnings[i].Code < report.Warnings[j].Code
|
||||
}
|
||||
return report.Warnings[i].Path < report.Warnings[j].Path
|
||||
})
|
||||
}
|
||||
+17
-7
@@ -22,9 +22,10 @@ arriesgar el front matter TOML ni el resaltado de sintaxis que sí usa
|
||||
## Convención de nombres
|
||||
|
||||
`kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo
|
||||
dentro de un archivo): `clusterlog-build`, `clusterlog-validate-local`,
|
||||
`clusterlog-review-mine`. `clusterlog validate` (ver
|
||||
`internal/clusterlog/runbooks.go`) falla si dos celdas repiten un nombre.
|
||||
dentro de un archivo): `clusterlog-sync-check`, `clusterlog-validate-local`,
|
||||
`weekly-review-mine`. `clusterlog validate` (código en
|
||||
[`internal/clusterlog/runbooks.go`](https://github.com/fenandosr/clusterlog/blob/main/internal/clusterlog/runbooks.go)
|
||||
del motor) falla si dos celdas repiten un nombre.
|
||||
|
||||
## Taxonomía de etiquetas
|
||||
|
||||
@@ -44,7 +45,16 @@ cumplir esto — no es sólo una convención de estilo.
|
||||
|
||||
## Qué no va en una celda
|
||||
|
||||
Ver la sección "Runme no contiene lógica de negocio" del ADR
|
||||
[`docs/adr/0004-runme-sidecar.md`](../docs/adr/0004-runme-sidecar.md). En
|
||||
resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente; no
|
||||
reimplementa validación, autorización, rollback ni gestión de secretos.
|
||||
Ver ADR-0004 y ADR-0005 en el
|
||||
[repositorio del motor](https://github.com/fenandosr/clusterlog/tree/main/docs/adr).
|
||||
En resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente;
|
||||
no reimplementa validación, autorización, rollback ni gestión de secretos.
|
||||
|
||||
## Esta es una instancia, no el motor
|
||||
|
||||
Esta carpeta y `RUNBOOK.md` en la raíz operan sobre **datos reales de este
|
||||
deployment** (`content/`, `data/`, `config/`). El desarrollo del propio
|
||||
`clusterlog` (compilar, probar, `bootstrap-instance`) vive en
|
||||
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); su
|
||||
`.clusterlog-version` en la raíz de este repositorio fija qué versión del
|
||||
motor usa esta instancia.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Ciclo de desarrollo de clusterlog
|
||||
|
||||
Propósito: correr el mismo ciclo que `AGENTS.md` pide "antes de entregar",
|
||||
como notebook en vez de copiar/pegar bloques sueltos. Todas las celdas de
|
||||
este documento envuelven targets existentes de `Makefile`; ninguna reimplementa
|
||||
lo que ya hace `go test`, `go vet` o `clusterlog`.
|
||||
|
||||
## Prerrequisitos
|
||||
|
||||
- Go 1.23+, Git con firmas SSH.
|
||||
- Para las celdas con Zola: binario `zola` en `PATH` (opcional; las celdas de
|
||||
`validate`/`sync` no lo requieren).
|
||||
|
||||
## Formato
|
||||
|
||||
```sh {"name":"clusterlog-gofmt-check","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
test -z "$(gofmt -l cmd internal)"
|
||||
```
|
||||
|
||||
## Pruebas y análisis estático
|
||||
|
||||
```sh {"name":"clusterlog-vet","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-test-race","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
## Compilar y proyectar
|
||||
|
||||
```sh {"name":"clusterlog-dev-build","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make build
|
||||
```
|
||||
|
||||
```sh {"name":"clusterlog-dev-sync","tag":"role-developer,env-local,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
|
||||
# Escribe data/generated/site_state.json. Revise el diff antes de confirmar
|
||||
# cualquier commit que incluya ese archivo regenerado.
|
||||
make sync
|
||||
git diff --stat -- data/generated/site_state.json
|
||||
```
|
||||
|
||||
### Prerrequisitos de `clusterlog-dev-sync`
|
||||
|
||||
- Haber corrido `clusterlog-dev-build` en esta sesión (o tener `bin/clusterlog`
|
||||
ya compilado).
|
||||
|
||||
### Validación posterior
|
||||
|
||||
```sh {"name":"clusterlog-dev-sync-check","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make sync-check
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
`data/generated/site_state.json` es 100% regenerable desde el contenido
|
||||
fuente (`content/`, `data/admins.json`, `data/*-events/`). Si `make sync`
|
||||
produjo un diff no deseado, descarte el archivo con
|
||||
`git checkout -- data/generated/site_state.json` y no lo incluya en el commit.
|
||||
|
||||
## Validar
|
||||
|
||||
```sh {"name":"clusterlog-dev-validate-local","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make validate-local
|
||||
```
|
||||
|
||||
## Flujo completo local (equivalente a CI, sin Zola)
|
||||
|
||||
```sh {"name":"clusterlog-dev-smoke","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||
make build
|
||||
./bin/clusterlog --root . sync --check
|
||||
./bin/clusterlog --root . validate --skip-zola
|
||||
./scripts/smoke.sh
|
||||
```
|
||||
|
||||
## Registro en Clusterlog
|
||||
|
||||
Ninguna celda de este runbook crea memorias, tareas ni commits. Si el
|
||||
trabajo de la sesión amerita una memoria, hágalo con el flujo normal descrito
|
||||
en `AGENTS.md` (`clusterlog memory create --from-json`, con `--dry-run` antes
|
||||
de `--commit`) — fuera de Runme, con su propia llave.
|
||||
|
||||
## Limitaciones
|
||||
|
||||
- `clusterlog-dev-sync` está marcado `manual-only` a propósito: regenera un
|
||||
archivo versionado y su diff debe revisarse antes de confirmarlo.
|
||||
- Este runbook no incluye `make site`/`zola build`: ver
|
||||
[`RUNBOOK.md`](../RUNBOOK.md#comprobaciones-rápidas) para por qué esa celda
|
||||
vive ahí y no se duplica aquí.
|
||||
@@ -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"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Descarga un artefacto del release fijado del motor (fenandosr/clusterlog)
|
||||
# y verifica su checksum contra checksums.txt del mismo release. Nunca usa
|
||||
# `latest`: la versión viene de .clusterlog-version.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENGINE_REPO="${CLUSTERLOG_ENGINE_REPO:-fenandosr/clusterlog}"
|
||||
VERSION="$(cat "$ROOT/.clusterlog-version")"
|
||||
KIND="${1:?uso: fetch-engine.sh <bin|templates> <destino>}"
|
||||
DEST="${2:?uso: fetch-engine.sh <bin|templates> <destino>}"
|
||||
|
||||
BASE_URL="https://github.com/${ENGINE_REPO}/releases/download/${VERSION}"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
case "$KIND" in
|
||||
bin)
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
|
||||
asset="clusterlog_${os}_${arch}.tar.gz"
|
||||
;;
|
||||
templates)
|
||||
asset="clusterlog-templates_${VERSION}.tar.gz"
|
||||
;;
|
||||
*)
|
||||
echo "Tipo de artefacto desconocido: $KIND (use bin o templates)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Descargando $asset (clusterlog $VERSION, $ENGINE_REPO)..."
|
||||
curl -fsSL -o "$TMP/checksums.txt" "$BASE_URL/checksums.txt"
|
||||
curl -fsSL -o "$TMP/$asset" "$BASE_URL/$asset"
|
||||
|
||||
expected="$(grep " $asset\$" "$TMP/checksums.txt" | awk '{print $1}')"
|
||||
if [ -z "$expected" ]; then
|
||||
echo "ERROR: $asset no aparece en checksums.txt del release $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
computed="$(sha256sum "$TMP/$asset" | awk '{print $1}')"
|
||||
if [ "$computed" != "$expected" ]; then
|
||||
echo "ERROR: checksum no coincide para $asset" >&2
|
||||
echo " esperado: $expected" >&2
|
||||
echo " obtenido: $computed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verificado."
|
||||
|
||||
case "$KIND" in
|
||||
bin)
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
tar xzf "$TMP/$asset" -C "$TMP" clusterlog
|
||||
install -m 0755 "$TMP/clusterlog" "$DEST"
|
||||
echo "Binario en $DEST ($("$DEST" version 2>/dev/null || true))"
|
||||
;;
|
||||
templates)
|
||||
rm -rf "$DEST"
|
||||
mkdir -p "$DEST"
|
||||
tar xzf "$TMP/$asset" -C "$DEST"
|
||||
echo "Plantillas en $DEST"
|
||||
;;
|
||||
esac
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 <ID></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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user