diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a10fd0b --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b3fb62f..1614173 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index cf93300..6c74c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/). diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..494f0b6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["stateful.runme"] +} diff --git a/AGENTS.md b/AGENTS.md index 9b00c97..83bef10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 23151ad..d8da6ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index 132ae74..80f68c2 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 462f0e6..b7e51b4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..f9fc035 --- /dev/null +++ b/RUNBOOK.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index edd35ac..6b762e0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/docs/RUNME.md b/docs/RUNME.md new file mode 100644 index 0000000..4f91689 --- /dev/null +++ b/docs/RUNME.md @@ -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 ` 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). diff --git a/docs/WEEKLY_REVIEW.md b/docs/WEEKLY_REVIEW.md index bca036b..9f5fa02 100644 --- a/docs/WEEKLY_REVIEW.md +++ b/docs/WEEKLY_REVIEW.md @@ -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. diff --git a/docs/adr/0004-runme-sidecar.md b/docs/adr/0004-runme-sidecar.md new file mode 100644 index 0000000..5807898 --- /dev/null +++ b/docs/adr/0004-runme-sidecar.md @@ -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. diff --git a/internal/clusterlog/runbooks.go b/internal/clusterlog/runbooks.go new file mode 100644 index 0000000..bda0673 --- /dev/null +++ b/internal/clusterlog/runbooks.go @@ -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 + }) +} diff --git a/internal/clusterlog/runbooks_test.go b/internal/clusterlog/runbooks_test.go new file mode 100644 index 0000000..cbde5d1 --- /dev/null +++ b/internal/clusterlog/runbooks_test.go @@ -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) + } +} diff --git a/internal/clusterlog/validate.go b/internal/clusterlog/validate.go index adf858a..a702c8c 100644 --- a/internal/clusterlog/validate.go +++ b/internal/clusterlog/validate.go @@ -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 { diff --git a/runbooks/README.md b/runbooks/README.md new file mode 100644 index 0000000..00e27b3 --- /dev/null +++ b/runbooks/README.md @@ -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/.md `. +- **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. diff --git a/runbooks/_template.md b/runbooks/_template.md new file mode 100644 index 0000000..0838984 --- /dev/null +++ b/runbooks/_template.md @@ -0,0 +1,99 @@ +# + +> 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 + +`` + +## 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é. diff --git a/runbooks/clusterlog-development.md b/runbooks/clusterlog-development.md new file mode 100644 index 0000000..fde85f5 --- /dev/null +++ b/runbooks/clusterlog-development.md @@ -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í. diff --git a/runbooks/weekly-review.md b/runbooks/weekly-review.md new file mode 100644 index 0000000..f28f798 --- /dev/null +++ b/runbooks/weekly-review.md @@ -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. diff --git a/scripts/runme-check.sh b/scripts/runme-check.sh new file mode 100755 index 0000000..7bdb4ef --- /dev/null +++ b/scripts/runme-check.sh @@ -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 diff --git a/scripts/runme-install.sh b/scripts/runme-install.sh new file mode 100755 index 0000000..3f067eb --- /dev/null +++ b/scripts/runme-install.sh @@ -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