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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:06:01 -06:00
co-authored by Claude Sonnet 5
parent fcee75fe83
commit c06037fa3b
22 changed files with 1682 additions and 1 deletions
+168
View File
@@ -0,0 +1,168 @@
package clusterlog
import (
"os"
"path/filepath"
"testing"
)
func newRunbookTestRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
mustWriteTestFile(t, filepath.Join(root, "Makefile"), ".PHONY: build test\nbuild:\n\techo build\ntest:\n\techo test\n")
mustWriteTestFile(t, filepath.Join(root, "scripts", "smoke.sh"), "#!/usr/bin/env bash\necho ok\n")
return root
}
func runRunbookValidation(t *testing.T, root string) *ValidationReport {
t.Helper()
report := &ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
app := &App{}
app.validateRunbooks(root, report)
return report
}
func hasErrorCode(report *ValidationReport, code string) bool {
for _, issue := range report.Errors {
if issue.Code == code {
return true
}
}
return false
}
func TestValidateRunbooksAcceptsWellFormedCell(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
"make build\n"+
"```\n")
report := runRunbookValidation(t, root)
if len(report.Errors) != 0 {
t.Fatalf("no se esperaban errores, hay: %+v", report.Errors)
}
if report.Checks["runme_cells"] != 1 {
t.Fatalf("se esperaba contar 1 celda, hay %d", report.Checks["runme_cells"])
}
}
func TestValidateRunbooksDetectsDuplicateNames(t *testing.T) {
root := newRunbookTestRoot(t)
cell := "```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\nmake build\n```\n"
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+cell)
mustWriteTestFile(t, filepath.Join(root, "runbooks", "clusterlog-development.md"), "# Dev\n\n"+cell)
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "duplicate_runme_cell_name") {
t.Fatalf("se esperaba duplicate_runme_cell_name, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRequiresTagCategories(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-build\",\"tag\":\"ci-safe\",\"interactive\":\"false\"}\n"+
"make build\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_cell_missing_tag_category") {
t.Fatalf("se esperaba runme_cell_missing_tag_category, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRequiresIsolationForMutatingCells(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-memory-create\",\"tag\":\"role-operator,env-production,risk-mutating,manual-only\"}\n"+
"make build\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_risk_cell_not_isolated") {
t.Fatalf("se esperaba runme_risk_cell_not_isolated, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRejectsCiSafeMutatingConflict(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-memory-create\",\"tag\":\"role-operator,env-production,risk-mutating,ci-safe\",\"interactive\":\"true\",\"excludeFromRunAll\":true}\n"+
"make build\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_ci_safe_risk_conflict") {
t.Fatalf("se esperaba runme_ci_safe_risk_conflict, errores: %+v", report.Errors)
}
if !hasErrorCode(report, "runme_risk_cell_not_manual_only") {
t.Fatalf("se esperaba runme_risk_cell_not_manual_only, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRejectsUnknownMakeTarget(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-deploy\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
"make deploy-to-mars\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_cell_unknown_make_target") {
t.Fatalf("se esperaba runme_cell_unknown_make_target, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRejectsUnknownScript(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-preflight\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
"./scripts/does-not-exist.sh\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_cell_unknown_script") {
t.Fatalf("se esperaba runme_cell_unknown_script, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksDetectsSecrets(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n\n"+
"```sh {\"name\":\"clusterlog-leak\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\",\"interactive\":\"false\"}\n"+
"export password: hunter2-super-secret\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "possible_secret") {
t.Fatalf("se esperaba possible_secret, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRejectsRunmeCellsInsideZolaContent(t *testing.T) {
root := newRunbookTestRoot(t)
if err := os.MkdirAll(filepath.Join(root, "content", "manuales"), 0o755); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, filepath.Join(root, "content", "manuales", "ejemplo.md"), "+++\ntitle = \"x\"\n+++\n\n"+
"```sh {\"name\":\"clusterlog-build\",\"tag\":\"role-developer,env-local,risk-read-only,ci-safe\"}\n"+
"make build\n"+
"```\n")
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_cell_in_zola_content") {
t.Fatalf("se esperaba runme_cell_in_zola_content, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksRejectsTrackedRunmeStateDir(t *testing.T) {
root := newRunbookTestRoot(t)
mustWriteTestFile(t, filepath.Join(root, "RUNBOOK.md"), "# Runbook\n")
if err := os.MkdirAll(filepath.Join(root, ".runme"), 0o755); err != nil {
t.Fatal(err)
}
report := runRunbookValidation(t, root)
if !hasErrorCode(report, "runme_state_dir_tracked") {
t.Fatalf("se esperaba runme_state_dir_tracked, errores: %+v", report.Errors)
}
}
func TestValidateRunbooksNoRunbooksIsNoop(t *testing.T) {
root := t.TempDir()
report := runRunbookValidation(t, root)
if len(report.Errors) != 0 || len(report.Warnings) != 0 {
t.Fatalf("sin runbooks no debería haber hallazgos: %+v / %+v", report.Errors, report.Warnings)
}
}