feat(runme): integrar Runme como sidecar de documentación ejecutable

Agrega RUNBOOK.md y runbooks/ (fuera de content/, para no interferir con
la publicación de Zola), con reglas de nombres/etiquetas/aislamiento de
celdas mutantes verificadas por clusterlog validate. Instalación pinneada
a v3.17.4 con checksum verificado (scripts/runme-install.sh), un job de
CI separado (.gitea/workflows/ci.yml) que sólo corre celdas ci-safe, y la
decisión documentada en docs/adr/0004-runme-sidecar.md y docs/RUNME.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:06:01 -06:00
co-authored by Claude Sonnet 5
parent fcee75fe83
commit c06037fa3b
22 changed files with 1682 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
# Copie a .env (ignorado por git) si su flujo local o Runme lo necesita.
# Nunca ponga valores reales de credenciales en este archivo ni en .env.
# Raíz del proyecto clusterlog, si no se ejecuta desde ahí.
CLUSTERLOG_ROOT=.
# Ruta a SU llave pública (o base) para identidad/firma. Nunca la ruta de la
# llave privada de otra persona, y nunca el contenido de una llave privada.
CLUSTERLOG_SSH_KEY=
# Entorno lógico para runbooks que necesiten distinguir local/staging/prod.
# Valores esperados: local, ci, staging, production.
CLUSTERLOG_ENV=local
# Ruta a un archivo JSON de memoria ya redactado (sin secretos), usado por
# el flujo de ejemplo de "memory create --from-json" en AGENTS.md.
CLUSTERLOG_MEMORY_JSON=
+30
View File
@@ -76,3 +76,33 @@ jobs:
- name: Fin del pipeline - name: Fin del pipeline
run: echo "listo" run: echo "listo"
runme:
runs-on: ubuntu-latest
permissions:
contents: read
env:
DO_NOT_TRACK: "true"
SCARF_NO_ANALYTICS: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Compilar CLI
run: make build
- name: Instalar Runme fijado (v3.17.4, checksum verificado)
run: make runme-install
- name: Exponer runme en PATH
run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Reglas de clusterlog validate sobre los runbooks
run: make runme-check
- name: Ejecutar celdas ci-safe de RUNBOOK.md
run: make runme-ci
+11
View File
@@ -4,3 +4,14 @@
/themes/zola.386/ /themes/zola.386/
.DS_Store .DS_Store
*.tmp *.tmp
# Runme: variables de entorno locales, nunca valores reales versionados.
.env
.env.local
.env.*.local
.direnv/
# No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver
# docs/RUNME.md): un glob amplio aquí podría ocultar Markdown legítimo por
# accidente. clusterlog validate detecta señales concretas en su lugar
# (directorio .runme/ versionado, archivos "*output*" dentro de runbooks/).
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["stateful.runme"]
}
+17
View File
@@ -72,6 +72,23 @@ No convierta salidas extensas de terminal en el cuerpo. Resuma y enlace evidenci
- `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse. - `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse.
- `task_has_running_timers`: identifique a los actores; no cierre o borre eventos. - `task_has_running_timers`: identifique a los actores; no cierre o borre eventos.
## Runbooks ejecutables (Runme)
`RUNBOOK.md` y `runbooks/**/*.md` son documentación ejecutable, no una
segunda CLI. Reglas para un agente:
- Nunca active una celda `risk-mutating` o `risk-destructive` sin que el
usuario haya autorizado explícitamente esa operación puntual — la
etiqueta `manual-only`/`interactive`/`excludeFromRunAll` es una defensa
técnica de Runme, no una autorización.
- Trate cada celda como el comando que envuelve: si el comando requiere
`--dry-run` antes de `--commit`, la celda también.
- No agregue lógica nueva dentro de una celda; si hace falta lógica
reutilizable, colóquela en `clusterlog`, un script en `scripts/` o un
target de `Makefile`, y haga que la celda sólo lo invoque.
- No genere ni edite manualmente un identificador Lifecycle Identity de
Runme.
## Cambios al código del proyecto ## Cambios al código del proyecto
Antes de entregar: Antes de entregar:
+6
View File
@@ -23,6 +23,12 @@ Sigue primero `AGENTS.md`. Estas reglas son específicas para sesiones de admini
- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada. - Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada.
- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado. - Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado.
## Runbooks ejecutables (Runme)
- No actives una celda `risk-mutating`/`risk-destructive` de `RUNBOOK.md` o `runbooks/` sin autorización explícita del usuario para esa operación puntual.
- Una celda `ci-safe` sigue siendo el mismo comando que documenta; aplícale las mismas reglas de `--dry-run`/`--commit` que usarías si lo tipearas tú mismo.
- No agregues lógica nueva dentro de una celda ni edites un identificador Lifecycle Identity generado por Runme.
## Formato de respuesta al usuario ## Formato de respuesta al usuario
```text ```text
+13 -1
View File
@@ -3,7 +3,7 @@ GO ?= go
ZOLA ?= zola ZOLA ?= zola
BINARY ?= bin/clusterlog BINARY ?= bin/clusterlog
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean .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
all: build all: build
@@ -49,3 +49,15 @@ ci: theme test vet e2e build sync sync-check validate
clean: clean:
rm -rf bin public themes/zola.386 rm -rf bin public themes/zola.386
runme-install:
./scripts/runme-install.sh
runme-list:
runme ls --filename RUNBOOK.md
runme-check: build
./scripts/runme-check.sh
runme-ci: build
runme run --filename RUNBOOK.md --tag ci-safe --all --skip-prompts
+12
View File
@@ -152,6 +152,17 @@ make site # tema + estado + validación completa + sitio
make ci # equivalente local de la canalización make ci # equivalente local de la canalización
``` ```
## Documentación ejecutable (Runme)
Los procedimientos frecuentes también existen como notebooks ejecutables,
sin reemplazar ningún comando canónico: [`RUNBOOK.md`](RUNBOOK.md) es el
punto de entrada. Requiere Runme `v3.17.4` (`make runme-install`) sólo si se
quiere abrir como notebook o correr por CLI; cada celda es, además, un
bloque de shell normal que sigue funcionando copiado a mano. 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).
## Estructura ## Estructura
```text ```text
@@ -176,6 +187,7 @@ docs/ decisiones arquitectónicas y operación
- [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`. - [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`.
- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal. - [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal.
- [`docs/ADR.md`](docs/ADR.md): decisiones de diseño de **clusterlog** (la herramienta), no del clúster que administra; formato, campos y ejemplo. Registros en [`docs/adr/`](docs/adr/), sin equivalente en la UI del sitio ni en la CLI. - [`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. - [`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. - [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0.
+95
View File
@@ -0,0 +1,95 @@
# 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
[`runbooks/README.md`](runbooks/README.md) para la convención completa de
nombres y etiquetas.
Este archivo funciona igual con o sin Runme instalado: cada bloque es un
comando de shell normal que también puede copiarse y ejecutarse a mano.
## Runbooks disponibles
- [`runbooks/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"}
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)"
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"}
make sync-check
```
```sh {"name":"clusterlog-validate-local","tag":"role-developer,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"}
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.
```sh {"name":"clusterlog-review-mine","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY con la ruta a su llave pública}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" review list --mine
```
`clusterlog-review-mine` tampoco lleva `ci-safe`: `--mine` resuelve identidad
a partir de una llave SSH personal, y CI no tiene ni debe tener acceso a
ninguna llave privada real.
```sh {"name":"clusterlog-verify-head","tag":"role-reviewer,env-local,risk-read-only,ci-safe","interactive":"false"}
./bin/clusterlog --root . --json verify commit HEAD
```
## Ejecutar
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
prerrequisitos.
+37
View File
@@ -121,6 +121,43 @@ Ante contenido sensible o firma cuestionable:
6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro; 6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro;
7. actualice manuales y controles preventivos. 7. actualice manuales y controles preventivos.
## Documentación ejecutable (Runme)
`RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como
notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004). Esto no cambia el
modelo de confianza: se agregan las siguientes reglas.
- Ejecutar una celda de Runme equivale exactamente a ejecutar el comando
local con los permisos del usuario que la corre. No hay una elevación ni
un sandbox adicional.
- El usuario debe leer una celda antes de ejecutarla, igual que revisaría
cualquier comando pegado desde un chat o un documento.
- Un agente de IA no puede activar una celda `risk-mutating`/`risk-destructive`
sin autorización explícita del usuario para esa operación puntual — igual
que con cualquier otro comando mutante.
- Una etiqueta (`ci-safe`, `role-*`, `risk-*`) es metadato de clasificación,
**no** un control de acceso. `interactive` y `excludeFromRunAll` son
defensas adicionales que Runme sí respeta técnicamente, pero tampoco son
autorización: son mecanismos para reducir el riesgo de un "Run All"
accidental, no una barrera de permisos.
- Runme no debe leer, exportar ni copiar ninguna llave privada. Ninguna
celda de este repositorio lo hace ni debe hacerlo.
- No se guardan outputs de celdas con secretos. Los Session Outputs de
Runme están desactivados por defecto en esta integración; si se habilitan
localmente, no deben versionarse ni compartirse.
- Runme Cloud, Gists y cualquier servicio remoto de Runme quedan fuera de
alcance. `runme open` (servidor web local) no debe exponerse en `0.0.0.0`.
- La ejecución de celdas desde el sitio Zola publicado está fuera de
alcance: Zola no construye `runbooks/`, y no existe ningún mecanismo para
correr Runme desde el navegador.
- Las operaciones productivas reales (RBAC, `sudo`, gestores de secretos,
kubeconfig, proveedor cloud, controles corporativos) siguen siendo
responsabilidad de esas herramientas. Runme nunca las reemplaza ni las
reimplementa.
- Un agente debe detenerse ante errores de identidad, firma o validación
también dentro de un runbook, igual que en cualquier otro flujo de
`clusterlog`.
## Reporte de vulnerabilidades ## Reporte de vulnerabilidades
No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales. No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales.
+212
View File
@@ -0,0 +1,212 @@
# 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).
+5
View File
@@ -1,5 +1,10 @@
# Reunión semanal de revisión operativa # Reunión semanal de revisión operativa
> Versión ejecutable de los comandos de esta página:
> [`runbooks/weekly-review.md`](../runbooks/weekly-review.md) (abrible como
> notebook con Runme, o copiable a mano). Este documento conserva la agenda
> y el criterio; los bloques de comando canónicos viven sólo ahí.
## Objetivo ## Objetivo
Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad. Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad.
+216
View File
@@ -0,0 +1,216 @@
# 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.
+356
View File
@@ -0,0 +1,356 @@
package clusterlog
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Este archivo valida los runbooks ejecutables (Runme) que viven en
// RUNBOOK.md y runbooks/**/*.md. No ejecuta Runme ni ninguna celda: sólo
// analiza el Markdown como texto para hacer cumplir las reglas de la
// sección 12 del ADR 0004 antes de que alguien abra el documento.
//
// El análisis es un escáner de líneas con estado (no una única expresión
// regular como defensa completa): la regex sólo reconoce la FORMA de una
// línea de fence, y el contenido de atributos siempre se decodifica con
// encoding/json.
var runmeFenceLine = regexp.MustCompile("^```([A-Za-z0-9_+-]*)\\s*(\\{.*\\})?\\s*$")
type runmeCell struct {
Path string
Line int
Lang string
Name string
Tags []string
Attrs map[string]any
Body string
}
func hasTag(tags []string, tag string) bool {
for _, t := range tags {
if t == tag {
return true
}
}
return false
}
func hasTagPrefix(tags []string, prefix string) bool {
for _, t := range tags {
if strings.HasPrefix(t, prefix) {
return true
}
}
return false
}
func attrBool(attrs map[string]any, key string) (bool, bool) {
value, ok := attrs[key]
if !ok {
return false, false
}
switch v := value.(type) {
case bool:
return v, true
case string:
return strings.EqualFold(v, "true"), true
default:
return false, true
}
}
// scanRunmeCells recorre un documento Markdown y devuelve las celdas que
// declaran atributos Runme con al menos un "name". Los fences sin atributos
// (bloques de código normales) se ignoran para efectos de reglas de Runme,
// pero igual se respeta su apertura/cierre para no desalinear el escaneo.
func scanRunmeCells(path string, report *ValidationReport) []runmeCell {
data, err := os.ReadFile(path)
if err != nil {
report.add("error", "runbook_read_failed", path, err.Error(), nil)
return nil
}
cells := []runmeCell{}
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
inFence := false
var current *runmeCell
var body strings.Builder
for i, raw := range lines {
lineNo := i + 1
trimmed := strings.TrimRight(raw, " \t")
if !inFence {
m := runmeFenceLine.FindStringSubmatch(trimmed)
if m == nil {
continue
}
inFence = true
lang, attrsRaw := m[1], m[2]
if attrsRaw == "" {
current = nil
continue
}
var attrs map[string]any
if jsonErr := json.Unmarshal([]byte(attrsRaw), &attrs); jsonErr != nil {
report.add("error", "runme_cell_invalid_attrs", path, "los atributos de la celda no son JSON válido: "+jsonErr.Error(), map[string]any{"line": lineNo})
current = nil
continue
}
name, _ := attrs["name"].(string)
if name == "" {
current = nil
continue
}
tagsRaw, _ := attrs["tag"].(string)
tags := []string{}
for _, t := range strings.Split(tagsRaw, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
current = &runmeCell{Path: path, Line: lineNo, Lang: lang, Name: name, Tags: tags, Attrs: attrs}
body.Reset()
continue
}
// Dentro de un fence: una línea de cierre es ``` sola en la línea.
if trimmed == "```" {
inFence = false
if current != nil {
current.Body = body.String()
cells = append(cells, *current)
current = nil
}
continue
}
if current != nil {
body.WriteString(raw)
body.WriteString("\n")
}
}
return cells
}
func findRunbookFiles(root string) ([]string, error) {
paths := []string{}
rootRunbook := filepath.Join(root, "RUNBOOK.md")
if info, err := os.Stat(rootRunbook); err == nil && !info.IsDir() {
paths = append(paths, rootRunbook)
}
base := filepath.Join(root, "runbooks")
walkErr := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
paths = append(paths, path)
return nil
})
if os.IsNotExist(walkErr) {
walkErr = nil
}
sort.Strings(paths)
return paths, walkErr
}
func makefileTargets(root string) map[string]bool {
targets := map[string]bool{}
data, err := os.ReadFile(filepath.Join(root, "Makefile"))
if err != nil {
return targets
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
targetLine := regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*:`)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "\t") {
continue
}
if strings.HasPrefix(strings.TrimSpace(line), ".PHONY:") {
for _, name := range strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), ".PHONY:")) {
targets[name] = true
}
continue
}
if m := targetLine.FindStringSubmatch(line); m != nil && !strings.HasPrefix(m[1], ".") {
targets[m[1]] = true
}
}
return targets
}
var makeInvocation = regexp.MustCompile(`\bmake\s+([A-Za-z0-9_.-]+)`)
var scriptInvocation = regexp.MustCompile(`\.\/(scripts\/[A-Za-z0-9_.-]+\.sh)`)
var infraToolPattern = regexp.MustCompile(`\b(kubectl|terraform|helm)\b`)
const (
runmeUnknownAttrWarning = "unknown_runme_attr"
)
var knownRunmeAttrs = map[string]bool{
"name": true, "tag": true, "interactive": true, "excludeFromRunAll": true,
"id": true, "background": true, "promptEnv": true, "cwd": true, "category": true,
}
// validateRunbooks aplica las reglas de la sección 12 del ADR 0004 sobre
// RUNBOOK.md y runbooks/**/*.md, y confirma que ninguna celda de Runme se
// haya colado en content/ (donde Zola sí publica).
func (a *App) validateRunbooks(root string, report *ValidationReport) {
files, err := findRunbookFiles(root)
if err != nil {
report.add("error", "runbook_walk_failed", filepath.Join(root, "runbooks"), err.Error(), nil)
}
targets := makefileTargets(root)
names := map[string]string{}
var allCells []runmeCell
for _, path := range files {
report.Checks["runbook_files"]++
cells := scanRunmeCells(path, report)
allCells = append(allCells, cells...)
if findings := scanSecrets(mustReadFile(path)); len(findings) > 0 {
report.add("error", "possible_secret", path, "el runbook parece incluir secretos", findings)
}
}
for _, cell := range allCells {
report.Checks["runme_cells"]++
if prior, ok := names[cell.Name]; ok && prior != cell.Path {
report.add("error", "duplicate_runme_cell_name", cell.Path, "nombre de celda Runme duplicado en el repositorio", map[string]any{"name": cell.Name, "also_in": prior, "line": cell.Line})
} else {
names[cell.Name] = cell.Path
}
hasRole := hasTagPrefix(cell.Tags, "role-")
hasEnv := hasTagPrefix(cell.Tags, "env-")
hasRisk := hasTagPrefix(cell.Tags, "risk-")
if !hasRole || !hasEnv || !hasRisk {
missing := []string{}
if !hasRole {
missing = append(missing, "role-*")
}
if !hasEnv {
missing = append(missing, "env-*")
}
if !hasRisk {
missing = append(missing, "risk-*")
}
report.add("error", "runme_cell_missing_tag_category", cell.Path, "a la celda le faltan categorías de etiqueta obligatorias", map[string]any{"name": cell.Name, "missing": missing, "line": cell.Line})
}
mutating := hasTag(cell.Tags, "risk-mutating") || hasTag(cell.Tags, "risk-destructive")
ciSafe := hasTag(cell.Tags, "ci-safe")
manualOnly := hasTag(cell.Tags, "manual-only")
if mutating {
interactive, hasInteractive := attrBool(cell.Attrs, "interactive")
exclude, hasExclude := attrBool(cell.Attrs, "excludeFromRunAll")
if !hasInteractive || !interactive || !hasExclude || !exclude {
report.add("error", "runme_risk_cell_not_isolated", cell.Path, "una celda risk-mutating/risk-destructive debe ser interactive:true y excludeFromRunAll:true", map[string]any{"name": cell.Name, "line": cell.Line})
}
if !manualOnly {
report.add("error", "runme_risk_cell_not_manual_only", cell.Path, "una celda risk-mutating/risk-destructive debe llevar la etiqueta manual-only", map[string]any{"name": cell.Name, "line": cell.Line})
}
}
if ciSafe && mutating {
report.add("error", "runme_ci_safe_risk_conflict", cell.Path, "una celda no puede ser ci-safe y risk-mutating/risk-destructive a la vez", map[string]any{"name": cell.Name, "line": cell.Line})
}
if ciSafe && manualOnly {
report.add("error", "runme_contradictory_tags", cell.Path, "una celda no puede ser ci-safe y manual-only a la vez", map[string]any{"name": cell.Name, "line": cell.Line})
}
for key := range cell.Attrs {
if !knownRunmeAttrs[key] {
report.add("warning", runmeUnknownAttrWarning, cell.Path, "atributo de celda Runme no reconocido por esta validación", map[string]any{"name": cell.Name, "attr": key, "line": cell.Line})
}
}
for _, m := range makeInvocation.FindAllStringSubmatch(cell.Body, -1) {
if !targets[m[1]] {
report.add("error", "runme_cell_unknown_make_target", cell.Path, "la celda invoca un target de make que no existe", map[string]any{"name": cell.Name, "target": m[1]})
}
}
for _, m := range scriptInvocation.FindAllStringSubmatch(cell.Body, -1) {
scriptPath := filepath.Join(root, filepath.FromSlash(m[1]))
if _, statErr := os.Stat(scriptPath); statErr != nil {
report.add("error", "runme_cell_unknown_script", cell.Path, "la celda invoca un script que no existe en el repositorio", map[string]any{"name": cell.Name, "script": m[1]})
}
}
if infraToolPattern.MatchString(cell.Body) {
report.add("warning", "runme_cell_infra_not_present", cell.Path, "la celda referencia una herramienta de infraestructura (kubectl/terraform/helm) que no tiene configuración real en este repositorio", map[string]any{"name": cell.Name})
}
}
a.checkNoRunmeCellsInZolaContent(root, report)
a.checkRunmeSessionArtifacts(root, report)
}
func mustReadFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(data)
}
// checkNoRunmeCellsInZolaContent hace cumplir la decisión de arquitectura:
// las celdas de Runme viven fuera de content/, nunca dentro. Si alguna
// página publicada por Zola trae atributos de celda Runme, es un error.
func (a *App) checkNoRunmeCellsInZolaContent(root string, report *ValidationReport) {
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
for _, section := range sections {
files, err := listMarkdownFiles(root, section)
if err != nil {
continue
}
for _, path := range files {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
for i, line := range strings.Split(string(data), "\n") {
if m := runmeFenceLine.FindStringSubmatch(strings.TrimRight(line, " \t")); m != nil && m[2] != "" {
var attrs map[string]any
if json.Unmarshal([]byte(m[2]), &attrs) == nil {
if _, ok := attrs["name"]; ok {
report.add("error", "runme_cell_in_zola_content", path, "content/ es publicado por Zola; las celdas de Runme deben vivir en runbooks/", map[string]any{"line": i + 1})
}
}
}
}
}
}
}
// checkRunmeSessionArtifacts es una defensa best-effort: la documentación
// pública de Runme (docs.runme.dev/usage/auto-save) no fija un nombre de
// archivo exacto para los Session Outputs en v3.17.4, así que en vez de un
// glob amplio en .gitignore (que podría ocultar Markdown legítimo), se
// buscan señales concretas y se documenta la limitación en docs/RUNME.md.
func (a *App) checkRunmeSessionArtifacts(root string, report *ValidationReport) {
runmeStateDir := filepath.Join(root, ".runme")
if info, err := os.Stat(runmeStateDir); err == nil && info.IsDir() {
report.add("error", "runme_state_dir_tracked", runmeStateDir, "existe un directorio .runme en el árbol de trabajo; nunca debe versionarse (estado local de Runme)", nil)
}
_ = filepath.WalkDir(filepath.Join(root, "runbooks"), func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
base := strings.ToLower(filepath.Base(path))
if strings.Contains(base, "output") && base != "_template.md" {
report.add("warning", "possible_runme_session_output", path, "el nombre sugiere un Session Output de Runme capturado; confirme que es contenido fuente y no una salida de ejecución antes de conservarlo", nil)
}
return nil
})
}
+168
View File
@@ -0,0 +1,168 @@
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
View File
@@ -109,6 +109,7 @@ func (a *App) runValidate(root string, args []string) error {
pages := a.validatePages(root, registry, &report) pages := a.validatePages(root, registry, &report)
a.validateEvents(root, registry, pages, &report) a.validateEvents(root, registry, pages, &report)
a.validateRunbooks(root, &report)
if len(report.Errors) == 0 { if len(report.Errors) == 0 {
if _, _, _, err := a.Sync(root, true); err != nil { if _, _, _, err := a.Sync(root, true); err != nil {
+50
View File
@@ -0,0 +1,50 @@
# Runbooks ejecutables
Esta carpeta vive **fuera** de `content/`, así que Zola nunca la construye ni
la publica (confirmado en `zola.toml`: no hay `content_dir` alterno). Es la
razón por la que aquí sí se pueden usar atributos de celda de Runme sin
arriesgar el front matter TOML ni el resaltado de sintaxis que sí usa
`content/manuales/`.
## Cómo abrir un runbook
- **Como notebook (Runme, VS Code):** instale la extensión recomendada en
[`.vscode/extensions.json`](../.vscode/extensions.json) (`stateful.runme`) y
abra el archivo `.md` normalmente; VS Code lo mostrará como notebook.
- **Como Markdown normal:** clic derecho sobre la pestaña → "Reopen Editor
With..." → "Text Editor". Ningún runbook depende de estar en modo notebook
para ser legible o copiable.
- **Por CLI:** `runme run --filename runbooks/<archivo>.md <nombre-de-celda>`.
- **Sin Runme instalado:** copie el bloque de comando y ejecútelo en su
terminal; ninguna celda usa sintaxis que no sea shell/`make`/`clusterlog`
normal.
## Convención de nombres
`kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo
dentro de un archivo): `clusterlog-build`, `clusterlog-validate-local`,
`clusterlog-review-mine`. `clusterlog validate` (ver
`internal/clusterlog/runbooks.go`) falla si dos celdas repiten un nombre.
## Taxonomía de etiquetas
Toda celda debe declarar al menos una etiqueta de cada categoría:
| Categoría | Valores | Obligatoria |
|---|---|---|
| Audiencia | `role-developer`, `role-operator`, `role-reviewer`, `role-owner` | sí |
| Entorno | `env-local`, `env-ci`, `env-staging`, `env-production` | sí |
| Riesgo | `risk-read-only`, `risk-mutating`, `risk-destructive` | sí |
| Ejecución automatizada | `ci-safe`, `manual-only` | no (pero mutuamente excluyente con `risk-mutating`/`risk-destructive`) |
Una celda `risk-mutating` o `risk-destructive` **debe** llevar
`manual-only`, `"interactive":"true"` y `"excludeFromRunAll":true`, y
**no puede** llevar `ci-safe` al mismo tiempo. `clusterlog validate` hace
cumplir esto — no es sólo una convención de estilo.
## Qué no va en una celda
Ver 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.
+99
View File
@@ -0,0 +1,99 @@
# <Título del runbook>
> Copie este archivo, renómbrelo en `kebab-case` y borre los comentarios
> `> así`. No borre las secciones: si alguna no aplica, escriba "No aplica" y
> explique por qué en una línea.
## Título
`<slug-del-runbook>`
## Propósito
> Qué logra este runbook en una o dos frases. Si no puede explicarlo en dos
> frases, probablemente son dos runbooks.
## Propietario
> Rol o persona responsable de mantenerlo actualizado.
## Audiencia
> Uno o más de: `role-developer`, `role-operator`, `role-reviewer`, `role-owner`.
## Entornos permitidos
> Uno o más de: `env-local`, `env-ci`, `env-staging`, `env-production`.
## Clasificación de riesgo
> `risk-read-only`, `risk-mutating` o `risk-destructive`, por celda — no
> necesariamente una sola clasificación para todo el documento.
## Prerrequisitos
> Herramientas, variables de entorno, accesos y estado previo necesarios.
> Distinga:
> - **Observación**: qué debe ser cierto antes de empezar.
> - **Hipótesis**: qué se espera que pase, si aplica (para runbooks de
> diagnóstico).
## Variables requeridas
> Nombre, propósito y si es secreta (nunca ponga el valor aquí; use
> `.env.example` como referencia de nombres).
## Preflight
> Celda de sólo lectura que confirma que el entorno está listo, sin instalar
> ni mutar nada.
```sh {"name":"template-preflight-example","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
echo "reemplace esto por una comprobación real de prerrequisitos"
```
## Procedimiento
> Distinga explícitamente:
> - **Acción**: qué comando se ejecuta.
> - **Resultado esperado**: qué debería observarse si funcionó.
Ejemplo de celda de sólo lectura (segura para CI):
```sh {"name":"template-safe-example","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
echo "una celda de sólo lectura va aquí"
```
Ejemplo conceptual de celda que muta estado (nunca copie literalmente sin
adaptar prerrequisitos y validación):
```sh {"name":"template-mutating-example","tag":"role-operator,env-production,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
echo "una celda que muta estado real va aquí; nunca risk-mutating + ci-safe"
```
## Validación
> Cómo se confirma que el resultado observado es el esperado. Comandos
> concretos, no "revisar que todo esté bien".
## Rollback
> Cómo revertir, si aplica. Si no hay reversión directa, dígalo explícitamente
> y describa el procedimiento de corrección manual.
## Registro en Clusterlog
> Qué memoria, tarea o commit debe generarse fuera de Runme como evidencia
> canónica de que esto se ejecutó.
## Evidencia
> Qué guardar como prueba (salida JSON, SHA de commit, ID de memoria).
## Referencias
> Enlaces a `CLI.md`, `SECURITY.md`, ADRs u otros runbooks relacionados.
## Limitaciones
> Qué este runbook explícitamente no cubre, y por qué.
+89
View File
@@ -0,0 +1,89 @@
# 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í.
+120
View File
@@ -0,0 +1,120 @@
# Reunión semanal de revisión — versión ejecutable
Este runbook es el complemento ejecutable de
[`docs/WEEKLY_REVIEW.md`](../docs/WEEKLY_REVIEW.md), que conserva la agenda y
el criterio de la reunión. Los comandos de preparación y cierre viven aquí
una sola vez; `docs/WEEKLY_REVIEW.md` enlaza a este archivo en vez de repetir
los bloques de shell.
## Propósito
Preparar y cerrar la reunión semanal sin copiar comandos a mano.
## Propietario
Cada administrador ejecuta su propia preparación con su propia llave.
## Audiencia
`role-reviewer`, `role-owner`.
## Entornos permitidos
`env-local` únicamente. Ningún comando de este runbook debe correr en CI:
todos requieren identidad SSH personal o mutan la cola de revisión.
## Clasificación de riesgo
Preparación: `risk-read-only`. Marcar una revisión: `risk-mutating`.
## Prerrequisitos
- `CLUSTERLOG_SSH_KEY` apuntando a su llave pública registrada.
- Haber leído las memorias pendientes antes de marcarlas.
## Variables requeridas
- `CLUSTERLOG_SSH_KEY`: ruta a su llave pública.
- `MEM_ID`: el `entry_id` exacto de la memoria que va a marcar (por ejemplo
`MEM-20260825-ABC123`), sólo para la celda de cierre.
## Preflight
```sh {"name":"weekly-review-identity","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" identity
```
## Procedimiento
### 1. Preparación individual
```sh {"name":"weekly-review-pending","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" review list --mine
```
```sh {"name":"weekly-review-my-tasks","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" task list --mine
```
### 2. Marcar una memoria como revisada (durante o después de la reunión)
```sh {"name":"weekly-review-mark","tag":"role-reviewer,env-production,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
: "${MEM_ID:?defina MEM_ID con el entry_id exacto de la memoria, por ejemplo MEM-20260825-ABC123}"
./bin/clusterlog --root . --json --commit --ssh-key "$CLUSTERLOG_SSH_KEY" \
review mark "$MEM_ID" --note "Revisado en reunión semanal"
```
Prerrequisito adicional de esta celda: haber leído y entendido la memoria
completa, no sólo su título. `env-production` porque un `--commit` exitoso
queda en el historial de Git compartido — no es una operación de laboratorio.
### 3. Cierre
```sh {"name":"weekly-review-close-check","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
make sync-check
```
```sh {"name":"weekly-review-close-validate","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
make validate-local
```
```sh {"name":"weekly-review-close-verify","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
./bin/clusterlog --root . verify commit HEAD
```
## Validación
`review list --mine` debe quedar vacío (o con causa y fecha de seguimiento
documentada aparte, según `docs/WEEKLY_REVIEW.md`).
## Rollback
`review mark` no tiene reversión directa: agrega un evento inmutable en
`data/review-events/`. Si se marcó por error, no se edita el evento; se
documenta el error en la siguiente memoria y se decide en equipo si se
requiere una excepción de política (ver "Preguntas de gobierno" en
`HOJA_DE_PROYECTO.md`).
## Registro en Clusterlog
`review mark --commit` ya deja su propio commit firmado; no hace falta una
memoria adicional sólo por marcar una revisión.
## Evidencia
Salida JSON de `review mark` (contiene el hash de contenido revisado) y el
SHA del commit que produce.
## Referencias
- [`docs/WEEKLY_REVIEW.md`](../docs/WEEKLY_REVIEW.md)
- [`CLI.md`](../CLI.md), sección 7 (Revisión).
## Limitaciones
Ninguna celda de este runbook es `ci-safe`: todas requieren una llave SSH
personal o mutan estado compartido. Esto es intencional, no una omisión.
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Comprobación de compatibilidad de los runbooks. No escribe nada en el
# repositorio. Las reglas duras (nombres únicos, etiquetas, aislamiento de
# celdas mutantes, secretos, referencias a scripts/targets inexistentes)
# viven en `clusterlog validate` (internal/clusterlog/runbooks.go) y corren
# siempre, con o sin Runme instalado. Este script agrega, por encima, un
# chequeo blando de que Runme puede parsear cada runbook de verdad.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
echo "== Reglas de clusterlog validate (obligatorio) =="
"$BIN" --root "$ROOT" --json validate --skip-zola
if ! command -v runme >/dev/null 2>&1; then
echo "== runme no está instalado: se omite el chequeo de compatibilidad de Runme (make runme-install) =="
exit 0
fi
echo "== Compatibilidad con la versión fijada de Runme (blando) =="
status=0
files=("$ROOT/RUNBOOK.md")
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$ROOT/runbooks" -name '*.md' -print0 2>/dev/null)
for f in "${files[@]}"; do
rel="${f#"$ROOT"/}"
if runme ls --filename "$f" >/dev/null 2>&1; then
echo " ok: $rel"
else
echo " ADVERTENCIA: runme no pudo parsear $rel" >&2
status=1
fi
done
if [ "$status" -ne 0 ]; then
echo "runme-check: advertencias de compatibilidad de Runme (no bloqueante)." >&2
fi
exit 0
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Instala una versión fijada de Runme (nunca "latest") verificando su
# checksum SHA-256 contra los valores publicados en el release oficial.
# No sustituye ni toca bin/clusterlog. No usa curl|sh.
set -euo pipefail
RUNME_VERSION="3.17.4"
INSTALL_DIR="${RUNME_INSTALL_DIR:-$HOME/.local/bin}"
BASE_URL="https://github.com/runmedev/runme/releases/download/v${RUNME_VERSION}"
# SHA-256 tomados del checksums.txt publicado en el release v3.17.4 y
# verificados de forma independiente (sha256sum) contra el tarball real
# durante la implementación de esta integración.
CHECKSUM_LINUX_X86_64="78af7ed33a84ff91a617a018886730038f10af8f73027451d322c21c417fed2c"
CHECKSUM_LINUX_ARM64="fd016b92115d7f77806c70619ee113dc951c49398863e54bc83eadb9d48665e4"
CHECKSUM_DARWIN_X86_64="7c69077ad7331725fa5224fab95562089a42c95baad42b130e1b0b3774ebc480"
CHECKSUM_DARWIN_ARM64="cba556395ca24ccee641ee2faa20cc5a4150826205371de354aafc4a4d830381"
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux)
case "$arch" in
x86_64) asset="runme_linux_x86_64.tar.gz"; checksum="$CHECKSUM_LINUX_X86_64" ;;
aarch64|arm64) asset="runme_linux_arm64.tar.gz"; checksum="$CHECKSUM_LINUX_ARM64" ;;
*) echo "Arquitectura Linux no soportada por este script: $arch" >&2; exit 1 ;;
esac
;;
Darwin)
case "$arch" in
x86_64) asset="runme_darwin_x86_64.tar.gz"; checksum="$CHECKSUM_DARWIN_X86_64" ;;
arm64) asset="runme_darwin_arm64.tar.gz"; checksum="$CHECKSUM_DARWIN_ARM64" ;;
*) echo "Arquitectura macOS no soportada por este script: $arch" >&2; exit 1 ;;
esac
;;
*)
echo "Sistema operativo no soportado por este script: $os" >&2
echo "En Windows, descargue manualmente runme_windows_{x86_64,arm64}.zip desde" >&2
echo " https://github.com/runmedev/runme/releases/tag/v${RUNME_VERSION}" >&2
echo "y verifique su checksum contra checksums.txt del mismo release." >&2
exit 1
;;
esac
if command -v runme >/dev/null 2>&1; then
current="$(runme --version 2>/dev/null | awk '{print $3}')"
if [ "$current" = "$RUNME_VERSION" ]; then
echo "runme $RUNME_VERSION ya está instalado en $(command -v runme); nada que hacer."
exit 0
fi
fi
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
echo "Descargando $asset (runme v${RUNME_VERSION})..."
curl -fsSL -o "$tmp_dir/$asset" "$BASE_URL/$asset"
echo "Verificando checksum SHA-256..."
computed="$(sha256sum "$tmp_dir/$asset" | awk '{print $1}')"
if [ "$computed" != "$checksum" ]; then
echo "ERROR: checksum no coincide para $asset" >&2
echo " esperado: $checksum" >&2
echo " obtenido: $computed" >&2
exit 1
fi
echo "Checksum verificado."
mkdir -p "$INSTALL_DIR"
tar xzf "$tmp_dir/$asset" -C "$tmp_dir" runme
install -m 0755 "$tmp_dir/runme" "$INSTALL_DIR/runme"
if [ -e "$INSTALL_DIR/clusterlog" ] || [ -e "$INSTALL_DIR/bin/clusterlog" ]; then
echo "ERROR: se detectó un binario 'clusterlog' en $INSTALL_DIR; abortando para no sobrescribirlo." >&2
exit 1
fi
echo "runme instalado en $INSTALL_DIR/runme"
"$INSTALL_DIR/runme" --version
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "Agregue $INSTALL_DIR a su PATH para usar 'runme' directamente." ;;
esac