bootstrap: iniciar bitácora operativa

This commit is contained in:
2026-08-13 00:43:15 -06:00
commit 1ecfb022b1
85 changed files with 7967 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
package clusterlog
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type ValidationIssue struct {
Level string `json:"level"`
Code string `json:"code"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
type ValidationReport struct {
Valid bool `json:"valid"`
Errors []ValidationIssue `json:"errors"`
Warnings []ValidationIssue `json:"warnings"`
Checks map[string]int `json:"checks"`
ZolaChecked bool `json:"zola_checked"`
}
type pageRecord struct {
Section string
Meta PageMeta
ID string
}
func (r *ValidationReport) add(level, code, path, message string, details any) {
issue := ValidationIssue{Level: level, Code: code, Path: filepath.ToSlash(path), Message: message, Details: details}
if level == "warning" {
r.Warnings = append(r.Warnings, issue)
} else {
r.Errors = append(r.Errors, issue)
}
}
func valueStrings(value any) []string {
switch v := value.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, fmt.Sprint(item))
}
return out
case string:
if v != "" {
return []string{v}
}
}
return nil
}
func fingerprintBelongs(registry AdminRegistry, adminID, fingerprint string) bool {
admin, ok := adminByID(registry, adminID)
if !ok {
return false
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return true
}
}
return false
}
func validTaskStatus(status string) bool {
switch status {
case "backlog", "todo", "in_progress", "blocked", "done", "cancelled":
return true
default:
return false
}
}
func validProjectStatus(status string) bool {
switch status {
case "planned", "active", "blocked", "done", "completed", "cancelled", "archived":
return true
default:
return false
}
}
func (a *App) runValidate(root string, args []string) error {
fs := newFlagSet("validate")
skipZola := fs.Bool("skip-zola", false, "omite zola check")
requireZola := fs.Bool("require-zola", false, "falla si Zola no está instalado")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
registry, err := loadAdmins(root)
if err != nil {
report.add("error", "admins_invalid", filepath.Join(root, "data", "admins.json"), err.Error(), nil)
} else {
a.validateAdmins(root, registry, &report)
}
pages := a.validatePages(root, registry, &report)
a.validateEvents(root, registry, pages, &report)
if len(report.Errors) == 0 {
if _, _, _, err := a.Sync(root, true); err != nil {
report.add("error", "generated_state_stale", filepath.Join(root, "data", "generated", "site_state.json"), err.Error(), nil)
} else {
report.Checks["generated_state"] = 1
}
}
if !*skipZola {
zolaPath, lookupErr := exec.LookPath("zola")
if lookupErr != nil {
if *requireZola {
report.add("error", "zola_missing", "", "Zola no está instalado o no está en PATH", nil)
} else {
report.add("warning", "zola_missing", "", "Zola no está instalado; se omitió la validación de plantillas y enlaces", nil)
}
} else {
report.ZolaChecked = true
cmd := exec.Command(zolaPath, "check")
cmd.Dir = root
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
report.add("error", "zola_check_failed", filepath.Join(root, "zola.toml"), "zola check falló", strings.TrimSpace(output.String()))
} else {
report.Checks["zola"] = 1
}
}
}
report.Valid = len(report.Errors) == 0
if !report.Valid {
return NewError(ExitValidation, "validation_failed", "la validación del proyecto encontró errores", report)
}
warnings := make([]string, 0, len(report.Warnings))
for _, issue := range report.Warnings {
warnings = append(warnings, issue.Message)
}
return a.printResult("validate", report, warnings...)
}
func (a *App) validateAdmins(root string, registry AdminRegistry, report *ValidationReport) {
if registry.ReviewPolicy.Default != "all-active" && registry.ReviewPolicy.Default != "all-active-except-author" {
report.add("error", "invalid_default_review_policy", filepath.Join(root, "data", "admins.json"), "review_policy.default no es válido", registry.ReviewPolicy.Default)
}
ids := map[string]string{}
emails := map[string]string{}
fingerprints := map[string]string{}
ownerCount := 0
for _, admin := range registry.Admins {
report.Checks["admins"]++
idKey := normalizeID(admin.ID)
if admin.ID == "" || slugify(admin.ID) != admin.ID {
report.add("error", "invalid_admin_id", filepath.Join(root, "data", "admins.json"), "el ID del administrador debe ser un slug estable en minúsculas", admin.ID)
}
if prior, ok := ids[idKey]; ok {
report.add("error", "duplicate_admin_id", filepath.Join(root, "data", "admins.json"), "ID de administrador duplicado", []string{prior, admin.ID})
}
ids[idKey] = admin.ID
emailKey := strings.ToLower(strings.TrimSpace(admin.Email))
if emailKey == "" || !strings.Contains(emailKey, "@") {
report.add("error", "invalid_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador inválido", admin.ID)
}
if prior, ok := emails[emailKey]; ok {
report.add("error", "duplicate_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador duplicado", []string{prior, admin.ID})
}
emails[emailKey] = admin.ID
if hasRole(admin, "owner") && admin.Active {
ownerCount++
}
if admin.Active && len(admin.SSHKeys) == 0 {
report.add("error", "active_admin_without_key", filepath.Join(root, "data", "admins.json"), "un administrador activo debe tener al menos una llave SSH", admin.ID)
}
for _, key := range admin.SSHKeys {
report.Checks["ssh_keys"]++
_, computed, err := parsePublicKeyLine(key.PublicKey)
if err != nil {
report.add("error", "invalid_public_key", filepath.Join(root, "data", "admins.json"), err.Error(), admin.ID)
continue
}
if key.Fingerprint != computed {
report.add("error", "fingerprint_mismatch", filepath.Join(root, "data", "admins.json"), "la huella almacenada no corresponde a la llave pública", map[string]any{"admin_id": admin.ID, "stored": key.Fingerprint, "computed": computed})
}
if prior, ok := fingerprints[key.Fingerprint]; ok {
report.add("error", "duplicate_ssh_key", filepath.Join(root, "data", "admins.json"), "una llave SSH está asignada a más de un administrador", []string{prior, admin.ID})
}
fingerprints[key.Fingerprint] = admin.ID
}
}
if len(registry.Admins) > 0 && ownerCount == 0 {
report.add("error", "missing_owner", filepath.Join(root, "data", "admins.json"), "debe existir al menos un administrador activo con rol owner", nil)
}
allowedPath := filepath.Join(root, "config", "allowed_signers")
allowedData, err := os.ReadFile(allowedPath)
if err != nil {
report.add("error", "allowed_signers_missing", allowedPath, "no se pudo leer el archivo de firmantes autorizados", err.Error())
return
}
allowed := string(allowedData)
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
fields := strings.Fields(key.PublicKey)
if len(fields) < 2 {
continue
}
expected := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
if !strings.Contains(allowed, expected) {
report.add("error", "allowed_signer_missing", allowedPath, "falta una llave activa en allowed_signers", map[string]any{"admin_id": admin.ID, "fingerprint": key.Fingerprint})
}
}
}
}
func (a *App) validatePages(root string, registry AdminRegistry, report *ValidationReport) []pageRecord {
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
pages := []pageRecord{}
ids := []string{}
for _, section := range sections {
files, err := listMarkdownFiles(root, section)
if err != nil {
report.add("error", "content_walk_failed", filepath.Join(root, "content", section), err.Error(), nil)
continue
}
for _, path := range files {
report.Checks["content_pages"]++
data, readErr := os.ReadFile(path)
if readErr != nil {
report.add("error", "content_read_failed", path, readErr.Error(), nil)
continue
}
if findings := scanSecrets(string(data)); len(findings) > 0 {
report.add("error", "possible_secret", path, "el contenido parece incluir secretos", findings)
}
meta, parseErr := parseMarkdown(path)
if parseErr != nil {
report.add("error", "front_matter_invalid", path, parseErr.Error(), nil)
continue
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
report.add("error", "missing_page_id", path, "la página no tiene extra.id", nil)
}
ids = append(ids, id)
pages = append(pages, pageRecord{Section: section, Meta: meta, ID: id})
if strings.TrimSpace(meta.String("title")) == "" {
report.add("error", "missing_title", path, "la página no tiene título", nil)
}
authors := valueStrings(meta.Root["authors"])
for _, author := range authors {
if _, ok := adminByID(registry, author); !ok && author != "sistema" {
report.add("error", "unknown_author", path, "la página referencia un autor no registrado", author)
}
}
}
}
for _, duplicate := range pageIDDuplicates(ids) {
report.add("error", "duplicate_page_id", filepath.Join(root, "content"), "hay IDs de contenido duplicados", duplicate)
}
projectIDs := map[string]bool{}
for _, page := range pages {
if page.Section == "proyectos" {
projectIDs[page.ID] = true
}
}
for _, page := range pages {
path := page.Meta.Path
switch page.Section {
case "memorias":
if !strings.HasPrefix(page.ID, "MEM-") {
report.add("error", "invalid_memory_id", path, "el ID de memoria debe iniciar con MEM-", page.ID)
}
authorID := page.Meta.ExtraString("author_id")
if _, ok := adminByID(registry, authorID); !ok && authorID != "sistema" {
report.add("error", "unknown_memory_author", path, "author_id no corresponde a un administrador", authorID)
}
fingerprint := page.Meta.ExtraString("author_fingerprint")
if authorID != "sistema" && !fingerprintBelongs(registry, authorID, fingerprint) {
report.add("error", "memory_fingerprint_mismatch", path, "author_fingerprint no pertenece al autor", map[string]any{"author_id": authorID, "fingerprint": fingerprint})
}
policy := page.Meta.ExtraString("review_policy")
if policy != "" && policy != "all-active" && policy != "all-active-except-author" {
report.add("error", "invalid_review_policy", path, "review_policy no es válido", policy)
}
if page.Meta.ExtraBool("reviewer_snapshot") {
seenReviewers := map[string]bool{}
for _, reviewer := range page.Meta.ExtraStrings("reviewers") {
if seenReviewers[reviewer] {
report.add("error", "duplicate_reviewer", path, "un revisor aparece más de una vez", reviewer)
}
seenReviewers[reviewer] = true
if _, ok := adminByID(registry, reviewer); !ok {
report.add("error", "unknown_reviewer", path, "el conjunto capturado referencia un administrador inexistente", reviewer)
}
}
}
case "tareas":
if !strings.HasPrefix(page.ID, "TSK-") {
report.add("error", "invalid_task_id", path, "el ID de tarea debe iniciar con TSK-", page.ID)
}
priority := page.Meta.ExtraString("priority")
if priority != "low" && priority != "medium" && priority != "high" && priority != "urgent" {
report.add("error", "invalid_task_priority", path, "priority no es válida", priority)
}
status := firstNonEmpty(page.Meta.ExtraString("status"), "todo")
if !validTaskStatus(status) {
report.add("error", "invalid_task_status", path, "status no es válido", status)
}
if due := page.Meta.ExtraString("due"); validateDate(due) != nil {
report.add("error", "invalid_due", path, "due debe usar YYYY-MM-DD", due)
}
projectID := normalizeID(page.Meta.ExtraString("project_id"))
if projectID != "" && !projectIDs[projectID] {
report.add("error", "unknown_project", path, "project_id no existe", projectID)
}
for _, assignee := range page.Meta.ExtraStrings("assignees") {
admin, ok := adminByID(registry, assignee)
if !ok || !admin.Active {
report.add("error", "invalid_assignee", path, "assignee no existe o está inactivo", assignee)
}
}
case "proyectos":
if !strings.HasPrefix(page.ID, "PRJ-") {
report.add("error", "invalid_project_id", path, "el ID de proyecto debe iniciar con PRJ-", page.ID)
}
status := firstNonEmpty(page.Meta.ExtraString("status"), "planned")
if !validProjectStatus(status) {
report.add("error", "invalid_project_status", path, "status no es válido", status)
}
if target := page.Meta.ExtraString("target_date"); validateDate(target) != nil {
report.add("error", "invalid_target_date", path, "target_date debe usar YYYY-MM-DD", target)
}
for _, owner := range page.Meta.ExtraStrings("owners") {
admin, ok := adminByID(registry, owner)
if !ok || !admin.Active {
report.add("error", "invalid_owner", path, "owner no existe o está inactivo", owner)
}
}
case "documentacion":
if !strings.HasPrefix(page.ID, "DOC-") {
report.add("error", "invalid_document_id", path, "el ID de documentación debe iniciar con DOC-", page.ID)
}
case "manuales":
if !strings.HasPrefix(page.ID, "RUN-") {
report.add("error", "invalid_manual_id", path, "el ID de manual debe iniciar con RUN-", page.ID)
}
}
}
return pages
}
func (a *App) validateEvents(root string, registry AdminRegistry, pages []pageRecord, report *ValidationReport) {
memories := map[string]pageRecord{}
tasks := map[string]pageRecord{}
for _, page := range pages {
if page.Section == "memorias" {
memories[page.ID] = page
}
if page.Section == "tareas" {
tasks[page.ID] = page
}
}
reviews, err := loadReviewLog(root)
if err != nil {
report.add("error", "review_log_invalid", filepath.Join(root, "data", reviewEventsDirName), err.Error(), nil)
} else {
seen := map[string]bool{}
for _, event := range reviews.Events {
report.Checks["review_events"]++
if seen[event.ID] {
report.add("error", "duplicate_review_event", filepath.Join(root, "data", reviewEventsDirName), "ID de evento de revisión duplicado", event.ID)
}
seen[event.ID] = true
if _, ok := memories[normalizeID(event.EntryID)]; !ok {
report.add("warning", "orphan_review_event", filepath.Join(root, "data", reviewEventsDirName), "el evento apunta a una memoria que ya no está en el árbol actual", event.EntryID)
}
if _, ok := adminByID(registry, event.AdminID); !ok {
report.add("error", "unknown_review_admin", filepath.Join(root, "data", reviewEventsDirName), "admin_id de revisión no existe", event.AdminID)
}
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
report.add("error", "review_fingerprint_mismatch", filepath.Join(root, "data", reviewEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
}
if _, parseErr := time.Parse(time.RFC3339, event.ReviewedAt); parseErr != nil {
report.add("error", "invalid_review_time", filepath.Join(root, "data", reviewEventsDirName), "reviewed_at no es RFC3339", event.ID)
}
if len(event.ContentSHA256) != 64 {
report.add("error", "invalid_review_hash", filepath.Join(root, "data", reviewEventsDirName), "content_sha256 no tiene longitud SHA-256", event.ID)
}
}
}
log, err := loadTaskLog(root)
if err != nil {
report.add("error", "task_log_invalid", filepath.Join(root, "data", taskEventsDirName), err.Error(), nil)
return
}
seen := map[string]bool{}
activeTimer := map[string]TaskEvent{}
totalMinutes := map[string]int{}
for _, event := range log.Events {
report.Checks["task_events"]++
if seen[event.ID] {
report.add("error", "duplicate_task_event", filepath.Join(root, "data", taskEventsDirName), "ID de evento de tarea duplicado", event.ID)
}
seen[event.ID] = true
if _, ok := tasks[normalizeID(event.TaskID)]; !ok {
report.add("error", "unknown_task_event_target", filepath.Join(root, "data", taskEventsDirName), "el evento apunta a una tarea inexistente", event.TaskID)
}
if _, ok := adminByID(registry, event.AdminID); !ok {
report.add("error", "unknown_task_admin", filepath.Join(root, "data", taskEventsDirName), "admin_id de evento no existe", event.AdminID)
}
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
report.add("error", "task_fingerprint_mismatch", filepath.Join(root, "data", taskEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
}
if _, parseErr := time.Parse(time.RFC3339, event.At); parseErr != nil {
report.add("error", "invalid_task_event_time", filepath.Join(root, "data", taskEventsDirName), "at no es RFC3339", event.ID)
}
switch event.Type {
case "start":
if active, ok := activeTimer[event.AdminID]; ok {
code := "duplicate_task_start"
message := "el administrador inició dos veces la misma tarea sin detenerla"
if normalizeID(active.TaskID) != normalizeID(event.TaskID) {
code = "concurrent_task_timers"
message = "el administrador tiene más de un temporizador activo"
}
report.add("error", code, filepath.Join(root, "data", taskEventsDirName), message, map[string]any{
"admin_id": event.AdminID, "active_event": active.ID, "new_event": event.ID,
})
}
activeTimer[event.AdminID] = event
case "stop":
active, ok := activeTimer[event.AdminID]
if !ok || normalizeID(active.TaskID) != normalizeID(event.TaskID) {
report.add("error", "task_stop_without_start", filepath.Join(root, "data", taskEventsDirName), "el evento stop no corresponde a un temporizador activo", event.ID)
} else {
delete(activeTimer, event.AdminID)
}
if event.Minutes <= 0 {
report.add("error", "invalid_stop_minutes", filepath.Join(root, "data", taskEventsDirName), "un evento stop debe registrar minutos positivos", event.ID)
}
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
case "adjust":
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
if event.Minutes == 0 {
report.add("error", "invalid_adjust_minutes", filepath.Join(root, "data", taskEventsDirName), "un ajuste no puede ser cero", event.ID)
}
case "status":
if !validTaskStatus(event.Status) {
report.add("error", "invalid_status_event", filepath.Join(root, "data", taskEventsDirName), "status no es válido", event.Status)
}
default:
report.add("error", "invalid_task_event_type", filepath.Join(root, "data", taskEventsDirName), "tipo de evento desconocido", event.Type)
}
}
for taskID, minutes := range totalMinutes {
if minutes < 0 {
report.add("error", "negative_task_time", filepath.Join(root, "data", taskEventsDirName), "los eventos dejan el tiempo acumulado por debajo de cero", map[string]any{
"task_id": taskID, "actual_minutes": minutes,
})
}
}
// La lectura estable de errores facilita comparaciones en CI y por agentes.
sort.Slice(report.Errors, func(i, j int) bool {
if report.Errors[i].Path == report.Errors[j].Path {
return report.Errors[i].Code < report.Errors[j].Code
}
return report.Errors[i].Path < report.Errors[j].Path
})
sort.Slice(report.Warnings, func(i, j int) bool {
if report.Warnings[i].Path == report.Warnings[j].Path {
return report.Warnings[i].Code < report.Warnings[j].Code
}
return report.Warnings[i].Path < report.Warnings[j].Path
})
}