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:
@@ -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=
|
||||
@@ -76,3 +76,33 @@ jobs:
|
||||
|
||||
- name: Fin del pipeline
|
||||
run: echo "listo"
|
||||
|
||||
runme:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DO_NOT_TRACK: "true"
|
||||
SCARF_NO_ANALYTICS: "true"
|
||||
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
@@ -4,3 +4,14 @@
|
||||
/themes/zola.386/
|
||||
.DS_Store
|
||||
*.tmp
|
||||
|
||||
# Runme: variables de entorno locales, nunca valores reales versionados.
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.direnv/
|
||||
|
||||
# No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver
|
||||
# docs/RUNME.md): 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/).
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["stateful.runme"]
|
||||
}
|
||||
@@ -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.
|
||||
- `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
|
||||
|
||||
Antes de entregar:
|
||||
|
||||
@@ -23,6 +23,12 @@ Sigue primero `AGENTS.md`. Estas reglas son específicas para sesiones de admini
|
||||
- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada.
|
||||
- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado.
|
||||
|
||||
## Runbooks ejecutables (Runme)
|
||||
|
||||
- No actives una celda `risk-mutating`/`risk-destructive` de `RUNBOOK.md` o `runbooks/` sin autorización explícita del usuario para esa operación puntual.
|
||||
- Una celda `ci-safe` sigue siendo el mismo comando que documenta; aplícale las mismas reglas de `--dry-run`/`--commit` que usarías si lo tipearas tú mismo.
|
||||
- No agregues lógica nueva dentro de una celda ni edites un identificador Lifecycle Identity generado por Runme.
|
||||
|
||||
## Formato de respuesta al usuario
|
||||
|
||||
```text
|
||||
|
||||
@@ -3,7 +3,7 @@ GO ?= go
|
||||
ZOLA ?= zola
|
||||
BINARY ?= bin/clusterlog
|
||||
|
||||
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean
|
||||
.PHONY: all build 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
|
||||
|
||||
@@ -49,3 +49,15 @@ ci: theme test vet e2e build sync sync-check validate
|
||||
|
||||
clean:
|
||||
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
|
||||
|
||||
@@ -152,6 +152,17 @@ make site # tema + estado + validación completa + sitio
|
||||
make ci # equivalente local de la canalización
|
||||
```
|
||||
|
||||
## Documentación ejecutable (Runme)
|
||||
|
||||
Los procedimientos frecuentes también existen como notebooks ejecutables,
|
||||
sin reemplazar ningún comando canónico: [`RUNBOOK.md`](RUNBOOK.md) es el
|
||||
punto de entrada. Requiere Runme `v3.17.4` (`make runme-install`) sólo si se
|
||||
quiere abrir como notebook o correr por CLI; cada celda es, además, un
|
||||
bloque de shell normal que sigue funcionando copiado a mano. 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
|
||||
|
||||
```text
|
||||
@@ -176,6 +187,7 @@ docs/ decisiones arquitectónicas y operación
|
||||
- [`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.
|
||||
|
||||
|
||||
+95
@@ -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
@@ -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;
|
||||
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
|
||||
|
||||
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
@@ -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).
|
||||
@@ -1,5 +1,10 @@
|
||||
# Reunión semanal de revisión operativa
|
||||
|
||||
> Versión ejecutable de los comandos de esta página:
|
||||
> [`runbooks/weekly-review.md`](../runbooks/weekly-review.md) (abrible como
|
||||
> notebook con Runme, o copiable a mano). Este documento conserva la agenda
|
||||
> y el criterio; los bloques de comando canónicos viven sólo ahí.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ func (a *App) runValidate(root string, args []string) error {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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é.
|
||||
@@ -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í.
|
||||
@@ -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.
|
||||
Executable
+41
@@ -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
|
||||
Executable
+84
@@ -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
|
||||
Reference in New Issue
Block a user